Single source of truth
"Right — so the insight here is that
createRouterFromToolsmakes the array the single source of truth, which means the type system and the runtime guard are describing the same thing, not two separate bets on what the developer remembered to keep in sync." — Matt Pocock (round 2, 9/10)
What we did
nextAllowed is declared inline on the tool itself, via defineTool. The router constructor takes a const tuple of defineTool outputs and derives the name union from tools[number]['name']. There is no:
- separate name registry,
- separate adjacency declaration,
- separate type-level mapping the developer has to keep in sync with the runtime mapping.
const search = defineTool({ name: 'search', nextAllowed: ['review'] as const, /* ... */ });
const review = defineTool({ name: 'review', nextAllowed: ['commit'] as const, /* ... */ });
const commit = defineTool({ name: 'commit', nextAllowed: [] as const, /* ... */ });
const router = createRouterFromTools([search, review, commit] as const, {
strictMode: true,
}); Why
Round 1's draft proposal had a separate defineGraph({ search: ['review'] }) next to the tool definitions. Pocock pushed back hard:
that's two declarations the developer has to keep aligned, and renames
silently break one without breaking the other. The pivot — drive the
union from the array literal — collapses the two declarations into
one. Both layers (the inferred NextTools<R, Prev> type and the
runtime adjacency map the guard reads) consult the same nextAllowed array.
Three concrete consequences:
- Renames are mechanical. Rename
review→inspectat itsdefineToolsite. Every other tool that listed'review'in itsnextAllowedis now a TypeScript error at its site, not yours. - The compile error and the runtime message reference the same
names. A Sentry alert showing
legalNext: ['review']matches the IDE's'commit' does not exist in type '{ review: ... }'— verbatim. - The graph is inspectable from one place.
printRouterGraphreadsrouter.adjacency, which is a literal copy of thenextAllowedarrays. There is no second view of the graph that could disagree.
How createRouterFromTools enforces it
function createRouterFromTools<const Tools extends readonly ToolRouteDef[]>(
tools: Tools,
options?: RouterOptions,
): Router<Tools> {
const names = new Set(tools.map((t) => t.name));
for (const t of tools) {
for (const n of t.nextAllowed) {
if (!names.has(n)) {
throw new Error(
`[ToolRoute] Tool '${t.name}' references unknown tool '${n}' in nextAllowed.`,
);
}
}
}
// ... wrapped tools and adjacency map are derived from the same array
} The same check runs at the type level via the literal nextAllowed tuples plus tools[number]['name'] — the TypeScript error fires before
the constructor ever runs.