Runtime before types

"OK so here's the thing — the runtime guard going in before the types is not a minor detail, that IS the idea, and without it you're just shipping vibes.ts." — Theo Browne (round 2)

What we did

We built the 50-line runtime guard first — a pure function checkTransition(prev, next, adjacency, strictMode, routerVersion) — and wired it into the three-tool code-review agent fixture. We recorded the terminal session of a real streamText run rejecting commit before review with strictMode: true. Only after that recording was committed did we layer the type machinery (defineTool, createRouterFromTools, NextTools<R, Prev>) on top.

The captured run from the spike (issue 001 in the project tracker) — pure runtime, no type machinery:

The committed cast file at assets/recordings/runtime-guard-spike.cast is replayable locally with npx asciinema play … — same bytes, no JS.

Why

If the type narrowing came first and the runtime check trailed behind, two failure modes become invisible:

  1. The narrowing could be wrong, and you would never know — there is no observable disagreement between code and runtime. Tests that assert "this fails to compile" are easy to write; tests that assert "this matches what actually happens at runtime" require a runtime to compare against.
  2. The runtime check could be skipped silently in some code path (e.g., a manual loop where the user uses tool.execute directly without streamText). A type-first design tempts you into believing the type is enough.

Building the runtime first inverts both. The narrowing is then forced to match observable behaviour, not merely describe it. Addy Osmani flagged this exactly in round 2:

"The recorded terminal session showing the rejection fires before a single type annotation is written — that sequencing tells me the runtime guarantee is real, not just a well-intentioned compile-time lie."

How we keep it honest

The package ships a single integration test that runs the same fixture through both layers and asserts they reject the same offending call:

// test/integration.test.ts
const legal = legalNextFor(router.adjacency, 'search');
expect(legal).toEqual(['review']);          // runtime view
expect(legal).not.toContain('commit');

await tools.search.execute({ query: 'q' }, ctx);
await expect(
  tools.commit.execute({ message: 'm' }, ctx),
).rejects.toBeInstanceOf(ToolRouteViolation); // runtime guard

Plus a type-level snapshot in test/narrow.test-d.ts asserting keyof NextTools<typeof router, 'search'> is exactly 'review'. If either layer drifts, both tests fail loudly.

Two recordings, one rejection
The repo ships two casts in assets/recordings/: runtime-guard-spike.cast (this page — runtime only, pre-types) and readme-hero.cast (type-level rejection + runtime, side by side). Both reject the same offending call. The difference is what evidence the cast carries.

Related