0044: Sibling routes.d.ts with per-route typed importFn
Status: Proposed Date: 2026-05-12
Context
ADR 0034 shipped LoaderDataOf<P, R> —
a pure-type helper that derives a route's loader return shape from
the emitted manifest's typed dynamic imports. The catch (called out
in ADR 0034 itself):
The on-disk
routes.tsfile emitted by ADR 0032/0033 has dynamic imports with literal absolute paths, which TypeScript infers asPromise<typeof import('…')>. That gives us the route module's exports — including theloaderfunction's signature — at type level. But the user-authored ambient declaration for the virtual'purity:routes'module typesimportFnas() => Promise<unknown>, so consumers reading from'purity:routes'resolve toundefinedfor every loader.Apps that want strong typing have to import from the on-disk manifest directly:
import { routes } from './.purity/routes.ts'; type HomeData = LoaderDataOf<'/', typeof routes>;
This works, but it asks the user to thread two import surfaces (the virtual specifier for runtime, the on-disk file for types) through their codebase. The handoff item Path M called out tightening this asymmetry as the polish follow-up.
Decision
When the routes plugin is configured with emitTo (ADRs 0032 + 0033),
the plugin also writes a sibling .d.ts file next to the .ts. The
.d.ts declares the virtual 'purity:routes' module with literal
tuple types for routes / notFound / notFoundChain whose
importFn is typed per-entry as
() => Promise<typeof import('<abs>')>.
// AUTO-GENERATED by @purityjs/vite-plugin (ADR 0044). Do not edit.
declare module 'purity:routes' {
import type { LayoutEntry, RouteEntry } from '@purityjs/vite-plugin';
export const routes: readonly [
{
readonly pattern: '/';
readonly filePath: 'index.ts';
readonly importFn: () => Promise<typeof import('/abs/path/pages/index.ts')>;
readonly layouts: readonly [
{
readonly filePath: '_layout.ts';
readonly importFn: () => Promise<typeof import('/abs/path/pages/_layout.ts')>;
},
];
readonly hasLoader: true;
},
// …one entry per route, in manifest order
];
export const notFound: { readonly filePath: '_404.ts'; readonly importFn: …};
export const notFoundChain: readonly [ … ];
export type { LayoutEntry, RouteEntry };
}
The user wires the file into tsconfig.json's include (typically
already "src/**/*.ts"), removes their hand-rolled
purity-routes.d.ts (the ambient declaration with
Promise<unknown>), and gets the same typing surface from
'purity:routes' as from the on-disk .ts file.
LoaderDataOf<'/users/:id', typeof routes> works against either
import — the helper accepts R extends readonly unknown[], and
indexing into the literal tuple by pattern carries through to the
specific Promise<typeof import('…')> of that route.
Why a sibling .d.ts (not just emit .ts)
The on-disk .ts already has typed dynamic imports — TypeScript
infers Promise<typeof import('<abs>')> from each
() => import('<abs>') expression in the literal array. So why
not just point users at the on-disk file via a re-export?
Two reasons:
- The virtual specifier is the public surface.
'purity:routes'is the contract documented in every ADR and example. Asking users to import from./.purity/routes.tsfor types and'purity:routes'for runtime splits the API surface and makes the on-disk path part of every app's import graph. Worse, the on-disk path is gitignored — it doesn't exist on a fresh clone until someone runsvite build/vite dev. - Re-export from
.tsdoesn't preserve the literal tuple type.declare module 'purity:routes' { export * from './routes'; }would re-export the runtimeroutesarray but the type would widen to the array's declared type (RouteEntry[]), losing the per-route literalpatternand per-routeimportFnshape thatLoaderDataOf<P, typeof routes>keys off. Emitting the literal tuple types directly in the.d.tsis the only way to keep the per-route info intact across the virtual-module boundary.
Implementation
packages/vite-plugin/src/routes.ts adds
generateRouteManifestTypes(manifest, absPathFor). The shape mirrors
generateRouteManifestSource — same manifest, same absPathFor
callback for absolute-path resolution, but emits a declare module
block with literal tuple types instead of a runtime array.
packages/vite-plugin/src/index.ts adds typesPathFor(absPath) which
swaps .ts → .d.ts (or appends .d.ts for any other extension).
Both buildStart (ADR 0033 path) and load (ADR 0032 path) now write
the .d.ts alongside the .ts when emitTo is set. The same
content-equality check that prevents .ts rewrites also prevents
.d.ts rewrites — no extra filesystem-watch loops in dev.
Side-fixes shipped this iteration
emitManifestToDiskWindows parent-dir bug. The pre-existing parent-dir computation used a POSIX-only regex (/\/[^/]+$/) that silently failed on Windows backslash paths, leaving the parent un-created and producingENOENTonwriteFileSync. Replaced withnode:path.dirname. Routes-emit tests on Windows now pass.- Mixed-separator emit paths on Windows.
posix.join(dir, file)with a Windows-nativedirproduced strings likeC:\Users\...\pages/index.ts. Now normalisesdirwithdir.replace(/\\/g, '/')before joining so emit paths are POSIX-consistent. TS dynamic-import specifiers and the new typedimport('<abs>')references both prefer forward slashes.
Non-features (deferred)
'purity:routes'virtual-module type-only mode. Some teams may prefer to keep using the user-authored ambient declaration and skip the on-disk emit entirely. Today the.d.tsonly fires alongsideemitTo. Emitting a.d.tsto a default location even withoutemitTowould require an extra option; not worth it for Phase 1.include-by-default in tsconfig. The user still has to point their tsconfig at the emitted.d.ts(typically a no-op sincesrc/**/*.tscovers it). Apurity initcommand that touches tsconfig is out of scope.- Generated path is committed vs gitignored. Same trade-off as
ADR 0032's
.tsemit — recommended.gitignore'd so each clone regenerates. Users who want pinned types can commit it.
Test surface
packages/vite-plugin/tests/routes-types-emit.test.ts:
- Pure codegen —
declare moduleblock, literal tuple types, per-routeimportFn, layout chains, error-boundary + hasLoader markers, notFound entry, notFoundChain. 'purity:routes're-exportsLayoutEntry/RouteEntry.- Plugin integration —
.d.tsis written next to.tsonload(). - Plugin integration —
.d.tsis written atbuildStart(eager-emit path). - Plugin integration — no
.d.tswhenemitTois omitted. - Plugin integration — emitted
importFnis typed per-route, neverPromise<unknown>. - Plugin integration —
.tsxemit path appends.d.tsrather than swapping the extension. - Plugin integration — content-equality skip avoids filesystem-watch loops.