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

Part V: From Vanilla JS to TypeScript

The state machines were the trigger. Defining SpaNavCallbacks as a TypeScript interface replaced 20 lines of JSDoc with a compile-time contract. That was enough to justify migrating everything.

The Starting Point

The site launched with no build step. Three JS files loaded via <script> tags:

<script src="js/theme-switcher.js"></script>
<script src="js/markdown-renderer.js"></script>
<script src="js/app.js"></script>

Each file was an IIFE (Immediately Invoked Function Expression) that attached its public API to window:

// theme-switcher.js
(function() {
  function setTheme(theme) { /* ... */ }
  window.ThemeSwitcher = { setTheme, getTheme };
})();
// app.js
(function() {
  // Can use window.ThemeSwitcher because theme-switcher.js loaded first
  const theme = window.ThemeSwitcher.getTheme();
  // ...
})();

No module system. No imports. No exports. Script loading order determined dependency order. Cross-file communication happened through window globals.

This worked for 3 files. When the state machine extraction split app.js into 15 files plus 3 wiring files, the <script> tag approach became untenable:

<!-- This is absurd -->
<script src="js/lib/font-size.js"></script>
<script src="js/lib/accent-palette-state.js"></script>
<script src="js/lib/copy-feedback-state.js"></script>
<script src="js/lib/sidebar-resize-state.js"></script>
<script src="js/lib/spa-nav-state.js"></script>
<script src="js/lib/page-load-state.js"></script>
<script src="js/lib/scroll-spy-machine.js"></script>
<script src="js/lib/tour-state.js"></script>
<script src="js/lib/zoom-pan-state.js"></script>
<script src="js/lib/keyboard-nav-state.js"></script>
<script src="js/lib/toc-tooltip-state.js"></script>
<script src="js/lib/toc-breadcrumb-state.js"></script>
<script src="js/lib/toc-expand-state.js"></script>
<script src="js/lib/headings-panel-machine.js"></script>
<script src="js/lib/toc-scroll-state.js"></script>
<script src="js/app-shared.js"></script>
<script src="js/app-static.js"></script>
<script src="js/app-dev.js"></script>

18 script tags. Manual dependency ordering. No tree shaking. No dead code elimination. Every browser download includes every machine, even if the current page only uses three of them.

We needed a bundler. And if we're adding a build step anyway, we might as well use TypeScript.


Why TypeScript

The state machines were already typed -- in JSDoc comments. Every callback interface had a block like:

/**
 * @typedef {Object} SpaNavCallbacks
 * @property {(state: string, prev: string) => void} onStateChange
 * @property {(hash: string) => void} scrollToHash
 * @property {() => void} toggleHeadings
 * @property {(targetPath: string) => void} startFetch
 * @property {() => boolean} closeHeadings
 * @property {(html: string, targetPath: string) => void} swapContent
 * @property {(targetPath: string) => void} updateActiveItem
 * @property {(href: string, replace: boolean) => void} pushHistory
 * @property {(targetPath: string, hash: string|null) => void} postSwap
 */

20 lines of JSDoc to express what TypeScript says in 12:

export interface SpaNavCallbacks {
  onStateChange: (state: SpaNavState, prev: SpaNavState) => void;
  scrollToHash: (hash: string) => void;
  toggleHeadings: () => void;
  startFetch: (targetPath: string) => void;
  closeHeadings: () => boolean;
  swapContent: (html: string, targetPath: string) => void;
  updateActiveItem: (targetPath: string) => void;
  pushHistory: (href: string, replace: boolean) => void;
  postSwap: (targetPath: string, hash: string | null) => void;
}

But more importantly, the TypeScript version is enforced. If you pass a wiring layer that's missing closeHeadings, the compiler tells you. JSDoc doesn't. TypeScript also narrows the state type:

export type SpaNavState = 'idle' | 'fetching' | 'closingHeadings' | 'swapping' | 'settled';

// This is a compile-time error:
if (state === 'loading') { ... }  // TS2367: 'loading' is not assignable to SpaNavState

In JavaScript, state === 'loading' silently evaluates to false. In TypeScript, it's a red underline. We found three such bugs during migration -- typos in state name comparisons that had been silently wrong for weeks.


tsconfig: The Decisions That Matter

Here's the full tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "./dist",
    "rootDir": ".",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "esModuleInterop": true,
    "allowImportingTsExtensions": true,
    "noEmit": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*.ts", "requirements/**/*.ts", "test/**/*.ts"],
  "exclude": ["node_modules", "dist", "public"]
}

Let's walk through the non-obvious choices:

target: ES2022

This is a CV website, not a library. The audience is recruiters and tech leads using modern browsers. ES2022 gives us Array.at(), top-level await, Error.cause, Object.hasOwn(), and class fields without downleveling. No polyfills needed.

module: ESNext + moduleResolution: bundler

The source files use import/export. But the browser doesn't run them directly -- esbuild bundles them into IIFEs. The bundler resolution mode tells TypeScript to resolve imports the way esbuild does, not the way Node.js does. This allows import { createSpaNavMachine } from './lib/spa-nav-state' without the .js extension.

noEmit: true

TypeScript doesn't produce output files. It only type-checks. esbuild handles the actual transpilation and bundling. This is a deliberate split: TypeScript for correctness, esbuild for speed.

strict: true

Enables all strict checks: strictNullChecks, noImplicitAny, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, alwaysStrict. Every one of these caught bugs during migration.

noUncheckedIndexedAccess: true

This is the most impactful setting. It makes array and object index access return T | undefined instead of T:

const FONT_STEPS = [12, 13, 15, 17, 18] as const;

// Without noUncheckedIndexedAccess:
const step = FONT_STEPS[idx];  // type: 12 | 13 | 15 | 17 | 18

// With noUncheckedIndexedAccess:
const step = FONT_STEPS[idx];  // type: 12 | 13 | 15 | 17 | 18 | undefined

This caught real bugs. The original detectByScroll() returned headings[0].id at the end without checking if headings was empty:

// Original JS — crashes on empty headings array
if (!active && headings.length) active = headings[0].id;

With noUncheckedIndexedAccess, headings[0] is HeadingPosition | undefined. The fix:

if (!active && headings.length) active = headings[0]!.id;

The ! asserts we've already checked headings.length. Some developers dislike ! assertions, but here it's correct -- we just verified the array isn't empty. The alternative is a redundant if (headings[0]) check that adds noise without value.

experimentalDecorators: true

Required for the @FeatureTest and @Implements decorators in the test files (covered in Part VI). These decorators link test methods to feature requirements for compliance scanning.


esbuild: TypeScript to Browser Bundles

The scripts/build-js.ts file configures esbuild to bundle 7 entry points:

const ENTRIES: EntryPoint[] = [
  { src: 'src/app-shared.ts', out: 'app-shared.js' },
  { src: 'src/app-static.ts', out: 'app-static.js' },
  { src: 'src/app-dev.ts', out: 'app-dev.js' },
  { src: 'src/tour.ts', out: 'tour.js' },
  { src: 'src/theme-switcher.ts', out: 'theme-switcher.js' },
  { src: 'src/search.ts', out: 'search.js' },
  { src: 'src/markdown-renderer.ts', out: 'markdown-renderer.js' },
];

Each entry point imports the state machines it needs. esbuild resolves the imports, tree-shakes unused code, and produces a single IIFE bundle:

const options = {
  entryPoints: [srcPath],
  outfile: path.join(OUT, entry.out),
  bundle: true,
  format: 'iife',
  platform: 'browser',
  target: 'es2022',
  sourcemap: false,
  minify: false,  // build-static.js handles minification
};

Why IIFE?

The site doesn't use a runtime module system. There's no webpack runtime, no SystemJS, no import maps. Each <script> tag loads a self-contained file that executes immediately. IIFE is the format that matches this requirement:

// Generated from src/app-shared.ts — do not edit directly
(() => {
  // All imports resolved and inlined
  var FONT_STEPS = [12, 13, 15, 17, 18];
  function createFontSizeManager(options) { /* ... */ }
  // ...
  window.AppShared = { /* exports */ };
})();

esbuild inlines all imported modules into the IIFE. No runtime import resolution. No module loading. The browser sees a single function that runs immediately and attaches its API to window.

No globalName

esbuild's globalName option would automatically assign the IIFE result to a global variable. We don't use it because each TypeScript file manages its own window assignment:

// At the bottom of src/app-shared.ts
(window as any).AppShared = {
  createFontSizeManager,
  createPaletteMachine,
  // ... other exports
};

This gives each file control over exactly what it exposes and under what name. The as any cast is the only impurity in the otherwise type-safe code -- the bridge between the module system (TypeScript) and the runtime (browser globals).

Watch Mode

For development, esbuild's watch mode rebuilds on file change:

if (watch) {
  const ctx = await esbuild.context(options);
  await ctx.watch();
} else {
  await esbuild.build(options);
}

Sub-millisecond rebuilds. The dev server picks up the new file immediately.


Two Build Pipelines

The site has two paths from TypeScript to execution:

Diagram

Browser path: src/*.ts → esbuild → js/*.js (IIFE) → <script> tags in HTML. The state machines and wiring layers are bundled for the browser.

Node.js path: scripts/*.ts and test/*.ts → tsx → direct execution. Build scripts, the CLI workflow tool, and all tests run directly in Node.js through tsx (TypeScript Execute), which transpiles on the fly.

tsx is not a bundler. It doesn't produce output files. It hooks into Node.js's module resolution and transpiles each file as it's imported. This means:

# No compile step needed
npx tsx scripts/build-static.ts
npx tsx scripts/workflow.ts serve dev
npx vitest run  # vitest uses tsx internally

TypeScript files run as if they were JavaScript. Import resolution, type erasure, and decorator emission all happen transparently.


The Migration Pattern

The migration was incremental -- one file at a time, each independently deployable. The pattern for each file:

Step 1: Rename

mv js/lib/font-size.js src/lib/font-size.ts

Moving the file from js/lib/ (runtime output) to src/lib/ (source). esbuild will generate the output in js/.

Step 2: Add Type Annotations

Starting with function signatures:

- function createFontSizeManager(options) {
+ function createFontSizeManager(options: FontSizeOptions) {

Then state variables:

- let state = 'idle';
+ let state: SpaNavState = 'idle';

Step 3: Extract Interfaces

The callback parameters that were implicit in JS become explicit interfaces in TS:

// Before: options was { getStored, setStored, apply } — documented in JSDoc
// After: options is FontSizeOptions — enforced by compiler

export interface FontSizeOptions {
  getStored: () => number | null;
  setStored: (size: number) => void;
  apply: (size: number) => void;
}

Step 4: Extract State Types

String literals scattered through if chains become union types:

// Before: state === 'idle' || state === 'fetching' || ...
// After:
export type SpaNavState = 'idle' | 'fetching' | 'closingHeadings' | 'swapping' | 'settled';

Step 5: Fix Strict Errors

This is where TypeScript pays for itself. Real bugs found:

Bug 1: Missing null check in scroll spy

// Original JS — no error
const firstHeading = headings[0];
return firstHeading.id;

// TypeScript with noUncheckedIndexedAccess — error
// headings[0] is HeadingPosition | undefined
// Cannot access .id on potentially undefined value

Bug 2: Wrong state name in transition guard

// Original JS — silent bug
if (state === 'scrolling') return;  // Should be 'scrollingToItem'

// TypeScript — compile error
// 'scrolling' is not assignable to TocScrollState

Bug 3: Callback parameter mismatch

// Original JS — wrong parameter count, no error
onStateChange(next);  // Missing 'prev' parameter

// TypeScript — compile error
// Expected 2 arguments, but got 1

Each of these was a latent bug in the production code. Bug 2 meant a guard clause was never triggered (the state was never 'scrolling', it was 'scrollingToItem'). Bug 3 meant the state change listener never received the previous state, breaking change detection.


Handling External Libraries

The site uses three external libraries loaded via CDN: marked.js, highlight.js, and Mermaid. These are <script> tags, not npm imports. TypeScript needs to know they exist.

An externals.d.ts file declares the globals:

// src/externals.d.ts
declare const marked: {
  parse(src: string, options?: Record<string, unknown>): string;
  use(extension: Record<string, unknown>): void;
};

declare const hljs: {
  highlightElement(element: HTMLElement): void;
  getLanguage(name: string): unknown | undefined;
};

declare const mermaid: {
  initialize(config: Record<string, unknown>): void;
  run(options: { nodes: NodeListOf<Element> }): Promise<void>;
};

These declarations tell TypeScript: "trust me, these globals exist at runtime." The declarations are minimal -- just enough to type-check the code that uses them. For full type safety, you'd install @types/marked etc., but for a CV site with three CDN scripts, handwritten declarations are simpler and more honest about what's actually available.


The Window Bridge Pattern

The TypeScript source uses import/export. The browser uses window globals. The bridge between them is a deliberate cast at the bottom of each entry point:

// At the bottom of src/app-shared.ts
import { createFontSizeManager } from './lib/font-size';
import { createPaletteMachine } from './lib/accent-palette-state';
import { createCopyFeedback } from './lib/copy-feedback-state';
// ... more imports

// Wire everything up
const fontManager = createFontSizeManager({ /* callbacks */ });
const palette = createPaletteMachine('green', { /* callbacks */ });
// ... more wiring

// Expose to other scripts via window
(window as any).AppShared = {
  fontManager,
  palette,
  // ... other exports
};

The as any cast is ugly but honest. It says: "I'm crossing the module boundary into the global namespace, and TypeScript can't verify this." The alternative -- a .d.ts file declaring window.AppShared -- is possible but adds complexity for no real safety gain (the consuming script is also your code).

Each entry point has exactly one window assignment at the bottom. The rest of the file is fully typed TypeScript. This keeps the "unsafe zone" small and visible.

Why Not Use ES Modules in the Browser?

ES modules in the browser (<script type="module">) would eliminate the window bridge entirely. Each file would import directly:

<script type="module" src="js/app-shared.js"></script>

We chose not to for two reasons:

  1. The existing architecture. The site already worked with <script> tags and IIFE globals. Changing to ES modules would require modifying the HTML loader, the build process, and the dev server. For a CV site, that's unnecessary churn.

  2. CDN libraries. marked.js, highlight.js, and Mermaid are loaded via CDN <script> tags. They expose globals (window.marked, window.hljs, window.mermaid). Mixing ES modules with global-exposing scripts requires careful ordering and sometimes import() workarounds.

The IIFE approach is pragmatic. It works with the existing architecture, requires no HTML changes, and produces identical runtime behavior to the original vanilla JS. The window bridge is the only visible compromise.


Migration Example: CopyFeedbackMachine

To make the migration concrete, here's the before and after for one complete machine:

Before (JavaScript, in app.js):

// Scattered across app.js, not a standalone module
let copyTimeouts = new WeakMap();

function wireCodeCopyButtons() {
  document.querySelectorAll('.copy-btn').forEach(btn => {
    btn.addEventListener('click', async () => {
      const existing = copyTimeouts.get(btn);
      if (existing) clearTimeout(existing);

      const code = btn.closest('pre').querySelector('code').textContent;
      btn.textContent = '📋';

      try {
        await navigator.clipboard.writeText(code);
        btn.textContent = '✅';
      } catch {
        btn.textContent = '❌';
      }

      copyTimeouts.set(btn, setTimeout(() => {
        btn.textContent = '📋';
        copyTimeouts.delete(btn);
      }, 2000));
    });
  });
}

After (TypeScript, src/lib/copy-feedback-state.ts):

export type CopyFeedbackState = 'idle' | 'copying' | 'success' | 'error';

export interface CopyFeedbackOptions {
  resetDelay: number;
  onStateChange?: (state: CopyFeedbackState, prev: CopyFeedbackState) => void;
  scheduleReset?: (delayMs: number, callback: () => void) => void;
  cancelReset?: () => void;
}

export interface CopyFeedbackMachine {
  copy(): void;
  succeed(): void;
  fail(): void;
  reset(): void;
  getState(): CopyFeedbackState;
}

function canTransition(from: CopyFeedbackState, to: CopyFeedbackState): boolean {
  switch (from) {
    case 'idle':    return to === 'copying';
    case 'copying': return to === 'success' || to === 'error';
    case 'success': return to === 'idle' || to === 'copying';
    case 'error':   return to === 'idle' || to === 'copying';
  }
}

export function createCopyFeedback(options: CopyFeedbackOptions): CopyFeedbackMachine {
  // ... 50 lines of pure logic, no DOM
}

The wiring layer (in app-shared.ts):

const copyMachine = createCopyFeedback({
  resetDelay: 2000,
  onStateChange: (state) => {
    btn.textContent = getLabel(state);
  },
  scheduleReset: (ms, cb) => {
    clearTimeout(timerId);
    timerId = setTimeout(cb, ms);
  },
  cancelReset: () => clearTimeout(timerId),
});

btn.addEventListener('click', async () => {
  copyMachine.copy();
  try {
    await navigator.clipboard.writeText(code);
    copyMachine.succeed();
  } catch {
    copyMachine.fail();
  }
});

The total line count is higher (105 lines for the machine + 20 lines for the wiring = 125 vs. the original 25). But the machine has a complete transition table, timer injection, and is covered by 15 unit tests. The original 25 lines had a race condition and zero tests.

More code is not always worse code. The question is: where does the complexity live? In the original, it lived in the DOM event handler where it was untestable. In the TypeScript version, it lives in the state machine where it's exercised by 15 test cases in 200ms.


Migration Order and Dependency Graph

The migration wasn't random. Files were migrated in dependency order -- leaf files first, then the files that import them:

Diagram

The 15 state machines have zero internal dependencies -- they don't import each other. They were migrated first, in any order. Each migration was self-contained: rename the file, add types, fix strict errors, run tests.

The wiring layers (app-shared.ts, app-static.ts, app-dev.ts) were migrated last because they import the machines. These files required the most work: converting DOM event handlers to typed callbacks, adding type annotations to the composition glue code, and handling the window bridge pattern.

The "One File at a Time" Guarantee

The migration was designed so that at every step, the site worked. Here's how:

  1. Before migration: js/app.js is a monolith. It works.
  2. Extract one machine: Move sidebar resize logic to src/lib/sidebar-resize-state.ts. Import it in src/app-shared.ts. Run build:js. The output is js/app-shared.js with the machine inlined. The site works.
  3. Extract another machine: Move copy feedback to src/lib/copy-feedback-state.ts. Same process. The site still works.
  4. Repeat 13 more times.

At no point was the site broken by a partial migration. Each machine was extracted, typed, and tested before moving to the next. If the migration had been abandoned at any point, the site would still have worked with a mix of migrated and un-migrated code.

This is the advantage of the IIFE approach: the output format didn't change. js/app-shared.js was always a self-contained IIFE, whether it was hand-written JavaScript or esbuild-bundled TypeScript.


What the Migration Cost

  • Time: About 6 hours across 3 sessions. Most time was spent on the wiring layers (app-shared.ts, app-static.ts), not the state machines.
  • New dependencies: esbuild (bundler), tsx (TypeScript runner). Both are single-binary, near-instant tools. No webpack. No babel.
  • Build step: The site now requires npm run build:js before serving. For development, esbuild watch mode makes this transparent.
  • Complexity: tsconfig.json (35 lines), build-js.ts (79 lines). That's the total infrastructure.

What the Migration Delivered

  • 3 bugs found during migration (wrong state names, missing null checks, callback mismatches)
  • Compile-time enforcement of all callback contracts. Can't pass the wrong callbacks to a machine.
  • IDE support: autocomplete, go-to-definition, rename-across-project for all state types and interfaces
  • Self-documenting interfaces: SpaNavCallbacks is both the API documentation and the type contract
  • Foundation for testing: Vitest can import .ts files directly through tsx. No separate compile step for tests.

What It Didn't Change

  • Runtime behavior: The generated JS is functionally identical to the original JS. Same IIFEs, same window globals, same execution order.
  • File structure: src/lib/ maps to js/lib/. The directory structure didn't change.
  • Bundle size: esbuild produces slightly smaller output than hand-written IIFEs due to variable shortening, but the difference is negligible for 15 small state machines.

esbuild

Used for: browser bundles.

TypeScript → esbuild → IIFE JavaScript → <script> tags

Speed: builds all 7 entry points in ~50ms. Watch mode rebuilds in <5ms.

Why esbuild: it handles TypeScript natively (type-strips, doesn't type-check), supports IIFE output, bundles imports, and is fast enough to run on every save. No configuration file -- the options are in build-js.ts.

tsx

Used for: everything else (scripts, tests, CLI).

TypeScript → tsx → Node.js execution (no output files)

Speed: startup overhead ~100ms, then runs at native speed.

Why tsx: Node.js can't run .ts files directly (yet). tsx hooks into the module loader and transpiles on import. This means npx tsx scripts/build-static.ts works with zero configuration. Vitest uses the same approach internally.


TypeScript and the State Machine Pattern

The migration wasn't just about converting JavaScript to TypeScript. It fundamentally improved the state machine pattern in three ways:

1. Discriminated Unions for State Types

In JavaScript, state was a string compared with ===:

if (state === 'idle') { ... }
if (state === 'fetching') { ... }
if (state === 'scrolling') { ... }  // Typo! Should be 'scrollingToItem'

In TypeScript, the state type is a union, and the compiler catches typos:

type TocScrollState =
  | 'idle'
  | 'scrollingToItem'
  | 'itemSettled'
  | 'childrenExpanding'
  | 'checkingVisibility'
  | 'scrollingToChildren'
  | 'settled'
  | 'userScrollLocked';

if (state === 'scrolling') { ... }
// Error: This comparison appears to be unintentional because
// the types '"scrolling"' and 'TocScrollState' have no overlap.

This is not just nice -- it prevents real bugs. The state names are long and easy to mistype. The compiler is the guard.

2. Exhaustive Switch Statements

TypeScript enforces exhaustive switch statements on union types:

function getLabel(state: CopyFeedbackState): string {
  switch (state) {
    case 'idle':    return '📋';
    case 'copying': return '📋';
    case 'success': return '✅';
    case 'error':   return '❌';
    // If you add a new state to CopyFeedbackState and forget to handle it here,
    // TypeScript reports an error: "Not all code paths return a value."
  }
}

When someone adds a new state to the union (e.g., 'retrying'), every switch statement that doesn't handle it becomes a compile error. The compiler tells you everywhere the new state needs to be handled. In JavaScript, the switch silently falls through to undefined.

3. Callback Contracts as Interfaces

The most impactful change. In JavaScript, a machine's callbacks were documented in comments and enforced by nothing:

/**
 * @param {Object} callbacks
 * @param {Function} callbacks.onStateChange - Called on state change
 * @param {Function} callbacks.scrollToHash - Scroll to anchor
 * ... 7 more callbacks
 */
function createSpaNavMachine(callbacks) {
  // callbacks.scrollToHash might not exist
  // callbacks.onStateChange might have the wrong signature
  // Nobody will know until runtime
}

In TypeScript, the callbacks are an interface:

interface SpaNavCallbacks {
  onStateChange: (state: SpaNavState, prev: SpaNavState) => void;
  scrollToHash: (hash: string) => void;
  // ... 7 more, each fully typed
}

function createSpaNavMachine(callbacks: SpaNavCallbacks): SpaNavMachine {
  // Every callback is guaranteed to exist
  // Every callback has the correct signature
  // The compiler enforces this at every call site
}

If the wiring layer in app-static.ts passes an object missing scrollToHash, the compiler refuses to build. If it passes a callback with the wrong parameter types, the compiler refuses. The interface is the contract, enforced at compile time, documented for free.

This is why TypeScript was worth the migration even for a small project. The interfaces are not overhead -- they're the specification. And the specification compiles.


What's Next

The TypeScript migration gave us type-safe interfaces and compile-time guarantees. But type safety alone doesn't prove correctness. For that, we need tests.

Part VI: Testing Pure State Machines covers the testing strategy: Vitest unit tests at 98%+ coverage, property-based tests with fast-check, Playwright E2E tests for the wiring layer, and the @FeatureTest decorator system that links tests to feature requirements.