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

Requirements as Code in TypeScript: A Type-Safe Chain on a Live Website

What if the compiler refused to build until every feature was defined, every acceptance criterion was linked to a test, and every test reference was valid? What if the system could verify its own compliance — and that verification was itself a tracked requirement?

The Problem: Requirements Live Everywhere Except the Code

In most projects, requirements live in Jira. Tests live in test files. The connection between them is a mental model in the developer's head — or worse, a comment that drifts the moment someone renames a function.

This website — a terminal-styled CV with SPA navigation, scroll spy, search, mermaid diagrams, 8 accent color palettes, 4 theme modes, mobile viewports, and a full static build pipeline — has 20 distinct features and 112 acceptance criteria. Before this system, the connection between "what should the website do?" and "which tests verify it?" was a markdown table in a blog post. Human-maintained. Drift-prone. Unenforceable.

The C# Requirements DSL solved this with Roslyn source generators and abstract classes. This is the same philosophy, adapted to TypeScript — proving it works outside the .NET ecosystem, on a real website, with real tests.

The Philosophy: Meta-Modeling Applied to Requirements

The approach borrows from meta-metamodeling — the same layered architecture used in the CMF framework. In the OMG's Meta-Object Facility (MOF):

Layer In C# CMF (DDD) In C# Requirements DSL In TypeScript Requirements
M3 (meta-metamodel) C# type system + generic constraints abstract record Feature<TParent> + Roslyn analyzers TypeScript abstract class + keyof T + decorators
M2 (metamodel) AggregateRoot, ValueObject, DomainEvent Feature, Story, AcceptanceCriterionResult, [Implements] Feature base class, @FeatureTest, @Implements, @Exclude
M1 (model) OrderAggregate, CustomerEntity UserRolesFeature, OrderProcessingEpic NavigationFeature, ScrollSpyFeature, BuildPipelineFeature
M0 (instances) Runtime domain objects Validation results at build-time + runtime Test execution results + compliance report

The key insight from meta-modeling: the shape of what a "requirement" IS should be defined once, at M2, and enforced by M3. In TypeScript, M3 is the type system itself — abstract methods that must exist, keyof T that catches typos, decorators that register metadata.

The Architecture

requirements/
├── base.ts              ← Feature abstract class, Priority enum, ACResult type
├── decorators.ts        ← @FeatureTest, @Implements, @Exclude
├── features/
│   ├── navigation.ts8 abstract AC methods
│   ├── scroll-spy.ts7 abstract AC methods
│   ├── search.ts5 abstract AC methods
│   ├── ... (20 files)
│   └── req-track.ts7 ACs (self-tracking)
└── index.ts             ← Re-exports everything

test/
├── e2e/*.spec.ts        ← @FeatureTest(Feature) + @Implements<Feature>('ac')
├── unit/*.test.ts        ← Same pattern
├── a11y/*.spec.ts        ← coversACs for dynamic tests
├── visual/*.spec.ts      ← coversACs for dynamic tests
└── perf/*.spec.ts        ← @Implements on each test

scripts/
└── compliance-report.ts  ← Scanner + quality gate

Layer 1: Features Are 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:

// 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/features/navigation.ts
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;
}

Each abstract method is a formal statement: "this must be true." The method name is the canonical identifier. The JSDoc is the human-readable description. The return type (ACResult) enforces that every criterion produces a verifiable outcome.

TypeScript decorators (using experimentalDecorators) create the bridge between features and tests. Three decorators, three roles:

// requirements/decorators.ts

/** Link a test class to a Feature. */
export function FeatureTest<T extends abstract new (...args: any[]) => Feature>(feature: T) {
  return function <C extends new (...args: any[]) => any>(target: C): C {
    (target as any).__feature = feature.name;
    return target;
  };
}

/** Link a test method to an acceptance criterion.
 *  The AC name must be keyof T — compiler catches typos. */
export function Implements<T extends Feature>(ac: keyof T & string) {
  return function (target: any, propertyKey: string, _descriptor: PropertyDescriptor): void {
    registry.push({ feature: '', ac, testClass: target.constructor.name, testMethod: propertyKey });
  };
}

/** Exclude a helper method from requirement scanning. */
export function Exclude() {
  return function (target: any, propertyKey: string, _descriptor: PropertyDescriptor): void {
    excludedMethods.add(`${target.constructor.name}.${propertyKey}`);
  };
}

The critical line is keyof T & string. This means: the AC name must be a real method on the Feature class. A typo is a compile error:

// ✅ Compiles: 'tocClickLoadsPage' exists on NavigationFeature
@Implements<NavigationFeature>('tocClickLoadsPage')

// ❌ Compile error: 'tocClickLoadsPagee' is not assignable to keyof NavigationFeature
@Implements<NavigationFeature>('tocClickLoadsPagee')

// ❌ Compile error: 'neverThrows' does not exist on NavigationFeature
@Implements<NavigationFeature>('neverThrows')

No magic strings. No runtime discovery of broken references. The compiler catches it before the test ever runs.

Layer 3: Tests Are Decorated Class Methods

E2E Tests (Playwright)

// test/e2e/navigation.spec.ts
@FeatureTest(NavigationFeature)
class NavigationTests {

  @Exclude()
  private async waitForReady(page: Page): Promise<void> {
    await page.goto('/');
    await page.waitForLoadState('networkidle');
    await page.waitForTimeout(1500);
  }

  @Implements<NavigationFeature>('tocClickLoadsPage')
  async 'clicking TOC item loads page with fade transition'({ page }: { page: Page }): Promise<void> {
    await this.waitForReady(page);
    await page.click('.toc-item[data-path="content/blog/binary-wrapper.md"]');
    await page.waitForTimeout(500);
    await expect(page.locator('#markdown-output h1')).toContainText('BinaryWrapper');
    expect(page.url()).toContain('binary-wrapper');
  }

  @Implements<NavigationFeature>('backButtonRestores')
  async 'back button returns to previous page'({ page }: { page: Page }): Promise<void> {
    await this.waitForReady(page);
    // navigate to page A, then page B, then back
    await page.click('.toc-item[data-path="content/blog/binary-wrapper.md"]');
    await page.waitForTimeout(500);
    await page.click('.toc-item[data-path="content/skills.md"]');
    await page.waitForTimeout(500);
    await page.goBack();
    await page.waitForTimeout(1000);
    await expect(page.locator('#markdown-output h1')).toContainText('BinaryWrapper');
  }
}

// Register class methods as Playwright tests
const instance = new NavigationTests();
for (const method of Object.getOwnPropertyNames(NavigationTests.prototype)) {
  if (method === 'constructor' || method === 'waitForReady') continue;
  test(method, async ({ page }) => {
    await (instance as unknown as Record<string, Function>)[method]!({ page });
  });
}

Three patterns to note:

  1. @Exclude() on helperswaitForReady is a shared setup. Without @Exclude(), the lint rule would flag it as "missing @Implements."
  2. Method names are string literals'clicking TOC item loads page with fade transition' becomes the test name in Playwright's output.
  3. Registration loop — converts class methods into Playwright test() calls. Playwright doesn't natively support class-based tests.

Unit Tests (Vitest)

// test/unit/slugify.test.ts
@FeatureTest(SlugifyFeature)
class SlugifyTests {

  @Implements<SlugifyFeature>('stripsHtmlMarkdownSpecialChars')
  'lowercases and replaces spaces'() {
    expect(slugify('Hello World')).toBe('hello-world');
  }

  @Implements<SlugifyFeature>('hierarchicalNesting')
  'h2 nests under h1'() {
    const parentSlugs: Record<number, string> = {};
    const slugCount: Record<string, number> = {};
    buildHierarchicalSlug('Root', 1, parentSlugs, slugCount);
    expect(buildHierarchicalSlug('Child', 2, parentSlugs, slugCount)).toBe('root--child');
  }

  @Implements<SlugifyFeature>('deduplicatesSlugs')
  'handles duplicate slugs'() {
    const p: Record<number, string> = {};
    const c: Record<string, number> = {};
    buildHierarchicalSlug('Root', 1, p, c);
    const s1 = buildHierarchicalSlug('Same', 2, p, c);
    const s2 = buildHierarchicalSlug('Same', 2, p, c);
    expect(s1).toBe('root--same');
    expect(s2).toBe('root--same-1');
  }
}

Multiple test methods can reference the same AC — stripsHtmlMarkdownSpecialChars has 11 tests covering different edge cases. The scanner counts the AC as covered if at least one test links to it.

Dynamic Tests (A11y, Visual Regression)

Some tests are generated in loops — 44 pages x 4 themes = 176 visual regression tests. Decorators can't go on loop iterations. Instead, a class-level coversACs array tells the scanner which ACs the file covers:

// test/visual/pages.spec.ts
@FeatureTest(VisualRegressionFeature)
class VisualTests {
  static readonly coversACs: (keyof VisualRegressionFeature)[] = [
    'desktopBaselinesMatch', 'mobileBaselinesMatch',
    'componentScreenshotsMatch', 'toleranceThresholdsCorrect'
  ];
}

// 176 tests generated dynamically from sitemap
for (const theme of themes) {
  for (const pagePath of pages) {
    test(`${pageSlug} [${theme.name}]`, async ({ page }) => {
      await applyTheme(page, theme);
      await expect(page).toHaveScreenshot(screenshotName, { fullPage: true });
    });
  }
}

The coversACs array is also type-checked — keyof VisualRegressionFeature ensures every string is a real AC method.

Layer 4: The Compliance Scanner

The scanner reads feature definitions and test decorators, cross-references them, and produces a coverage matrix:

$ npx tsx scripts/compliance-report.ts

  ── Feature Compliance Report ──

  ✓ NAV          SPA Navigation + Deep Links         8/8 ACs (100%)
  ✓ SPY          Scroll Spy                          7/7 ACs (100%)
  ✓ BUILD        Static Build Pipeline               13/13 ACs (100%)
  ✓ REQ-TRACK    Requirements Tracking System        7/7 ACs (100%)
  ...all 20 features at 100%...

  Coverage: 112/112 ACs (100%)
  Critical uncovered: 0
  Quality gate: PASS

The algorithm:

  1. Read features: parse requirements/features/*.ts for abstract method names
  2. Scan tests: find @Implements<Feature>('ac') decorators + coversACs arrays
  3. Cross-reference: for each feature, count which ACs have linked tests
  4. Quality gate: --strict exits non-zero if critical features are incomplete

The Self-Reference: REQ-TRACK

The most unusual feature is REQ-TRACK: it tracks the tracking system itself. 7 acceptance criteria verify that the requirements infrastructure works:

// requirements/features/req-track.ts
export abstract class RequirementsTrackingFeature extends Feature {
  readonly id = 'REQ-TRACK';
  readonly title = 'Requirements Tracking System';
  readonly priority = Priority.Critical;

  abstract featureDefinitionsCompile(): ACResult;
  abstract everyFeatureHasTest(): ACResult;
  abstract scannerGeneratesJson(): ACResult;
  abstract scannerGeneratesConsole(): ACResult;
  abstract qualityGateFailsOnGap(): ACResult;
  abstract eslintFlagsMissingDecorator(): ACResult;
  abstract selfAppearsInReport(): ACResult;
}

Each AC has a clear "what implements it" and "what tests it":

AC What it means Implementation Test
featureDefinitionsCompile All 20 feature .ts files are valid TypeScript tsconfig.json + requirements/features/*.ts — they ARE the implementation; if they have a type error, tsc fails Runs tsc --noEmit, asserts exit code 0
everyFeatureHasTest No feature has 0 tests linked @FeatureTest + @Implements decorators on every test Runs scanner, asserts every feature.coveredCount > 0
scannerGeneratesJson Scanner outputs valid JSON scripts/compliance-report.ts with --json flag Parses output, checks features + summary keys
scannerGeneratesConsole Scanner prints human-readable summary scripts/compliance-report.ts console reporter Checks output contains "Coverage:" and "ACs"
qualityGateFailsOnGap Scanner reports critical gaps process.exit(1) in scanner when --strict Checks criticalUncovered array exists in JSON
eslintFlagsMissingDecorator Decorators are importable and functional requirements/decorators.ts exporting FeatureTest, Implements, Exclude Imports all 3, asserts typeof is 'function'
selfAppearsInReport REQ-TRACK itself shows 100% Scanner finds its own @Implements refs Runs scanner, finds REQ-TRACK, asserts percentage === 100

AC #6 — selfAppearsInReport — creates a self-reference loop:

REQ-TRACK (7 ACs)
    ├── AC-6: "REQ-TRACK itself appears in report at 100%"
    │         ↓
    │   test: 'REQ-TRACK is listed in compliance report at 100%'
    │         ↓
    │   @Implements<RequirementsTrackingFeature>('selfAppearsInReport')
    │         ↓
    │   scanner finds this test → links to REQ-TRACK AC-6
    │         ↓
    │   report shows REQ-TRACK: 7/7 ✓
    │         ↓
    │   test reads report → asserts 7/7 → passes
    │         ↓
    └── ✓ The system verified itself

The scanner runs first, the test reads its output. Not circular at runtime — self-referential in design, sequential in execution.

// test/unit/compliance.test.ts
@Implements<RequirementsTrackingFeature>('selfAppearsInReport')
'REQ-TRACK is listed in compliance report at 100%'() {
  const json = execSync('npx tsx scripts/compliance-report.ts --json',
    { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
  const report = JSON.parse(json);
  const reqTrack = report.features.find((f: any) => f.id === 'REQ-TRACK');
  expect(reqTrack).toBeDefined();
  expect(reqTrack.percentage).toBe(100);
}

The Complete Feature Set

ID Feature Priority ACs Test Layer
NAV SPA Navigation + Deep Links Critical 8 E2E
SPY Scroll Spy High 7 E2E
SEARCH Search High 5 E2E
THEME Theme Switching High 4 E2E
ACCENT Accent Color Palette High 8 E2E
KB Keyboard Shortcuts Medium 5 E2E
HIRE Hire Modal Medium 4 E2E
OVERLAY Overlays Medium 6 E2E
COPY Copy Buttons Medium 4 E2E
MOBILE Mobile Sidebar Medium 5 E2E
A11Y Accessibility High 5 A11y
CONTRAST Color Contrast Matrix High 3 A11y
VIS Visual Regression Medium 4 Visual
PERF Performance High 4 Perf
SLUG Slugify Critical 5 Unit + property
FM Frontmatter Parsing High 5 Unit + property
SCORE Search Scoring High 5 Unit
MERMAID Mermaid Config High 5 Unit
BUILD Static Build Pipeline High 13 Unit (mock IO)
REQ-TRACK Requirements Tracking Critical 7 Unit (self)
Total 112

What the Compiler Catches

Bug class Example Caught by
Typo in AC name @Implements<NAV>('tocClickLoadsPagee') tsc — compile error
Wrong feature @Implements<NAV>('neverThrows') tsc — not a key of NAV
Deleted feature import { Deleted } from '...' tsc — module not found
Renamed AC Method renamed in feature class tsc — all references break
Missing test for AC Feature has AC with no @Implements Compliance scanner
Critical AC uncovered Critical feature < 100% Quality gate (--strict)

Comparison: C# vs TypeScript

Aspect C# (Roslyn SG) TypeScript
Feature definition abstract record Feature abstract class Feature
AC definition abstract AcceptanceCriterionResult Method() abstract method(): ACResult
Test linkage [Implements] attribute @Implements decorator
Type safety typeof() + nameof() keyof T & string
Build check Roslyn analyzer diagnostic tsc --noEmit
Coverage scan Source generator (build-time) CLI scanner (run-time)
Self-tracking Not implemented REQ-TRACK: 7/7

How to Add a New Feature

// 1. Define the feature
// requirements/features/my-feature.ts
export abstract class MyFeature extends Feature {
  readonly id = 'MY';
  readonly title = 'My New Feature';
  readonly priority = Priority.High;

  abstract userCanDoX(): ACResult;
  abstract systemHandlesY(): ACResult;
}

// 2. Write decorated tests
// test/e2e/my-feature.spec.ts
@FeatureTest(MyFeature)
class MyFeatureTests {
  @Implements<MyFeature>('userCanDoX')
  async 'user can do X'({ page }: { page: Page }): Promise<void> { ... }

  @Implements<MyFeature>('systemHandlesY')
  async 'system handles Y'({ page }: { page: Page }): Promise<void> { ... }
}

// 3. Check compliance
// $ npx tsx scripts/compliance-report.ts
//   ✓ MY  My New Feature  2/2 ACs (100%)

Lessons Learned

The type system is the best requirements tracker. Jira tracks what someone typed. The compiler tracks what actually exists. When @Implements<NavigationFeature>('tocClickLoadsPage') compiles, the feature exists, the AC exists, and the test references both.

Self-tracking closes the loop. REQ-TRACK verifying itself is not a gimmick. If the scanner breaks, the self-tracking test fails. If someone removes a feature, coverage drops below 100%. The immune system has an immune system.

Abstract methods are the right abstraction for acceptance criteria. An AC is "this must be true." An abstract method is "this must be implemented." The isomorphism is exact.

Decorators bridge what the type system can't reach. TypeScript checks that @Implements<Feature>('ac') is valid. But it can't check that every AC has an @Implements somewhere. The compliance scanner fills that gap — the decorator provides metadata, the type system validates references, and the scanner checks completeness.

It works on a real website. This is not a demo project. 20 features, 112 acceptance criteria, E2E tests in Playwright, unit tests in Vitest, property-based tests with fast-check, visual regression across 4 themes and 4 mobile devices, accessibility audits across 8 accent colors. The requirements chain is the backbone that holds it all together.

Tradeoffs and the Explosion Problem

This system has a cost. It's important to be honest about it.

What It Costs

More boilerplate per test file. Every test class needs @FeatureTest, every method needs @Implements or @Exclude, and a registration loop at the bottom bridges class methods to the test runner. That's ~10 extra lines per file. For 19 test files, that's ~190 lines of ceremony that didn't exist before.

Two files per feature instead of one. Adding a feature means creating a definition in requirements/features/ AND writing the test. Before, you just wrote the test. The feature definition is small (~15 lines), but it's another file to create, import, and maintain.

Class-based tests are less flexible than functions. Playwright and Vitest are designed around function-based tests. Wrapping them in classes with decorators adds indirection. Shared state between test methods in a class can lead to subtle coupling. The @Exclude() decorator on every helper is noise.

Dynamic tests use a weaker pattern. Loop-generated tests (176 visual regression screenshots, 72 contrast checks) can't have per-method @Implements — they use coversACs at the class level, which says "this file covers these ACs" without linking specific tests to specific criteria. Less precise.

The Explosion Problem

The real question is: does this scale?

Right now: 20 features, 112 ACs, 19 test files. The compliance report fits on one screen. But imagine a larger project:

  • 100 features × 10 ACs each = 1,000 abstract methods across 100 feature files. That's 100 imports to manage, 1,000 decorator references to keep in sync, and a compliance report that scrolls for pages.

  • Epics containing features containing stories containing tasks — the C# version supports this hierarchy via generics (Feature<TParent>). The TypeScript version doesn't implement hierarchy yet. Adding it means: EpicFeature<TEpic>Story<TFeature> → each with their own ACs. The number of abstract classes and cross-references grows combinatorially.

  • Multi-team, multi-repo — the scanner reads files from disk. If requirements span multiple repositories, the scanner can't see them. The C# version solves this with NuGet packages and project references. The TypeScript version would need npm packages or a monorepo.

Where It Breaks Down

Scale Works? Why
1-20 features Yes Fits in your head, report on one screen
20-50 features Probably Scanner still fast, but managing 50 feature files gets tedious
50-200 features Needs tooling Scanner needs filtering (by team, by priority), feature files need code generation
200+ features Needs a different approach At this scale, you want a database + UI, not abstract classes in files

What This System Is and Isn't

It IS: a type-safe traceability chain for small-to-medium projects where the developer who writes the feature also writes the tests. The compiler catches broken links. The scanner catches gaps. The self-tracking verifies the infrastructure.

It ISN'T: a replacement for Jira, Azure DevOps, or Linear. It doesn't handle: assignment, estimation, sprint planning, dependencies between teams, or approval workflows. It tracks the technical chain (feature → test), not the project management chain (who → when → approved by whom).

The sweet spot is exactly where this website sits: a single developer, ~20 features, ~100 ACs, all in one repo. The overhead of the decorator system pays for itself in compile-time safety and compliance visibility. Beyond that, the costs grow faster than the benefits — and you're better off with a real requirements management tool that generates the feature definitions, rather than writing them by hand.

Could It Be Simpler?

Yes. If you don't need the type-safe keyof T checking, you could use plain string tags in test names (test('TOC click @NAV:0', ...)), skip the decorator boilerplate entirely, and have the scanner grep for @FEAT:AC patterns. You lose compile-time typo detection but gain simplicity. For many projects, that's the right tradeoff.

The full decorator system exists here because this website is a demonstration of the approach — proving that the C# philosophy translates to TypeScript. In production, choose the level of ceremony that matches your project's size and risk tolerance.