Part IV: Features as Abstract Classes
A feature is not a string. Not a Jira ticket. Not a comment. A feature is an abstract class whose abstract methods are its acceptance criteria.
The Core Idea
Most requirement systems represent features as data: strings in a database, rows in a spreadsheet, cards on a board. The connection between "what the feature should do" and "what code verifies it" is maintained by humans — through naming conventions, comments, or discipline.
Typed specifications flip this. Features are types. Acceptance criteria are methods. The compiler enforces the structure. The type system is the specification language.
The Base Types
Three definitions power the entire system:
// requirements/base.ts
export enum Priority {
Critical = 'critical',
High = 'high',
Medium = 'medium',
Low = 'low',
}
export interface ACResult {
satisfied: boolean;
reason?: string;
}
export abstract class Feature {
abstract readonly id: string;
abstract readonly title: string;
abstract readonly priority: Priority;
}// requirements/base.ts
export enum Priority {
Critical = 'critical',
High = 'high',
Medium = 'medium',
Low = 'low',
}
export interface ACResult {
satisfied: boolean;
reason?: string;
}
export abstract class Feature {
abstract readonly id: string;
abstract readonly title: string;
abstract readonly priority: Priority;
}That's it. An enum, an interface, and an abstract class. Everything else follows from these three.
Defining a Feature
A feature definition is a TypeScript file in requirements/features/. Each abstract method is an acceptance criterion — a behavior the system must exhibit:
// requirements/features/navigation.ts
import { Feature, Priority, type ACResult } from '../base';
export abstract class NavigationFeature extends Feature {
readonly id = 'NAV';
readonly title = 'SPA Navigation + Deep Links';
readonly priority = Priority.Critical;
/** Clicking a TOC entry loads the corresponding page. */
abstract tocClickLoadsPage(): ACResult;
/** The browser back button restores the previous page state. */
abstract backButtonRestores(): ACResult;
/** The active navigation item is visually highlighted. */
abstract activeItemHighlights(): ACResult;
/** Clicking an anchor link scrolls smoothly to the target. */
abstract anchorScrollsSmoothly(): ACResult;
/** Navigating directly to a URL loads the correct page. */
abstract directUrlLoads(): ACResult;
/** Pressing F5 reloads the page and preserves the current view. */
abstract f5ReloadPreserves(): ACResult;
/** Deep links resolve to the correct section within a page. */
abstract deepLinkLoads(): ACResult;
/** Every page state produces a bookmarkable URL. */
abstract bookmarkableUrl(): ACResult;
}// requirements/features/navigation.ts
import { Feature, Priority, type ACResult } from '../base';
export abstract class NavigationFeature extends Feature {
readonly id = 'NAV';
readonly title = 'SPA Navigation + Deep Links';
readonly priority = Priority.Critical;
/** Clicking a TOC entry loads the corresponding page. */
abstract tocClickLoadsPage(): ACResult;
/** The browser back button restores the previous page state. */
abstract backButtonRestores(): ACResult;
/** The active navigation item is visually highlighted. */
abstract activeItemHighlights(): ACResult;
/** Clicking an anchor link scrolls smoothly to the target. */
abstract anchorScrollsSmoothly(): ACResult;
/** Navigating directly to a URL loads the correct page. */
abstract directUrlLoads(): ACResult;
/** Pressing F5 reloads the page and preserves the current view. */
abstract f5ReloadPreserves(): ACResult;
/** Deep links resolve to the correct section within a page. */
abstract deepLinkLoads(): ACResult;
/** Every page state produces a bookmarkable URL. */
abstract bookmarkableUrl(): ACResult;
}Eight abstract methods. Eight acceptance criteria. Each one has a JSDoc comment explaining the expected behavior in plain English. The method name is the machine-readable identifier. The comment is the human-readable specification.
The Feature Inventory
The site has 20 features across four priority levels:
| Priority | Features | Total ACs |
|---|---|---|
| Critical | NAV, FM, SLUG, A11Y, REQ-TRACK | 30 |
| High | BUILD, SPY, SCORE, SEARCH, KB, OVERLAY, MOBILE, THEME, CONTRAST, VIS | 67 |
| Medium | ACCENT, COPY, HIRE, MERMAID, PERF | 25 |
requirements/features/
├── navigation.ts ← 8 ACs (Critical)
├── scroll-spy.ts ← 7 ACs (High)
├── build-pipeline.ts ← 13 ACs (High)
├── search.ts ← 5 ACs (High)
├── search-scoring.ts ← 5 ACs (High)
├── accessibility.ts ← 5 ACs (Critical)
├── theme.ts ← 4 ACs (High)
├── accent.ts ← 8 ACs (Medium)
├── keyboard.ts ← 5 ACs (High)
├── overlays.ts ← 6 ACs (High)
├── mobile.ts ← 5 ACs (High)
├── copy-buttons.ts ← 4 ACs (Medium)
├── hire-modal.ts ← 4 ACs (Medium)
├── slugify.ts ← 5 ACs (Critical)
├── frontmatter.ts ← 5 ACs (Critical)
├── mermaid-config.ts ← 5 ACs (Medium)
├── visual.ts ← 4 ACs (High)
├── contrast.ts ← 3 ACs (High)
├── performance.ts ← 4 ACs (Medium)
└── req-track.ts ← 7 ACs (Critical)requirements/features/
├── navigation.ts ← 8 ACs (Critical)
├── scroll-spy.ts ← 7 ACs (High)
├── build-pipeline.ts ← 13 ACs (High)
├── search.ts ← 5 ACs (High)
├── search-scoring.ts ← 5 ACs (High)
├── accessibility.ts ← 5 ACs (Critical)
├── theme.ts ← 4 ACs (High)
├── accent.ts ← 8 ACs (Medium)
├── keyboard.ts ← 5 ACs (High)
├── overlays.ts ← 6 ACs (High)
├── mobile.ts ← 5 ACs (High)
├── copy-buttons.ts ← 4 ACs (Medium)
├── hire-modal.ts ← 4 ACs (Medium)
├── slugify.ts ← 5 ACs (Critical)
├── frontmatter.ts ← 5 ACs (Critical)
├── mermaid-config.ts ← 5 ACs (Medium)
├── visual.ts ← 4 ACs (High)
├── contrast.ts ← 3 ACs (High)
├── performance.ts ← 4 ACs (Medium)
└── req-track.ts ← 7 ACs (Critical)112 acceptance criteria total. Each one is an abstract method. Each one needs at least one test.
Why Abstract Classes, Not Interfaces
TypeScript interfaces exist only at compile time — they're erased during transpilation. Abstract classes exist at runtime. This matters because:
The compliance scanner reads source code. It can regex-match
abstract class NavigationFeatureandabstract tocClickLoadsPage(). Interfaces would work too, but abstract classes make the pattern more explicit.Decorators reference class types.
@FeatureTest(NavigationFeature)passes the class itself as a value. You can't pass an interface as a value in TypeScript.keyof Tworks on both. The type-safe@Implements<NavigationFeature>('tocClickLoadsPage')decorator useskeyof Tto validate the AC name — this works with both interfaces and classes.Runtime metadata. The class name (
NavigationFeature) and its properties (id,title,priority) are available at runtime for the registry and reporting.
The Meta-Modeling Connection
This approach borrows from meta-metamodeling — the same layered architecture used in the CMF framework:
| Layer | What It Is | In This System |
|---|---|---|
| M3 (meta-metamodel) | The rules that define what a "feature" can be | TypeScript's abstract class + keyof T + decorator types |
| M2 (metamodel) | The vocabulary of the domain | Feature, Priority, ACResult, @FeatureTest, @Implements |
| M1 (model) | Specific features | NavigationFeature, BuildPipelineFeature, SearchFeature |
| M0 (instances) | Runtime data | Test execution results, compliance report JSON |
The key insight: the shape of what a "feature" IS should be defined once (M2) and enforced by the type system (M3). Every concrete feature (M1) is validated against this shape automatically. No manual checking needed.
Design Decisions
One File Per Feature
Each feature gets its own file. This makes the compliance scanner simpler (it reads requirements/features/*.ts) and keeps features focused. A feature file should be short — if it has more than 15 ACs, consider splitting it.
JSDoc as Human Specification
The abstract method name is the machine identifier. The JSDoc comment is the human specification. Both live in the same place, so they can't drift apart:
/** The browser back button restores the previous page state. */
abstract backButtonRestores(): ACResult;/** The browser back button restores the previous page state. */
abstract backButtonRestores(): ACResult;The method name tells the compiler and the compliance scanner what to look for. The comment tells the developer what the test should verify.
Priority Drives the Quality Gate
The Priority enum isn't decorative. The compliance scanner's --strict flag uses it:
- Critical features must have 100% AC coverage or the build fails
- High and Medium features are reported but don't block
This lets you adopt typed specifications incrementally — start with critical features, expand as the team builds confidence.
ACResult Is Not Used (Yet)
The ACResult interface exists for future runtime validation — imagine a startup health check that verifies features are working. For now, the abstract methods exist only as specification markers. The tests that implement them use Playwright assertions and Vitest expectations, not ACResult.
Previous: Part III: Multi-Layer Quality Next: Part V: The Decorator Chain — three decorators that link every test to the feature it verifies.