ReviewOS

stacks/stx

fix(types): the generated route declaration is dangling — `RouteMap` constrains nothing and `navigate('/typo')` type-checks

#1887
Closed glennmichael123 opened this 14 days ago · 1 comment
14 days ago

Summary

generateRouteTypes() emits a RouteMap of every route in the project, and nothing in the source tree declares or imports the module it augments. The declaration parses, ships, and constrains nothing. Typed route navigation does not exist today despite the file that claims to provide it.

This is the same shape as #1879 (PageMeta.layout declared and never read), one level up — found while fixing that one.

Evidence

packages/router/src/codegen.ts:6-26 writes .stx/route-types.d.ts:

declare module "stx/routes" {
  interface RouteMap {
    '/about': {  }
    '/posts/:slug': { slug: string }
  }
}

stx/routes is not a real module. Grepping the workspace for who declares or imports it:

$ grep -rn "RouteMap" packages/stx/src packages/router/src --include="*.ts" | grep -v codegen.ts
packages/stx/src/dev-server/serve-multi.ts:29:interface RouteMapping {   # unrelated name collision

Nothing. And the navigation API it is supposed to constrain is plain string — packages/stx/stx.d.ts:127:

declare function navigate(_url: string, _options?: StxNavigateOptions): void

So navigate('/dashbaord') type-checks, <StxLink to="/dashbaord"> type-checks, and the failure surfaces as a 404 at runtime.

Because declare module "stx/routes" names a specifier that does not resolve, TypeScript treats it as an ambient module declaration rather than an augmentation — which is exactly why it produces no error and no diagnostic about being unused.

Why it matters

Route names are load-bearing strings that no compiler currently checks, and the same generator's sibling output (.stx/routes.ts) is consumed. The typed half is the half that silently does nothing.

Nuxt types navigateTo against generated route names; TanStack Router and Next's typedRoutes both make an unknown route a build error.

Constraint

There are ~87 navigate( and ~115 StxLink references in this repo's own tests and docs alone. Many pass template literals, query strings, hashes or variables. A naive union would reject valid code, so the fix has to catch typos on static string literals without rejecting computed URLs.

Prior art in this repo

#1879 landed a working precedent — packages/stx/src/layout-types.ts + KnownLayouts/LayoutName in head.ts. It augments the real package (@stacksjs/stx), is referenced by a real signature, and degrades to string when the interface is empty so projects without generated types are unaffected. Verified by compiling a real project with tsc, precisely because a string-shaped assertion would have passed for the dangling version too.

Ask

  1. Make the generated declaration augment the real package and be referenced by a real signature, so it can actually produce an error.
  2. Type navigate() and <StxLink to> so a static-literal typo errors while computed URLs still compile.
  3. Degrade to string when no declaration has been generated.
  4. Test it by compiling a real project, not by asserting on the emitted string — that assertion passes for the dangling version too.
  5. Either fix or delete declare module "stx/routes". A declaration that constrains nothing is worse than none.

Related: #1879, #1798, #1849 (same class: typed and never read).

14 days ago

Fixed in 08e6417.

The design problem. Two failure modes pull against each other, and the obvious fix hits one or the other:

  • RoutePath = KnownRoute | (string & {}) catches nothing. A parameter that accepts a computed string accepts every string literal too — the same trade the dangling declaration already made, just with autocomplete.
  • A plain union rejects real code. Mapping the repo found ~10% of to= sites and a majority of non-doc navigate() shapes would break, including the #1777 regression tests and a pattern the docs explicitly prescribe. And the union holds patterns (/cars/:id) while callers pass URLs (/cars/42) — against a real app it scored zero true positives and five false ones.

So the check is CheckHref<T>, a generic gate on the argument's own literal type: non-literals pass, un-adopted projects pass, and only a leading-slash literal is checked against routes expanded from their patterns. Verified by compiling a real project — 20 shapes that must compile, 4 that must not:

compile   /  /about  /cars/42  /settings  /settings/profile  /about?ref=x  /about#top
          ?status=resolved   guide   https://example.com/pricing   ./sibling
          nextUrl   `/cars/${id}`   '/cars/' + id   ternary   forEach(u => navigate(u))
          $path('/cars/:id', { id: 7 })   $path('/settings/:tab?')
error     /abuot   /host/dashbaord   /cars/:id   ternary with a typo arm

$path('/cars/:id', { id }) is the strict half for dynamic routes.

Three bugs found along the way, all fixed here:

  1. Catch-all params were declared string[]. A catch-all is one capture containing slashes — /blog/:slug* against /blog/2024/jan/hi yields { slug: '2024/jan/hi' }. No consumer could have used the old type correctly. (Nothing consumed it, so nobody found out.)
  2. Optional params were declared required, so $path('/settings/:tab?') demanded an argument for the segment whose entire point is being absent. Caught by the fixture, not by review.
  3. generateRouteTypes did not filter non-page routes while its sibling generateRouteManifest did — so the manifest and the type declaration disagreed about which routes exist. Same two-lists-disagree shape as #1866.

Also worth recording: the trailing export {} is load-bearing, not style. Without it the declare module block replaces the package rather than augmenting it.

Ask 5 resolved as fix-not-delete. Covered by packages/stx/test/route-types.test.ts (17 tests); verified non-vacuous by restoring the old specifier, which fails four of them. Full suite: 11211 pass, 1 pre-existing unrelated failure.

Adoption caveat that needs to ship with this: tsconfig wildcard expansion skips dot-directories, so "**/*.d.ts" does not pick up .stx/route-types.d.ts. Projects must list it explicitly ("include": [".stx/*.d.ts"]) or the check silently no-ops — same for the layout types from #1879.

Deliberately out of scope, each worth its own issue:

  • <StxLink to> — 88% of route literals in this repo, and tsc cannot reach .stx markup at all. Needs a build-time attribute validator or LSP support, sharing this predicate or the two checkers will contradict each other.
  • route() / Router.resolve — their interpolators have their own bugs (/users/42?, /blog/2024%2Fjan%2Fhi*); they should delegate to $path before being typed.
  • A root-level catch-all (/:slug*) silently makes the gate inert — stx doctor should say so.
  • x-to on <StxLink> is documented in CLAUDE.md but never read by resolveTo, so the anchor stays at href="#" and the router refuses to intercept it. Real bug, unrelated to types.

Sign in to comment on this issue.