Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

An Inventory

If the intention/extension framing has any architectural force, it should already describe things we do. It should not require new packages to be visible; it should be a way of naming what is already in front of us. This article is the inventory. For each kind of TypeScript artifact, I name an example, identify the implicit intention it carries, and ask whether making the intention explicit would change anything.

The inventory is type-check-only. Every snippet compiles; none of it lives in any package; none of it is meant to be the proposed API. The proposed API arrives in Parts 5 through 8. Here I am only asking where the intentions already are.

Type as extension

A bare TypeScript type declaration is the simplest case. Consider:

export type Email = string & { readonly __brand: 'Email' };
export type UserId = string & { readonly __brand: 'UserId' };

These two declarations look like type aliases for string. They are not. The brand makes them disjoint nominal types — an Email cannot be passed where a UserId is expected, even though both erase to string at runtime.

What is the intention that this type expresses? It is something like: "there exist values that look like strings but are not interchangeable, and the type system should refuse to mix them." That intention has no name in the codebase. It is implicit in the brand-pattern idiom. If you grep for the intention, you find none. If you grep for the implementation, you find dozens of branded types scattered across the codebase, each carrying the same intention without ever naming it.

Could you name it? Could you write something like Intention<NoIdentifierConfusion> and have every branded type be an @TypeExtension(NoIdentifierConfusion)? Yes — and the question is whether the naming buys anything. The answer depends on whether you ever ask the question "which types are nominally distinguished and why?" If you do, naming the intention surfaces the answer; if you do not, naming the intention is overhead.

I lean toward "the question gets asked" but I want to flag it as a bet, not a certainty. The bet is that codebases over a certain size start asking the question — and once they ask it, having the answer pre-computed is more valuable than the cost of writing the intention down.

Class as extension

A class is a richer case because the class body carries multiple things at once: state, behaviour, invariants. Consider:

export class AuthSession {
  constructor(
    public readonly userId: UserId,
    public readonly issuedAt: Date,
    public readonly expiresAt: Date,
  ) {
    if (expiresAt <= issuedAt) {
      throw new Error('AuthSession expiresAt must be after issuedAt');
    }
  }

  isExpired(now: Date = new Date()): boolean {
    return now >= this.expiresAt;
  }
}

What is the intention this class carries? Not just "represent an authenticated session." That is the runtime function. The intention is more like: "a user's authenticated state has a bounded lifetime, and the system shall be unable to construct a session whose expiry precedes its issuance." The constructor invariant (expiresAt > issuedAt) is not a feature; it is a promise the class makes on behalf of an upstream intention. The invariant is the projection of the intention into the class category.

Could the class declare this? Could it say @ClassExtension(BoundedAuthenticatedLifetime) and have the analyzer check that the constructor enforces some invariant relating issuedAt and expiresAt? Yes — and again the question is whether the visibility is worth the friction. The bet is that invariants drift silently: a refactor relaxes the constructor check, the invariant disappears, and the codebase no longer reflects the upstream intention. Naming the intention gives the drift somewhere to be detected.

This is also where the line with DDD starts to wobble. A DDD analysis would model AuthSession as a value object or an entity, and the invariant would be a modelling decision about the domain. The intention/extension framing is shallower: it does not ask whether AuthSession is a value object; it asks whether the invariant the class carries is traceable to a named upstream object. A DDD analysis subsumes this question (and answers many more), but the intention framing answers just this question even in codebases that have not committed to DDD modelling.

Method as extension

The fine-grained case. Consider:

class UserRepository {
  async save(user: User): Promise<void> {
    await this.audit(user, 'save');
    await this.db.users.put(user);
  }

  async delete(userId: UserId): Promise<void> {
    await this.audit({ userId }, 'delete');
    await this.db.users.delete(userId);
  }

  private async audit(payload: unknown, action: string): Promise<void> {
    /* ... */
  }
}

The save and delete methods both carry the same cross-cutting intention: "every state-changing operation on the user aggregate emits an audit record before the change is committed." This intention is not a method-level concept. It is a promise about a family of methods, and the family is defined by some property of those methods (in this case, "state-changing on the user aggregate").

This is the case where the existing kernel has the least to say. @frenchexdev/requirements-* can express it as a Requirement, with @Satisfies decoration on the UserRepository class. It can verify it via @Verifies on test methods. What it cannot do is declare the intention at the method level — annotate each method that carries the cross-cutting promise — and have the kernel recognise that the intention propagates across them.

This is the case where I think the intention framing earns most of its keep, and it is also the case where the AOP / aspects objection bites hardest. Part 6 is where I will sit with that objection at length.

Module as extension

A module's public exports are the most public statement a piece of TypeScript makes about itself. Consider a module:

// auth/index.ts
export { AuthSession } from './auth-session';
export { authenticate } from './authenticate';
export { revokeSession } from './revoke-session';
export type { UserCredentials, AuthError } from './types';

What is the intention of this module? It is something like: "the rest of the system can perform authentication operations on opaque session objects, without having access to credential structure or session internals." This intention is communicated entirely through the shape of the exports — what is exported and what is not. The intention is also enforced entirely by the absence of certain exports: nothing exports Session internals, nothing exports the hashing primitives, nothing exports the database adapter.

Could the module declare this intention? Could it say @ModuleExtension(OpaqueAuthSurface) and have the analyzer check that the module's exports conform to the intention? Yes — and the test of whether this is worth anything is whether people refactoring the module accidentally widen the public surface. They do, in my experience, constantly. The intention framing here looks like a generalisation of "private" markers from the field of class members to the field of module exports. That generalisation already happens informally in tools like @internal JSDoc tags; the intention framing makes it part of the kernel instead of a convention.

This is the case where the framing looks most like a re-naming of what already exists. The objection that bites here is the simplest one: nothing is unlocked. We can already use @internal and ESLint rules. Why route through Intention<T>? The answer, if there is one, is that all the other cases route through it too, and the unification is what is being bought. If only the module case existed, the kernel would not be worth it.

Application as extension

The coarsest case. An entire application can be read as the projection of one (or a few) intention(s). Consider this CV site itself. It is built by a Node.js SSG. It serves static pages from markdown. It is deployed to Vercel via git push. What is the intention? Something like: "the author's writing and the author's CV shall be served as a fast, statically-rendered site, controllable by git, with no production-side build infrastructure."

That intention is the seed of every architectural decision in the codebase. The choice of SSG over SSR, the choice of static over dynamic deployment, the choice of git-push-deploys over CI/CD — all of these are projections of the same intention.

The objection here is the most uncomfortable: at this granularity, "extension" is just "implementation." An entire application is its implementation; there is no third thing. Calling the application an extension of an intention sounds like a rationalisation of a decision tree, not an architectural concept. This is the case where the framing is most vulnerable, and Part 7 is where I will sit with that.

Type-system as extension

The most abstract case, and the one I am least sure about. A type discipline — a coherent set of types governed by a system, like effect types, capability types, branded types — can be read as the projection of an intention. The intention NoUnauthorizedAccess, for instance, might project not into a single type or class but into a whole family of phantom types that make the unauthorized code path unrepresentable.

This case is where the framing connects to the @frenchexdev/ts-codegen-pipeline and to the line of work in ts-source-generator. It is also where the Haskell-envy objection bites hardest, and Part 8 is the article.

What this inventory shows

I want to draw two conclusions from this inventory, and be careful about what they do not show.

The conclusions are:

  1. The intentions are already there. Every TypeScript artifact in active use carries a directionality toward some upstream promise. The promise is not named in the kernel, but the artifact would not have the shape it has if the promise were not present.

  2. The visibility differs by case. In some cases (type, class) the visibility cost is low and the bet on payoff is moderate. In one case (method) the visibility cost is moderate and the bet on payoff is high. In one case (module) the framing looks like re-naming. In one case (application) the framing looks vulnerable. In one case (type-system) the framing is most powerful and most contestable.

What this inventory does not show is whether unifying these cases under a single Intention<T> kernel actually saves anything compared to handling them with one-off mechanisms per case. The unification claim is the load-bearing claim of the series. The inventory shows that the raw material is present in each case. It does not yet show that the unification is worth its cost.

The counter-argument here is structural: "everything has a purpose" is vacuous. Saying that every type, class, method, module, and application has an intention upstream of it is just saying that every artifact was made for a reason. That observation, on its own, is empty. The non-empty version of the claim is that the reasons share a structure, and that the structure is worth reifying. I have not yet shown that. The inventory is a precondition for showing it, not the showing itself.

What stays open

I do not know, at the end of this article, whether the cases I have listed are exhaustive. There may be extension kinds I have not noticed — protocols, configurations, schemas, deployment manifests. I do not know whether the unification across cases produces a single shared kernel shape or whether each kind requires its own kernel and the "shared" part is decorative. I do not know whether the intention framing dissolves into DDD modelling for the cases where DDD applies and becomes useful only in the cases where DDD does not apply — in which case the framing is a complement to DDD in narrow circumstances, not a replacement of anything.

Part 4 is where I take the existing @frenchexdev/requirements-shared-kernel and try to re-read it as Ext_R — the requirement-projection functor. The point of that re-reading is the dissolution test: if the re-reading is bit-identical to the current kernel and changes nothing about how I would write code tomorrow, the hypothesis is decorative and I should stop.

⬇ Download