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 atsconfig.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;
}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:
No
abstractkeyword before instance fields in C# / Java. In C# you writepublic abstract int Priority { get; }— abstract properties rather than abstract fields. TS collapses the distinction:abstract readonly priority: Priorityis both a declaration and a contract.No access modifiers required. TS defaults to
public. You can writeprivate,protected,public— or nothing. The DSL uses nothing.Single inheritance only. TS classes have exactly one base class. For the DSL this is a feature, not a limitation:
extends Featuremeans "this is a Feature in the M1 stratum," unambiguously.Abstract classes exist at runtime. This is the important one for what follows. Unlike TS
interfaceortype, which are erased at compile time, anabstract classcompiles to a real JavaScript class declaration. The identifierFeatureexists in the JavaScript output; you can import it, pass it around, compare references. You cannot callnew 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:
- They are schemas. Every abstract field or method declares a slot a subclass must fill. A concrete
NavigationFeature extends Featurethat forgetstitleis a compile error. - They are runtime handles. The class identifier —
NavigationFeature— is a first-class value you can pass to a decorator:@FeatureTest(NavigationFeature). - They are types for
keyof T. A type likeinterface NavigationFeaturewould serve jobs 1 and 3 but not 2. A runtime object like aconst 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;
}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
}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}`);
};
}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:
Excludeis an outer function that takes the decorator's arguments — in this case, none.Excludereturns 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.namegives the class name. (For a static method,targetwould 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,
});
};
}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 { ... }@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;
};
}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>—Tis constrained to be a constructor type that produces a Feature. Theabstract new (...args: any[]) => Featureis 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.featureis the Feature class the test is about.optsis an optional timeout record.return function <C extends new (...args: any[]) => any>(target: C): C— the inner function. It's a class decorator:targetis 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 emptyfeatureslot 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
describeanditexist on the global scope (they do, whenglobals: trueis set invitest.config.ts), the decorator synthesises a test suite out of the prototype methods. This is what lets test files contain zero baredescribe/itcalls.
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);
}
});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:
- Instantiates the test class (this is why test classes must have a zero-argument constructor — they do, implicitly, because they only inherit from
Featurewhich has no constructor). - Walks the prototype chain for own method names.
- Skips the constructor and any method the user marked with
@Exclude(). - 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 }; }
}@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:
- Decorator factories have an outer call that takes your arguments and an inner call that takes the decoration target.
- The body of the inner call runs once, at module load, at the
@line. Not lazily, not per-invocation. - 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();// 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:
- The decorator is a real function running real code. It is not metadata read out later; it is the recording step.
- 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 explicitgetInstance()contract) and stronger than a random global (no other file can reference the array by name). getSatisfiesRegistryreturns 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.- 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 { ... }
}@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?
- Node starts evaluating
./features/navigation.ts. - The top of the file has
import { Feature, Priority } from '../../src'— Node pauses, evaluates../../src/index.ts. ../../src/index.tsre-exports from./baseand./decorators. Both are evaluated../basedefines the abstractFeatureclass. No side effects except the class being bound to the export../decoratorsdefines theSatisfies,Verifies,FeatureTest,Exclude,Refinesfunctions. And crucially: it initialises the module-local registries —const registry = [],const satisfactionLinks = [],const excludedMethods = new Set(). Those arrays exist now, empty.- Back in
./features/navigation.ts, the@Satisfies(ReqNavigationRequirement)line runs. This means:Satisfies(ReqNavigationRequirement)executes, returning a class decorator; that decorator is called withNavigationFeatureas its argument; and the inner body pushes{ featureClass: 'NavigationFeature', requirementClasses: ['ReqNavigationRequirement'] }intosatisfactionLinks. - 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) { ... }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'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;
}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 }; }
}@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:
tscreports: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:
tsccompiles cleanly.- The test runs.
- The registry gets an entry with
ac: 'tocClicckLoadsPage'. - The compliance scanner reports
NavigationFeature.tocClicckLoadsPageas an uncovered AC (because the actual AC name is spelled without the doublec), 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 classgives 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 Tgives 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
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
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.
- Chapter 03 — The Decorator Surface — the full API, every decorator in
src/decorators.ts, read in order, with the edge cases called out. - Chapter 14 — AST Extraction and Registry — the secondary channel. Instead of evaluating modules, the scanner parses their source and reads decorator arguments as AST nodes. This is how
@Expects(TestLevel.Unit)works as a compile-time-only decorator: its runtime function is a no-op, but the AST extractor readsTestLevel.Unitstatically. packages/requirements/src/decorators.ts— the source. 185 lines. At this point you should be able to read it top to bottom without looking anything up.packages/requirements/src/base.ts— the base classes. Longer thandecorators.ts, most of it is the style-system type machinery that Chapter 05 covers in depth.
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 Featurecompile to a real JavaScript class whileinterface Featurecompiles 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 & stringpreferred overkeyof 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.
Related reading
- typed-specs/02-foundation.md — the SPA-and-static foundation that made the Feature abstract class useful in the first place.
- Chapter 00 — Named but Not Modelled — the motivating gap this series closes, for context on why the DSL exists.
- Chapter 03 — The Decorator Surface — the next stop for this chapter's reader.
- contention-over-convention/ — the DSL philosophy that underlies every decision here.
- claude-skills-discipline.md — an adjacent practice of tool discipline: load the skill for the task, don't scroll the whole codebase.
Previous: 03 — The Decorator Surface Next: 04 — Twenty-Two Requirements, Part One