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.ts file emitted by ADR 0032/0033 has dynamic imports with literal absolute paths, which TypeScript infers as Promise<typeof import('…')>. That gives us the route module's exports — including the loader function's signature — at type level. But the user-authored ambient declaration for the virtual 'purity:routes' module types importFn as () => Promise<unknown>, so consumers reading from 'purity:routes' resolve to undefined for 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:

  1. 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.ts for 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 runs vite build / vite dev.
  2. Re-export from .ts doesn't preserve the literal tuple type. declare module 'purity:routes' { export * from './routes'; } would re-export the runtime routes array but the type would widen to the array's declared type (RouteEntry[]), losing the per-route literal pattern and per-route importFn shape that LoaderDataOf<P, typeof routes> keys off. Emitting the literal tuple types directly in the .d.ts is 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

  • emitManifestToDisk Windows 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 producing ENOENT on writeFileSync. Replaced with node:path.dirname. Routes-emit tests on Windows now pass.
  • Mixed-separator emit paths on Windows. posix.join(dir, file) with a Windows-native dir produced strings like C:\Users\...\pages/index.ts. Now normalises dir with dir.replace(/\\/g, '/') before joining so emit paths are POSIX-consistent. TS dynamic-import specifiers and the new typed import('<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.ts only fires alongside emitTo. Emitting a .d.ts to a default location even without emitTo would 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 since src/**/*.ts covers it). A purity init command that touches tsconfig is out of scope.
  • Generated path is committed vs gitignored. Same trade-off as ADR 0032's .ts emit — 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 module block, literal tuple types, per-route importFn, layout chains, error-boundary + hasLoader markers, notFound entry, notFoundChain.
  • 'purity:routes' re-exports LayoutEntry / RouteEntry.
  • Plugin integration — .d.ts is written next to .ts on load().
  • Plugin integration — .d.ts is written at buildStart (eager-emit path).
  • Plugin integration — no .d.ts when emitTo is omitted.
  • Plugin integration — emitted importFn is typed per-route, never Promise<unknown>.
  • Plugin integration — .tsx emit path appends .d.ts rather than swapping the extension.
  • Plugin integration — content-equality skip avoids filesystem-watch loops.