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

Chapter 03b — Newcomer Primer: Abstract Classes, Decorators, Registries

The language features are not unusual on their own. The combination is. This chapter earns the right to take it for granted in every other chapter.

Who this chapter is for, who it isn't

This chapter exists because the rest of the series reads like it takes one thing for granted: that the reader, opening packages/requirements/src/decorators.ts, can parse the four TypeScript features it combines and see at a glance why they are combined that way. Most of the readers I have in mind can parse each feature on its own. Fewer have built around the combination.

The target reader, concretely, is someone who:

  • writes production code in C#, Java, Go, Python, Rust, or Scala;
  • has written TypeScript occasionally — React components, a CLI, a small Node service — enough to be comfortable with interface, type, generics, async, and the shape of a tsconfig.json;
  • has not built a framework or library around the combination of abstract class + decorator factories + module-load registries + keyof T;
  • is reading this series because the design is what is new, and wants to understand the TS machinery well enough to follow the design without the machinery getting in the way.

If you already read src/decorators.ts top-to-bottom without looking anything up, skip this chapter. Chapter 03 — The Decorator Surface — picks up where this one leaves off and assumes everything that follows.

If you don't, this chapter is a fifteen-minute refresher. No React. No Node internals. No generics beyond what Chapter 03 actually uses. The bar is clarity, not hand-waving — each feature is explained on its own terms, then placed next to the others, then shown in the exact form the package uses.

One note on register: I will compare TS features to their nearest analogue in other languages where that helps, and say "TS has no equivalent" where it doesn't. The comparisons are not evaluative. Every language on the list has equally good reasons for its choices. The comparisons exist only because a reader who already knows one of those languages can triangulate to TS faster through contrast than through a cold read.

A second note, on the kind of TypeScript you will see: the package uses the experimentalDecorators flavour, not the newer Stage-3 decorators that TypeScript 5 shipped. Both exist. They overlap but are not interchangeable — the signatures of the inner functions differ, and the Stage-3 version does not yet have the same ecosystem maturity. The DSL picked experimental decorators because every library it talks to (vitest, the AST scanner, the scaffolder) assumes that shape. If you are reading the source and wondering why reflect-metadata isn't imported and why decorators don't use the context parameter from the new spec, that's the reason. Chapter 03 explains the choice in more depth; the machinery in this chapter is the experimental-decorator machinery throughout.

Abstract classes in TypeScript, for people who know them from elsewhere

Start with the feature the DSL leans on hardest.

export abstract class Feature {
  abstract readonly id: string;
  abstract readonly title: string;
  abstract readonly priority: Priority;
  readonly enabled?: boolean;
}

If you come from C# or Java, this reads almost exactly as you'd expect. abstract class means the class cannot be instantiated with new. abstract readonly id: string means any concrete subclass must supply a value (or implementation) for id. The compiler will error on a subclass that forgets id. The enabled field is not abstract, it's an optional inherited property.

The differences from C# / Java are small but worth naming:

  1. No abstract keyword before instance fields in C# / Java. In C# you write public abstract int Priority { get; } — abstract properties rather than abstract fields. TS collapses the distinction: abstract readonly priority: Priority is both a declaration and a contract.

  2. No access modifiers required. TS defaults to public. You can write private, protected, public — or nothing. The DSL uses nothing.

  3. Single inheritance only. TS classes have exactly one base class. For the DSL this is a feature, not a limitation: extends Feature means "this is a Feature in the M1 stratum," unambiguously.

  4. Abstract classes exist at runtime. This is the important one for what follows. Unlike TS interface or type, which are erased at compile time, an abstract class compiles to a real JavaScript class declaration. The identifier Feature exists in the JavaScript output; you can import it, pass it around, compare references. You cannot call new Feature() — but that's a compile-time restriction the TS compiler enforces, not a runtime one.

Python readers will recognise the shape from abc.ABC and @abstractmethod, with one nuance: Python ABCs enforce abstractness at instantiation time (it throws if a subclass forgot to implement something), whereas TS enforces it at compile time. Both work. TS catches the mistake sooner.

Go readers: there is no direct analogue. The closest is an interface that also carries default method implementations — which Go doesn't have. Functionally, in the DSL, Feature plays the role Go would play with "a schema that every concrete subclass must fill in, and whose name is importable as a handle". The handle part is what matters — which brings us to the runtime existence point above.

Rust readers: trait with associated items is close, but trait is a pure compile-time contract. The runtime existence of the class identifier — being able to pass NavigationFeature as a value to a decorator — is what TS has and Rust does not in the same form. Rust achieves similar traceability with derive macros expanding at compile time; TS achieves it with decorators executing at runtime. Different mechanisms, same end.

Scala readers: abstract class in Scala is close enough that the TS version will read almost unchanged.

In the DSL, abstract classes do three jobs:

  1. They are schemas. Every abstract field or method declares a slot a subclass must fill. A concrete NavigationFeature extends Feature that forgets title is a compile error.
  2. They are runtime handles. The class identifier — NavigationFeature — is a first-class value you can pass to a decorator: @FeatureTest(NavigationFeature).
  3. They are types for keyof T. A type like interface NavigationFeature would serve jobs 1 and 3 but not 2. A runtime object like a const spec = { id: '...', title: '...' } would serve job 2 but not 1 or 3. Only an abstract class serves all three at once.

That last point is the quiet keystone of the DSL. The rest of this chapter is a tour of why it matters.

A concrete subclass, for anchoring

One more piece of ballast before decorators. Here is a minimal concrete subclass that fills in the abstract slots:

import { Feature, Priority, type ACResult } from '@frenchexdev/requirements';

export abstract class NavigationFeature extends Feature {
  readonly id = 'NAVIGATION';
  readonly title = 'Navigation';
  readonly priority = Priority.High;

  abstract tocClickLoadsPage(): ACResult;
  abstract backButtonRestoresState(): ACResult;
  abstract deepLinkResolvesHeading(): ACResult;
}

Note that NavigationFeature is still abstract. It filled in the metadata fields (id, title, priority) but left the acceptance criteria — tocClickLoadsPage() and friends — as abstract methods. The test class is where those methods finally get implementations:

export class NavigationFeatureTest extends NavigationFeature {
  tocClickLoadsPage(): ACResult {
    // real test logic, returning { satisfied: true } or { satisfied: false, reason: '...' }
    return { satisfied: true };
  }
  // … the others
}

This two-step — abstract feature, abstract AC methods / concrete test, concrete AC methods — is the shape typed-specs introduced and @frenchexdev/requirements carries forward. It is the UML pattern a C# reader would write with abstract class + overrides, a Java reader with the same, a Python reader with ABC + @abstractmethod. The only TS-specific detail is the lack of access modifiers and the default export style.

Decorator factories: reading the syntax

TypeScript decorators look like Python decorators and spell themselves almost identically — @Something above a class or method — but the machinery underneath is different. Let's build it up from the smallest example in the package, then climb to the full shape.

The smallest shape: a decorator that takes no arguments

The cleanest starting point is @Exclude() from src/decorators.ts:

export function Exclude() {
  return function (target: any, propertyKey: string, _descriptor?: PropertyDescriptor): void {
    excludedMethods.add(`${target.constructor.name}.${propertyKey}`);
  };
}

Two things to notice before reading a single line of body:

  1. Exclude is an outer function that takes the decorator's arguments — in this case, none.
  2. Exclude returns an inner function that takes the decoration target — here, the method being decorated.

This is the decorator factory pattern. The outer call binds your arguments; the inner call is what the TypeScript runtime hands back to, with the thing being decorated. The @ syntax is sugar: @Exclude() is equivalent to Exclude()(targetMethod), conceptually.

Now the body. A method decorator (this is one) receives three arguments:

  • target: any — for an instance method, the prototype of the class. target.constructor.name gives the class name. (For a static method, target would be the class constructor itself. The DSL doesn't use static decorators.)
  • propertyKey: string — the method name, as a string.
  • _descriptor?: PropertyDescriptor — the property descriptor object (get/set/value/writable/…). Optional, and unused here, hence the leading underscore and the ?.

The body adds the fully-qualified method name — "NavigationFeatureTest.helperMethod" — to a module-local Set<string> named excludedMethods. That Set is then exported via getExcludedMethods() and read later by the vitest auto-registration loop in @FeatureTest.

Side-effect-at-decoration-time. Write that on a sticky note. It will reappear in every decorator in the file.

Climbing one step: a decorator that takes arguments

@Verifies('acName') is next:

export function Verifies<T extends Feature>(ac: keyof T & string) {
  return function (target: any, propertyKey: string, _descriptor?: PropertyDescriptor): void {
    registry.push({
      feature: '', // filled by @FeatureTest class decorator
      ac,
      testClass: target.constructor.name,
      testMethod: propertyKey,
    });
  };
}

Same shape. Verifies is the outer factory; it takes the AC name as an argument. The inner function is the method decorator; it pushes a record into the module-local registry array.

What the outer call adds is a type parameter: <T extends Feature>. The caller writes:

@Verifies<NavigationFeature>('tocClickLoadsPage')
testTocClickLoadsPage(): void { ... }

The <NavigationFeature> in angle brackets fixes T to NavigationFeature. Then ac: keyof T & string means: ac is one of the property names of NavigationFeature, narrowed to string keys. If you typo it — 'tocClicckLoadsPage' — the compiler rejects the call at the decorator site, before the file ever runs. We'll unpack keyof T in its own section; hold the shape for now.

The full shape: a class decorator with side effects

The biggest decorator in the file is @FeatureTest. Lightly simplified:

export function FeatureTest<T extends abstract new (...args: any[]) => Feature>(
  feature: T,
  opts?: FeatureTestOptions,
) {
  return function <C extends new (...args: any[]) => any>(target: C): C {
    (target as any).__feature = feature.name;
    (target as any).__featureClass = feature;

    // Backfill feature name on registry entries registered before the class decorator ran
    for (const ref of registry) {
      if (ref.testClass === target.name && !ref.feature) {
        ref.feature = feature.name;
      }
    }

    // Auto-register with vitest — test files need no bare describe/it
    const g = globalThis as Record<string, unknown>;
    if (typeof g.describe === 'function' && typeof g.it === 'function') {
      // … create describe(target.name, …) and an it() per prototype method
    }

    return target;
  };
}

Line by line, for the newcomer:

  • export function FeatureTest<T extends abstract new (...args: any[]) => Feature>T is constrained to be a constructor type that produces a Feature. The abstract new (...args: any[]) => Feature is TypeScript's syntax for "a constructor signature, possibly on an abstract class, that returns something assignable to Feature". It's dense; for this chapter, read it as "T is a Feature class".
  • (feature: T, opts?: FeatureTestOptions) — the decorator's arguments. feature is the Feature class the test is about. opts is an optional timeout record.
  • return function <C extends new (...args: any[]) => any>(target: C): C — the inner function. It's a class decorator: target is the class constructor itself, not a prototype. It returns the constructor (unchanged here, but class decorators are allowed to replace or decorate it).
  • (target as any).__feature = feature.name — stamps two private fields onto the class constructor. Convention-named with double underscores to mark them non-API.
  • The backfill loop — existing registry entries that were pushed by @Verifies (which ran before the class decorator) get their empty feature slot filled in with the feature's name. This is a subtle ordering point we'll return to in the registry section.
  • The vitest block — if describe and it exist on the global scope (they do, when globals: true is set in vitest.config.ts), the decorator synthesises a test suite out of the prototype methods. This is what lets test files contain zero bare describe/it calls.

Expanding on that last bullet, because it's the most surprising piece. The synthesised block looks, in effect, like this:

const instance = new target();
vDescribe(target.name, () => {
  for (const method of Object.getOwnPropertyNames(target.prototype)) {
    if (method === 'constructor') continue;
    if (excludedMethods.has(`${target.name}.${method}`)) continue;
    vIt(method, () => instance[method](), opts?.timeout);
  }
});

So the decorator:

  1. Instantiates the test class (this is why test classes must have a zero-argument constructor — they do, implicitly, because they only inherit from Feature which has no constructor).
  2. Walks the prototype chain for own method names.
  3. Skips the constructor and any method the user marked with @Exclude().
  4. Wraps each surviving method in it(methodName, () => instance.methodName()).

The effect on the test file is dramatic. A file authoring this DSL's tests looks like:

@FeatureTest(NavigationFeature)
export class NavigationFeatureTest extends NavigationFeature {
  @Verifies<NavigationFeature>('tocClickLoadsPage')
  tocClickLoadsPage(): ACResult { /* … */ return { satisfied: true }; }

  @Verifies<NavigationFeature>('backButtonRestoresState')
  backButtonRestoresState(): ACResult { /* … */ return { satisfied: true }; }
}

Not a single bare describe or it. Not a single literal test name. The class decorator does both jobs. This is one of the requirements of the package — REQ-DOG-FOOD specifies "zero describe/it" as an acceptance criterion, and the package self-audits to enforce it. The vitest synthesis block is what makes that criterion satisfiable without sacrificing ergonomics.

Three things to carry forward from this walk-through:

  1. Decorator factories have an outer call that takes your arguments and an inner call that takes the decoration target.
  2. The body of the inner call runs once, at module load, at the @ line. Not lazily, not per-invocation.
  3. The body can have side effects — writing to a module-local array, stamping properties onto a class constructor, calling describe() / it(). Those side effects are the whole point.

That last item is the bridge to the next section.

A sidebar on decorator flavours

TypeScript supports decorators on several kinds of declaration. The DSL uses two of them; for completeness, here is the full list with the signature the inner function takes:

Decoration target Inner signature DSL usage
Class (target: ClassCtor) => ClassCtor | void @FeatureTest, @Satisfies, @Refines
Method (target: Proto, key: string, desc?: PropertyDescriptor) => void @Verifies, @Expects, @Exclude
Accessor (getter/setter) Same as method
Property (target: Proto, key: string) => void
Parameter (target: Proto, key: string, paramIndex: number) => void

The DSL uses class and method decorators only. Accessor, property, and parameter decorators are powerful in the right context (e.g. parameter decorators drive dependency injection in frameworks like NestJS) but the traceability DSL has no need for them. Fewer moving parts.

One more caveat specific to the package: @Verifies has _descriptor?: PropertyDescriptor with a ?. The reason is a quirk of TS 5 — method decorators applied to quoted-name methods or accessors can receive a two-argument form (target, propertyKey) instead of the three-argument form. Making the third argument optional accepts both. The underscore prefix follows the convention for unused parameters. Small detail; matters if you ever write your own method decorator.

Registries at module load

Here is the question a .NET or Java reader usually asks at this point: if the decorator's job is to record metadata, why is it a function with side effects instead of a declarative attribute that some reflection pass reads out later?

The answer is: TypeScript has no mature reflection-over-decorators API. There is no equivalent of typeof(MyClass).GetCustomAttributes(). The language gives you exactly one hook — the decorator function runs at module load — and it's up to the library author to do something useful during that window.

The idiomatic thing to do, and what @frenchexdev/requirements does, is populate a module-local array. Here's the absolute smallest version of the pattern:

// decorators.ts
const registry: SatisfiesEntry[] = [];

export function Satisfies(...reqs: ReqCtor[]) {
  return (target: FeatureCtor) => {
    registry.push({
      featureName: target.name,
      reqs: reqs.map(r => r.name),
    });
  };
}

export const getSatisfiesRegistry = () => registry.slice();

Four observations for a reader coming from .NET attribute reflection:

  1. The decorator is a real function running real code. It is not metadata read out later; it is the recording step.
  2. The registry is a module-local variable. Module-local here has specific meaning: ES modules are evaluated once, so the const registry = [] line runs exactly one time for the lifetime of the process, and subsequent imports see the same array. This is weaker than a singleton pattern (no explicit getInstance() contract) and stronger than a random global (no other file can reference the array by name).
  3. getSatisfiesRegistry returns a slice, not the array itself. Callers get a read-only snapshot. This is the kind of defensive detail you learn to appreciate when the alternative — handing out the live array — has led to surprising mutations in someone else's code.
  4. Order of calls matters. The registry is populated in whatever order the decorator functions execute, which is whatever order the modules are imported. That order is deterministic but not always obvious.

For a Python reader, the closest mental model is a module-level list combined with __init_subclass__ or a registering decorator — the pattern is familiar. For a Java reader, it's closer to a ServiceLoader arrangement without the service-descriptor files. For a Go reader, it's like the init() functions that register types into a map[string]reflect.Type at package load — same idea, different syntax.

Let's unpack point 4, because the @FeatureTest backfill loop in the previous section only makes sense once it's clear.

When you write:

@FeatureTest(NavigationFeature)
export class NavigationFeatureTest {
  @Verifies<NavigationFeature>('tocClickLoadsPage')
  testTocClickLoadsPage(): void { ... }
}

…the method decorators run before the class decorator. That's the language spec: member decorators (properties, methods) run in source order, then the class decorator runs last. So @Verifies pushes its entry into registry first — with an empty feature: '' — and then @FeatureTest runs and walks the registry to fill in the feature field on entries whose testClass matches.

This ordering detail is the kind of thing a reader of src/decorators.ts will trip on if they don't know it. Now you know it.

The full module-load dance

Zoom out. When a Node process starts up and the requirements CLI does require('./features/navigation'), what actually happens?

  1. Node starts evaluating ./features/navigation.ts.
  2. The top of the file has import { Feature, Priority } from '../../src' — Node pauses, evaluates ../../src/index.ts.
  3. ../../src/index.ts re-exports from ./base and ./decorators. Both are evaluated.
  4. ./base defines the abstract Feature class. No side effects except the class being bound to the export.
  5. ./decorators defines the Satisfies, Verifies, FeatureTest, Exclude, Refines functions. And crucially: it initialises the module-local registries — const registry = [], const satisfactionLinks = [], const excludedMethods = new Set(). Those arrays exist now, empty.
  6. Back in ./features/navigation.ts, the @Satisfies(ReqNavigationRequirement) line runs. This means: Satisfies(ReqNavigationRequirement) executes, returning a class decorator; that decorator is called with NavigationFeature as its argument; and the inner body pushes { featureClass: 'NavigationFeature', requirementClasses: ['ReqNavigationRequirement'] } into satisfactionLinks.
  7. Node finishes evaluating the file.

By the time step 7 completes, the runtime registry for @Satisfies holds one entry. Load more feature files and it holds more. At the end of the require() phase, the scanner can call getSatisfactionLinks() and get a complete picture of which Features declared satisfaction of which Requirements.

This is why — and when — runtime registries work. They work because module evaluation is deterministic, side effects are fine if you confine them to initialisation, and the library exposes a read-only view of the final state. They stop working if you forget to import a file (its decorators never run, its entries are missing) or if you import files in different orders in different entry points (the order in the registry is not stable).

The @frenchexdev/requirements package handles this in two ways:

  • The compliance scanner does its own filesystem walk to find every feature and test file, and dynamically require()s them — so the import set is complete regardless of what the entry point happens to do.
  • There is also an AST extraction pass (Chapter 14 — AST Extraction and Registry) that reads decorator arguments statically, without evaluating the module. This catches cases where a decorator is present but runtime evaluation would be wrong — e.g. @Expects(TestLevel.Unit) which is intentionally a runtime no-op because the AST scanner reads its argument list directly.

The dual-channel design (runtime registry + static AST read) is one of the subjects of Chapter 14 proper. Here the point is smaller: the runtime registry is the default, not the only, channel.

Module-local state, honestly

A reader allergic to global state may, at this point, object: you're describing a module-local mutable array populated by side effects, and claiming it's idiomatic. That is not idiomatic in most languages I work in.

Fair objection. A few things to say in defence.

First, the scope of the mutability is narrow. The array is bound to a const in src/decorators.ts, never exported, and exposed only through getRequirementRegistry() which returns a slice. Outside callers cannot push, pop, splice, or reorder. The only writers are the decorator inner functions, which write exactly once each, at module load. After load, the array is effectively frozen.

Second, the mutability is necessary. There is no way to build the registry purely through expression evaluation — decorator calls are distributed across dozens of files, and each file only knows about its own decorations. The aggregation has to happen somewhere, and the language's natural aggregation point is a module-local array that every decorator writes into.

Third, the alternative — asking each feature file to export its declarations and then collecting them at a fixed entry point — reintroduces exactly the problem the DSL exists to solve. It makes the developer responsible for keeping an export manifest in sync with their decorations. Forget to add a new feature to the manifest, it disappears from the scanner. The whole point of decorator-driven registration is that the act of writing the decorator is the act of registering. No separate registration step, no forgotten wire-up.

The honest summary is: this is controlled side-effect state, used at initialisation, exposed read-only. If that still bothers you, the AST extraction channel in Chapter 14 is the purer alternative, and the package maintains both.

keyof T: the type-level AC-name safety

Set the registry aside for a moment and return to a type-level question. This one line from src/decorators.ts:

export function Verifies<T extends Feature>(ac: keyof T & string) { ... }

Everything the type system contributes to the DSL flows through the keyof T & string fragment. Let's take it apart.

What keyof T is

keyof T is a type operator. Given a type T, it produces the union of T's property names. Example:

type Point = { x: number; y: number; label: string };
type Keys = keyof Point;
// Keys = 'x' | 'y' | 'label'

Keys is a type. Its inhabitants are exactly three string literals. No function has run at runtime; this is a purely compile-time operation.

What keyof T & string is

Property keys in JavaScript can be strings, numbers, or symbols. keyof T by default includes whichever of those the type declares. keyof T & string uses TypeScript's intersection to narrow: it keeps only the string-typed keys. For Point, that's all of them; for types that declare numeric-indexed or symbol-keyed properties, it filters those out.

In the DSL, Features only have string-named properties, so the & string is technically belt-and-braces. It's there for safety and for readability at the callsite: the signature reads "this argument is one of the string-named property keys of T".

What T extends Feature does in this position

The generic constraint <T extends Feature> says: T can be any subtype of Feature — so any concrete feature class in user code. When a caller writes @Verifies<NavigationFeature>('tocClickLoadsPage'), they fix T = NavigationFeature, and keyof T & string resolves to the union of that class's string-typed property names.

If NavigationFeature declares:

abstract class NavigationFeature extends Feature {
  readonly id = 'NAVIGATION';
  readonly title = 'Navigation';
  readonly priority = Priority.High;

  abstract tocClickLoadsPage(): ACResult;
  abstract backButtonRestoresState(): ACResult;
  abstract deepLinkResolvesHeading(): ACResult;
}

…then keyof NavigationFeature & string is 'id' | 'title' | 'priority' | 'tocClickLoadsPage' | 'backButtonRestoresState' | 'deepLinkResolvesHeading' (plus any inherited members).

So @Verifies<NavigationFeature>('tocClickLoadsPage') compiles. And @Verifies<NavigationFeature>('tocClicckLoadsPage') — typo, double c — does not. The compiler points at the argument and says, in effect: "'tocClicckLoadsPage' is not assignable to 'id' | 'title' | 'priority' | 'tocClickLoadsPage' | …". A squiggle under the typo in the editor. A red X in the terminal when tsc runs.

Why this is the keystone

Strip keyof T out of the signature — replace it with ac: string — and the DSL still works at runtime. The registry still populates, the scanner still reports, the compliance report still prints. What's lost is compile-time safety. A typo becomes a runtime discrepancy: the registry has 'tocClicckLoadsPage', the feature has 'tocClickLoadsPage', the scanner reports an uncovered AC, and someone spends ten minutes comparing strings with fresh eyes.

keyof T turns that ten-minute investigation into a red squiggle. It does not feel like much, any single time. The cumulative effect across 112 ACs is that typos simply don't exist as a class of problem in this codebase.

A C# reader might point out that nameof(NavigationFeature.TocClickLoadsPage) gets you the same safety. Correct — and that's exactly the mental analogue to carry. keyof T is "the set of things you could nameof() on T", lifted to a type.

A Python reader looking at duck-typed dict schemas won't have a direct analogue. TypedDict with Literal['key-a', 'key-b'] approximates it, but the composition with generic constraints is weaker.

A Rust reader will recognise the shape — it's similar to accessing struct fields through a macro that validates against the field list. Different mechanism, same check.

A worked example: catching a typo

To make the compile-time check concrete, here is the smallest possible example that exercises it. Suppose the AC name should be tocClickLoadsPage but a developer writes:

@FeatureTest(NavigationFeature)
export class NavigationFeatureTest extends NavigationFeature {
  @Verifies<NavigationFeature>('tocClicckLoadsPage')  // typo
  testTocClick(): ACResult {
    return { satisfied: true };
  }

  // … implementations of the real abstract methods
  tocClickLoadsPage(): ACResult { return { satisfied: true }; }
  backButtonRestoresState(): ACResult { return { satisfied: true }; }
  deepLinkResolvesHeading(): ACResult { return { satisfied: true }; }
}

What happens:

  • tsc reports: Argument of type '"tocClicckLoadsPage"' is not assignable to parameter of type 'keyof NavigationFeature & string'.
  • VS Code (or any TS-aware editor) underlines the string literal in red.
  • The build stops. Nothing is written. Nothing is published.

Now strip keyof T out of the picture: change ac: keyof T & string to ac: string. Same code, same typo, same execution:

  • tsc compiles cleanly.
  • The test runs.
  • The registry gets an entry with ac: 'tocClicckLoadsPage'.
  • The compliance scanner reports NavigationFeature.tocClicckLoadsPage as an uncovered AC (because the actual AC name is spelled without the double c), and separately reports the test as verifying nothing (because no AC matches its claim).
  • A human has to compare the two strings character by character to find the typo.

The second path is the one typed-specs avoided, and the first path — keyof T-checked — is what @frenchexdev/requirements preserves. Seeing the two paths side by side is, for most readers from non-TS stacks, the "oh, I see why they bothered" moment.

Why this combination is idiomatic here

With all four features laid out — abstract classes, decorator factories, module-load registries, keyof T — the reader from another stack is entitled to one more question: why these four together? Why not some other combination of features that gets the same result?

Short answer: because each alternative runs into something TypeScript doesn't have.

Why not attributes with reflection (C# / Java)? Because TS has no reflection-over-decorators API. There is no built-in way to ask "give me every class annotated with @Satisfies and their arguments". reflect-metadata exists as a library, but it's an optional shim with its own ergonomics and it doesn't help with decorator arguments in the general case. The cleanest thing the language gives you is a function that runs at decoration time. The DSL uses it.

Why not dictionary schemas (Python / dynamic languages)? Because keyof T requires a type the compiler can reason about. A plain const spec = { id: 'NAVIGATION', title: 'Navigation', ... } is a value, not a type — and using keyof typeof spec reverses the relationship (you need the value to exist first), which makes it hard to reference the feature's keys from elsewhere. Putting the schema in an abstract class gives you a named type that keyof can pluck keys from, and a runtime class identifier that decorators can pluck class names from.

Why not derive macros (Rust)? Because TS has no macro system. Decorators at load time are the closest functional analogue, and they have one unusual virtue derive macros lack: the decorator call is itself a syntactic node in the source file that a separate AST pass can inspect without running the code. Chapter 14 builds on exactly this property. Macros generally compile away — what TypeScript decorators do leaves both a runtime record and a static record, and the DSL uses both.

Why not interfaces instead of abstract classes? Because interfaces are erased at compile time. After the TypeScript compiler runs, there is no runtime artefact to pass to @FeatureTest(NavigationFeature). You can't import { NavigationFeature } from './features/navigation' and pass it to a decorator if NavigationFeature is an interface — there is nothing to import at runtime. Abstract classes survive compilation and provide that runtime handle.

Why not plain classes (non-abstract)? Because plain classes can be instantiated. new NavigationFeature() would succeed, producing an object with no meaningful implementations for any of the acceptance criteria. The abstract keyword is a compile-time seal: it prevents the nonsensical operation of "construct the specification itself" and reinforces that concrete subclasses — NavigationFeatureTest extends NavigationFeature — are where implementations live.

Put the four features side by side:

  • abstract class gives a named, runtime-present schema.
  • Decorator factories give a one-time, load-time recording hook.
  • Module-local arrays give a stable, process-wide registry.
  • keyof T gives compile-time enforcement that decorator arguments name real slots.

Remove any one and the DSL regresses. Keep all four and the reported gap between the specification and the tests that claim to satisfy it closes to zero at compile time.

Cross-stack equivalence table

For readers who want to translate what they see back into their home language, here is a rough mapping of the mechanisms each part of the DSL uses:

DSL concern TypeScript mechanism C# analogue Java analogue Python analogue Rust analogue
Schema for a Feature abstract class abstract class abstract class ABC + @abstractmethod trait with associated items
Class as runtime value class identifier typeof(T) Class<T> the class object itself no direct analogue
Compile-time metadata decorator factory attribute + reflection annotation + reflection decorator function derive macro
Aggregation registry module-local array AppDomain attribute scan ClassGraph / annotation scan module dict walk linker-time static slice
Name-level type safety keyof T nameof(T.Member) none (pre-records) TypedDict + Literal macro field access
Compile-time AC typo catch keyof T & string compile with nameof compile error (if hard-coded enum) runtime TypedDict check compile error in macro expansion

No row of the table is a perfect match. C# attributes + reflection is the closest overall analogue, but it runs the metadata scan at a different time (at use, typically) where TS runs it at module load. Rust derive macros match the static safety but not the load-time aggregation. Python decorators match the load-time aggregation but not the static safety.

The specific combination TypeScript enables — static safety on decorator arguments and load-time aggregation and runtime handles on classes — is what makes this DSL shape feel natural in TypeScript and awkward in each of the alternatives.

Diagram 1 — class hierarchy overview

Diagram
The M2 types declared in base.ts (top) and two example M1 subclasses user code defines (bottom). Only Feature and Requirement are abstract classes; Priority is an enum, ACResult is an interface (erased at runtime).

The diagram reads top-to-bottom as M2 → M1. Everything above the inheritance lines is declared once in the package. Everything below is declared by user code, per feature, per requirement. The * marks abstract members: id, title, priority on Feature; tocClickLoadsPage(), backButtonRestoresState() on NavigationFeature. A concrete test class — NavigationFeatureTest extends NavigationFeature — would supply implementations for those methods.

Note what is and isn't in the runtime output. Priority compiles to a real JavaScript object (enums have runtime presence). Feature and Requirement compile to real classes. ACResult compiles to nothing — it's an interface, erased. The distinction matters because only the runtime-present entities can be passed as arguments to decorators.

Diagram 2 — registry build order

Diagram
The registry is built at module load. Decorators run as a side effect of evaluating their source file; the scanner reads the registry afterwards. Order matters — importing the features module is what makes its Satisfies entries visible.

Two things to see in this diagram.

First, the @Verifies entries are pushed into the registry before the @FeatureTest class decorator runs on the same file. That is why @FeatureTest contains a backfill loop — it has to patch in the feature field on entries the method decorators already wrote. Member decorators first, class decorators last, per the spec.

Second, the scanner reads only at the end. It calls getRequirementRegistry() and getSatisfactionLinks() after every feature and test file has been required. If a feature file isn't imported, its decorators never run, its entries never appear, and the scanner produces a misleadingly clean report. This is why the scanner does its own filesystem walk to guarantee the full set is loaded — and why the AST extraction pass in Chapter 14 exists as a secondary channel that doesn't depend on module evaluation at all.

Where to go next

This chapter is a primer. The rest of the series uses what's covered here as given.

If something still reads as magical after this chapter, it is probably one of two things: the bidirectional registry pattern used by @Satisfies and @Refines, covered in Chapter 03; or the interaction between the module-load registry and the AST scanner, covered in Chapter 14. Neither is deep. Both are downstream of what's here.

A checklist, if you want to pause and test yourself

Before moving on, a short self-check. If any of these read as unclear, the cited chapter section is where to look.

  • Why does abstract class Feature compile to a real JavaScript class while interface Feature compiles to nothing? — "Abstract classes in TypeScript", fourth difference.
  • Why do the method decorators inside a class run before the class decorator? — "Registries at module load", the backfill loop explanation.
  • Why is keyof T & string preferred over keyof T? — "keyof T: the type-level AC-name safety", subsection on the intersection.
  • Why does the package prefer module-local arrays over a global registry? — "Module-local state, honestly".
  • Why does @Expects(TestLevel.Unit) have a no-op runtime body? — it's read statically by the AST scanner, pointer forward to Chapter 14.
  • Why not interfaces? — "Why this combination is idiomatic here", the fourth bullet.

If you can answer each without re-reading, Chapter 03 will be comfortable. If not, the section names above are the targeted re-read.


Previous: 03 — The Decorator Surface Next: 04 — Twenty-Two Requirements, Part One

⬇ Download