When the Kind of Types Matters
This is the most abstract article in the series, and the one I am least confident in. The case is: an intention projects not into individual types but into a type discipline — a coherent family of types governed by a system, such that the projection's evidence is not any single type but the shape of the type-level relations among many types.
The example I keep working with is Intention<NoUnauthorizedAccess> projecting into a branded-type regime where the unauthorized code path is unrepresentable — not just guarded against at runtime, but literally impossible to write because the types do not compose.
The Haskell-envy objection cuts hardest here. I want to engage it honestly and partially concede.
The case: making the wrong call unrepresentable
Start with a function that needs to be called only on authenticated requests:
declare function deleteAccount(userId: UserId): Promise<void>;declare function deleteAccount(userId: UserId): Promise<void>;The type signature does not require authentication. A caller can invoke deleteAccount without any evidence that the caller has been authenticated. Authentication is runtime-checked somewhere upstream, presumably; the type system has no opinion on it.
Now consider the same function under a branded discipline:
type AuthenticatedContext = { readonly __auth: 'verified' } & unknown;
type AuthorizedFor<Op extends string> = AuthenticatedContext & { readonly __op: Op };
declare function deleteAccount(
userId: UserId,
ctx: AuthorizedFor<'deleteAccount'>,
): Promise<void>;
declare function authorize<Op extends string>(
session: AuthSession,
op: Op,
): AuthorizedFor<Op> | null;type AuthenticatedContext = { readonly __auth: 'verified' } & unknown;
type AuthorizedFor<Op extends string> = AuthenticatedContext & { readonly __op: Op };
declare function deleteAccount(
userId: UserId,
ctx: AuthorizedFor<'deleteAccount'>,
): Promise<void>;
declare function authorize<Op extends string>(
session: AuthSession,
op: Op,
): AuthorizedFor<Op> | null;The discipline carries the authentication evidence as a brand. The only way to produce an AuthorizedFor<'deleteAccount'> value is to call authorize, which requires an AuthSession and the operation name. There is no way to call deleteAccount without going through authorize. The type system enforces the precondition.
What is the intention here? It is something like: "no operation that requires authentication shall be callable without compile-time evidence that the caller has been authenticated for that operation." The intention does not project into a single type. It projects into:
- A brand (
__auth: 'verified'). - A parameterised brand (
__op: Op). - A capability constructor (
authorize). - A modification of every protected function's signature to require the brand.
The four pieces are not independent. They are co-produced: the projection of NoUnauthorizedAccess into the type-system category is the tuple of all four. Calling any of them a type extension on its own is incomplete; the system is the extension.
This is what TypeSystemExtension would have to model: not a single type, but a coherent discipline that mutually constrains a family of types.
Sketch of the API
The API has to express the discipline as a unit. A first attempt:
declare const NoUnauthorizedAccess: Intention<{
readonly name: 'NoUnauthorizedAccess';
readonly purpose: 'no-operation-callable-without-typed-authentication-evidence';
}>;
@TypeSystemExtension(NoUnauthorizedAccess)
export module AuthorizationDiscipline {
export type AuthenticatedContext = { readonly __auth: 'verified' } & unknown;
export type AuthorizedFor<Op extends string> = AuthenticatedContext & { readonly __op: Op };
export declare function authorize<Op extends string>(
session: AuthSession,
op: Op,
): AuthorizedFor<Op> | null;
}declare const NoUnauthorizedAccess: Intention<{
readonly name: 'NoUnauthorizedAccess';
readonly purpose: 'no-operation-callable-without-typed-authentication-evidence';
}>;
@TypeSystemExtension(NoUnauthorizedAccess)
export module AuthorizationDiscipline {
export type AuthenticatedContext = { readonly __auth: 'verified' } & unknown;
export type AuthorizedFor<Op extends string> = AuthenticatedContext & { readonly __op: Op };
export declare function authorize<Op extends string>(
session: AuthSession,
op: Op,
): AuthorizedFor<Op> | null;
}The decoration is on a module (or a namespace, or some other multi-export unit), not on a type. The decoration claims that the module as a whole constitutes one type-system extension of the intention. The analyzer's job is to verify that the discipline is self-consistent: that the brand cannot be forged, that the capability constructor is the only producer of the branded value, that no other producer exists in the codebase.
The analyzer's job is also to verify adoption: that every function in the codebase whose signature should require the brand actually does. This is the most ambitious part — the analyzer has to know which functions are protected by the intention, which means the intention must enumerate the protected sites, which is a registry entry of its own.
What makes this case different from earlier cases
In Parts 5 and 6, an extension was a single artifact (a type, a class, a method) decorated with the intention. The decoration was site-local. The analyzer scanned for sites and built the registry.
In this case, an extension is a system of mutually-constraining types. The decoration is module-local, but the evidence of the extension is distributed across the discipline's internal consistency rules and across the call sites of every protected function. The analyzer has to do more — it has to check global properties of the codebase, not just local properties of decorated sites.
This is what makes the case interesting: the type-system extension is qualitatively different from the other extension kinds. It is not "one more kind in the family." It is a kind whose checking discipline is materially heavier than the others. If the framing's unification claim is "all extension kinds share a kernel shape," this case is the one that strains the claim hardest. The kernel shape might have to accommodate both local-site extensions (Parts 5, 6) and distributed-discipline extensions (this article), and accommodating both might require the kernel to be larger than the unification claim suggested.
The Haskell-envy objection
The objection runs: making the unauthorized path unrepresentable is the kind of thing Haskell does well and TypeScript does poorly. The branded-type discipline above is a TypeScript imitation of a pattern Haskell expresses more directly (via newtype, smart constructors, opaque modules, ST/IO monads, capability types in linear or affine type systems). The imitation works mechanically but it is more verbose, more leaky, and less rigorous than the original. Wrapping it in @TypeSystemExtension framing does not make TypeScript a better Haskell. It dresses up TypeScript's awkwardness with vocabulary borrowed from a richer setting and pretends the awkwardness is principled.
I have to concede most of this and defend a residue.
The full concession. TypeScript's structural typing fights against this discipline at every step. Brands are not real; they are intersection-with-impossible-noms that the compiler treats as nominal but the runtime treats as nothing. A caller who knows about the brand can forge it ({ __auth: 'verified' } as AuthorizedFor<'deleteAccount'>) and the compiler will accept the cast. The discipline relies on developer discipline to not forge — exactly the kind of reliance the discipline is supposed to remove. Haskell does not have this leak. In Haskell, newtype constructors can be hidden by module export discipline, and the type system enforces non-forgability. In TypeScript, the analogous mechanism is convention and code review.
This is a real limitation. The framing does not fix it. A @TypeSystemExtension decoration does not make the brand un-forgable; it just declares that the discipline expects the brand to be un-forgable. The analyzer can scan for forging casts and flag them, but the analyzer is doing lint, not type-checking. In Haskell, the same property would be enforced by the compiler with no analyzer needed.
The residue I want to defend. TypeScript is the language the codebase is written in. The choice was made before this series started; it is not under consideration. Within that choice, the question is whether to give up on type-system-level disciplines or to invest in them under the awareness that they are leaky. The intention framing makes that investment more traceable — it surfaces the discipline as a named, queryable upstream object — which is a partial mitigation of the leakage. Knowing that the brand is forgable is less harmful when the brand has a named intention behind it that an analyzer can scan against (the analyzer can at least flag forging casts in regions of code that project from the intention).
So the residue is: in a language that does not enforce the discipline at the compiler level, making the discipline visible and queryable is a partial substitute for compiler enforcement. Better than nothing. Worse than Haskell. Honest about the trade-off.
The case where the residue earns its keep. Regulated codebases. Security-sensitive codebases. Codebases where the cost of a forged brand is high and the cost of an analyzer running on every commit is low. For these, the trade-off is worth making. For ordinary application codebases, the trade-off is probably not worth making — the developer discipline of "do not forge brands" is cheaper than the kernel-plus-analyzer mechanism.
The audience for the type-system extension case is therefore narrow. It is narrower than the type case (Part 5) or the method case (Part 6). It is narrower than the application case (Part 7). The case exists; the case has real users; the case is a minority case.
Connection to the codegen pipeline
The type-system extension case is also where the @frenchexdev/ts-codegen-pipeline becomes structurally necessary, not optional. The discipline above (brands, capability constructor, protected function signatures) requires the kernel to generate the brand declarations and the capability constructor from the intention specification, rather than asking the developer to write them by hand. Hand-written, the discipline is fragile: the developer can omit the brand, forget to thread the capability, drop the parameter from a protected function. Generated, the discipline is uniform.
This is the strongest case for codegen across the entire series. The earlier extension kinds (type, class, method) can be implemented with decorators and an analyzer alone. The type-system extension requires generation. If the kernel is to support all extension kinds uniformly, the kernel must include a codegen step. The codegen is not optional for this case; it is structural.
This is also where the framing connects to the ts-source-generator series and to the Ide.Dsl-TS work-in-progress. Those series sketch the codegen infrastructure that this case would consume. The intention framing is, from one angle, the missing meta-layer above ts-codegen-pipeline — it provides the "what to generate from what" structure that the pipeline currently relies on developers to specify imperatively.
What the framing cannot deliver
I want to be explicit about what the framing cannot deliver in this case, because it is the case where over-claiming would be most tempting.
The framing cannot make TypeScript a stronger type system. The brand is still forgable; the analyzer is still lint; the compiler enforcement is still partial. Calling the discipline a TypeSystemExtension does not change the type system's expressive power.
The framing cannot eliminate the developer discipline required to maintain the brand's integrity. The developer still has to not forge. The analyzer can catch some forging, but a determined developer can defeat any lint rule.
The framing cannot make the discipline interoperable with code that does not project from the intention. If half the codebase uses the discipline and half does not, the half that does not is a free-for-all. The framing surfaces the boundary but does not enforce it.
Honest framing is framing that names its limits.
What stays open
I do not know whether the codegen requirement for this case breaks the unification claim. If type-system extensions require codegen and method extensions do not, the kernel has two operating modes (decorator-only and decorator-plus-codegen), and the modes might be incompatible. I do not know whether the audience for this case is broad enough to justify the codegen investment. I do not know whether there are TypeScript techniques (Effect-TS, fp-ts, branded-types libraries) that already cover this territory adequately without needing a kernel.
I also do not know how this case interacts with the application case from Part 7. An application that adopts a type-system extension is making a coarser-grained commitment than an application that adopts a module extension. The relationship between these two granularities is not obvious to me.
Part 9 is the synthesis article. It takes the eight extension kinds and proposes a concrete package tree, with the migration question for the existing requirements-* family. The objection there is the strongest of all — package proliferation for its own sake — and I will not pretend to fully answer it.