When the Type Is the Projection
This is the first concrete sketch. The previous four articles argued for the framing; this one starts asking what an API would look like under the framing. The case is types — the lowest-friction projection, the one where the existing language already has the most support, and the one where the DDD objection bites hardest.
The sketch in this article is type-check-only. None of it lives in a package; none of it is meant to be the proposed implementation. The point is to feel what the API would have to be like and whether the shape that emerges feels right or wrong.
The case: Intention<UserAuthentication>
Start with the upstream object. Under the hypothesis, an intention is a named thing in some registry:
declare const UserAuthentication: Intention<{
readonly name: 'UserAuthentication';
readonly purpose: 'gate-system-access-by-verified-identity';
readonly refines?: never;
}>;declare const UserAuthentication: Intention<{
readonly name: 'UserAuthentication';
readonly purpose: 'gate-system-access-by-verified-identity';
readonly refines?: never;
}>;This is a handle. It carries a brand and some metadata. It does not, by itself, do anything. What it permits is for other things to reference it.
Now consider the types that carry the intention:
@TypeExtension(UserAuthentication)
export type UserCredentials = {
readonly identifier: string;
readonly secret: Secret;
};
@TypeExtension(UserAuthentication)
export type AuthSession = {
readonly userId: UserId;
readonly issuedAt: Date;
readonly expiresAt: Date;
};
@TypeExtension(UserAuthentication)
export type AuthError =
| { kind: 'invalid-credentials' }
| { kind: 'session-expired' }
| { kind: 'rate-limited'; retryAfter: Date };@TypeExtension(UserAuthentication)
export type UserCredentials = {
readonly identifier: string;
readonly secret: Secret;
};
@TypeExtension(UserAuthentication)
export type AuthSession = {
readonly userId: UserId;
readonly issuedAt: Date;
readonly expiresAt: Date;
};
@TypeExtension(UserAuthentication)
export type AuthError =
| { kind: 'invalid-credentials' }
| { kind: 'session-expired' }
| { kind: 'rate-limited'; retryAfter: Date };What does @TypeExtension(UserAuthentication) claim? It claims that these three types are the type-projection of the UserAuthentication intention. Together — credentials, session, errors — they constitute everything the type system needs to express about that intention.
The claim is not that every type related to authentication is a TypeExtension of UserAuthentication. The claim is that these three types are the type extension — singular, complete, closed. If a fourth type appears (AuthAuditEntry, say), the decision is whether to add it to this projection or to recognise that a different intention (perhaps UserAuthenticationAuditability) has just appeared and is projecting its own types.
This singularity-and-completeness claim is the load-bearing one. It is what makes the projection a fact about the codebase rather than a casual annotation. It is also what makes the framing testable: an analyzer can ask, given an intention, whether its type projection is complete (do all promised types exist?) and whether it is closed (are there orphan types that are not in any projection but reference these?).
What the decorator does at runtime, what the codegen does at compile time
TypeScript type aliases do not have decorators. The decorator-on-type syntax above is not legal TypeScript. The kernel has to express the same fact through a different mechanism. There are two viable options, and the choice matters.
Option 1: companion runtime object. Each type-extension gets a paired runtime object that carries the decoration:
export type UserCredentials = {
readonly identifier: string;
readonly secret: Secret;
};
export const UserCredentials_meta = typeExtension({
intention: UserAuthentication,
name: 'UserCredentials',
});export type UserCredentials = {
readonly identifier: string;
readonly secret: Secret;
};
export const UserCredentials_meta = typeExtension({
intention: UserAuthentication,
name: 'UserCredentials',
});The runtime object exists only for scanner consumption. It is tree-shakable in production builds (no consumer references it; it is scanned at build time and dropped). The drawback is the naming convention — _meta suffix is fragile, and the developer can forget to write the runtime object, making the type a silent orphan that the scanner cannot detect.
Option 2: codegen-generated companion. The developer writes only the type, but adds a JSDoc-shaped marker:
/** @typeExtension UserAuthentication */
export type UserCredentials = {
readonly identifier: string;
readonly secret: Secret;
};/** @typeExtension UserAuthentication */
export type UserCredentials = {
readonly identifier: string;
readonly secret: Secret;
};The ts-codegen-pipeline reads the marker via the AST, generates the companion runtime object into src/generated/, and the scanner consumes the generated objects. The developer's source file stays clean. The orphan-detection problem disappears because the codegen also generates a manifest of all type extensions per intention, and the analyzer can compare the manifest against the intention's expected projection.
Option 2 is heavier (it requires the codegen pipeline) but it is the option that survives the singularity-and-completeness claim. Without codegen-driven manifest generation, the scanner can know what extensions exist but not what extensions should exist. The completeness check requires both sides of the comparison, and only the codegen can produce the side that says "these are the types the intention promises."
I lean toward Option 2. The lean is contingent on the codegen pipeline already being a dependency the project is willing to carry. For projects unwilling to carry codegen, Option 1 is a degraded but workable fallback.
What the analyzer would catch
If the API above were implemented, the analyzer could catch at least these failure modes:
- Orphan type. A type that references
UserId,Secret, or other types in the projection but is not itself decorated with@TypeExtension(UserAuthentication). Either it belongs to the projection and the decoration is missing, or it belongs to a different intention and the relationship should be explicit. - Missing projection. An intention declared but not projected into any type extension. The intention is unrealized — it has no type-level evidence. This is one of the failure modes the existing kernel cannot detect because it has no notion of "intention promises a type projection."
- Conflicting projection. Two intentions claiming the same type. Under the singularity claim, a type belongs to exactly one type-projection. Two claims is a model inconsistency.
- Stale projection. A type extension that references an intention which has been removed or renamed. Currently this would fail at compile time only if the intention is a value reference; under the codegen option, the manifest catches it even if the reference is purely structural.
These four catches are real. They are also, individually, things one could catch with one-off lint rules. The bet is that all four fall out of the kernel's existence rather than being added per failure mode.
The objection: this is DDD value objects with decorator paint
The objection is sharp and I want to give it space.
A DDD analysis of the same example would model UserCredentials, AuthSession, and AuthError as value objects in the UserAuthentication bounded context. The bounded context would carry the conceptual unity that the proposed Intention<UserAuthentication> carries. The value object boundaries would carry the conceptual coherence that the proposed @TypeExtension decoration carries. The orphan detection, the missing projection detection, the conflict detection — all of these have analogs in a DDD-tooled codebase (the ts-ddd series sketches several of them, including @ValueObject and @BoundedContext analyzers that do roughly this work).
If the DDD analyzer suite already does this work, what does @TypeExtension(Intention) add? The objection says: a different label and a slightly different mental model, with no new capability.
I want to engage this in three steps.
Step one: concede the overlap. For codebases that have committed to DDD modelling and use the ts-ddd analyzers, the type-extension framing produces no new capability. The bounded context is the intention; the value objects are the type extensions; the analyzers already do the four catches. Adding @TypeExtension on top would be redundant decoration. For these codebases, the framing is unnecessary, and I will say so in Part 9: if you use DDD, you already have this.
Step two: identify where the overlap ends. Not every codebase commits to DDD modelling. Many codebases — especially smaller libraries, internal tools, framework adapters — have a clear intention (an authentication library, a logging shim, a config loader) but no bounded-context boundary because there is no domain to bound. The intention/extension framing is intentionally shallower than DDD: it asks the question "what is this code projecting from?" without requiring an answer in domain terms. A 200-line authentication library has an intention even if it has no bounded context. The framing applies to it; DDD framework does not, or at least not without forcing.
Step three: identify the bidirectional capability the DDD framing does not promise. Even in a fully DDD-modelled codebase, the analyzers are typically one-way: given a class, ask which value object pattern it follows. The intention framing claims a bidirectional relation: given an intention, enumerate its type extensions; given a type extension, trace to its intention. The bidirectional manifest is a registry, and the registry enables operations that one-way analysis does not — for instance, "show me every artifact across the codebase that projects from UserAuthentication, across every extension kind." DDD analyzers do not promise this because DDD does not model the upstream object as a first-class registry entry; bounded contexts are organisational, not registered.
So the partial answer to "isn't this DDD with paint" is: for DDD-committed codebases, the overlap is substantial and the framing is redundant; for non-DDD codebases, the framing is a strictly weaker but cheaper alternative to going all-in on DDD; and the bidirectional registry is a capability that DDD does not typically deliver. The first two of those are admissions; the third is the residual claim.
Whether the residual claim is worth a kernel is the question the diagnostic in Part 10 is supposed to answer.
The open question for this article
The article leaves one specific question open: does the type-extension kernel require codegen, or is decorator metadata at runtime sufficient? I lean codegen because the completeness check requires a promised projection on the intention side, and that promise has to be expressible somewhere. The natural home for it is generated code emitted alongside the intention declaration. But Part 6 will sketch the method-extension case where pure runtime metadata might be enough, and the disagreement between Parts 5 and 6 is one I want to keep visible rather than paper over.
If the kernel ends up requiring codegen for some extension kinds and not others, the "shared kernel" claim is weakened — there is not really one kernel, there are several kernels with different runtime/compile-time stances. That weakening would be a partial collapse of the unification thesis. It would not be fatal but it would be expensive.
What stays open
I do not know whether the singularity-and-completeness claim survives in larger codebases. Real codebases tend to develop overlapping type families that resist clean projection-from-one-intention. I do not know whether the codegen-vs-decorator choice is a real fork or whether one side will obviously win. I do not know whether the bidirectional manifest is actually used enough to justify its existence — it might be a capability nobody asks for.
Part 6 takes the framing to the fine-grained case — class, method, function — where the cross-cutting question is unavoidable and the AOP objection is the one to engage.