Skip to content

fix(route): InferRoutePatternSegment returns EmptyRecord for static segments, breaks multi-segment dynamic paths #178

Description

@rickylabs

Summary

InferRoutePatternSegment in packages/fresh/src/application/route/types.ts:114 returns EmptyRecord (= Record<string, never>) for static route segments. When intersected via & in InferRoutePatternPathSegments, the index signature [k: string]: never collapses all dynamic-segment fields to never. This makes createRouteReference("/channel/[id]").href({ path: { id: "c-123" } }) fail with TS2322: Type "{ id: string; }" is not assignable to type "never".

Confirmed reproduction against @netscript/fresh@0.0.1-alpha.12:

TS2322 [ERROR]: Type "{ id: string; }" is not assignable to type "never".
const ok: ChannelPathInput = { id: "c-123" };

Related

Root cause

// packages/fresh/src/application/route/types.ts:110-121 (CURRENT — buggy)
type InferRoutePatternSegment<TSegment extends string> =
  TSegment extends `[[...${infer TParam}]]`
    ? { [TKey in TParam]?: readonly string[] }
    : TSegment extends `[...${infer TParam}]` ? { [TKey in TParam]: readonly string[] }
    : TSegment extends `[${infer TParam}]` ? { [TKey in TParam]: string }
    : EmptyRecord;   // BUG: index signature narrows dynamic fields to never

type InferRoutePatternPathSegments<TPattern extends string> =
  TPattern extends `${infer TSegment}/${infer TRest}`
    ? InferRoutePatternSegment<TSegment> & InferRoutePatternPathSegments<TRest>   // missing Simplify<>
    : InferRoutePatternSegment<TPattern>;

Same bug mirrored in packages/fresh/src/application/route/_internal/contract-types.ts:74. The internal file already wraps the intersection in Simplify<> at line 78 — only the EmptyRecord{} change is needed there.

Prior-repo comparison

netscript-start/packages/fresh/route/contract.ts:65-74 (the previous version) had the correct implementation already:

type InferRoutePatternSegment<TSegment extends string> =
  TSegment extends `[[...${infer TParam}]]`
    ? { [TKey in TParam]?: readonly string[] }
    : TSegment extends `[...${infer TParam}]` ? { [TKey in TParam]: readonly string[] }
    : TSegment extends `[${infer TParam}]` ? { [TKey in TParam]: string }
    : {};   // was already {}

type InferRoutePatternPathSegments<TPattern extends string> =
  TPattern extends `${infer TSegment}/${infer TRest}`
    ? Simplify<InferRoutePatternSegment<TSegment> & InferRoutePatternPathSegments<TRest>>   // was already Simplify<>
    : InferRoutePatternSegment<TPattern>;

This is a regression introduced during the netscript-start → netscript port, not a design change. The fix is to restore the prior implementation.

Proposed fix

Two-line patch per file (do NOT change EmptyRecord itself — it is used as the "no path params" discriminator at types.ts:162-171 and many other places):

packages/fresh/src/application/route/types.ts

 type InferRoutePatternSegment<TSegment extends string> =
   TSegment extends `[[...${infer TParam}]]`
     ? { [TKey in TParam]?: readonly string[] }
     : TSegment extends `[...${infer TParam}]` ? { [TKey in TParam]: readonly string[] }
     : TSegment extends `[${infer TParam}]` ? { [TKey in TParam]: string }
-    : EmptyRecord;
+    : {};

 type InferRoutePatternPathSegments<TPattern extends string> =
   TPattern extends `${infer TSegment}/${infer TRest}`
-    ? InferRoutePatternSegment<TSegment> & InferRoutePatternPathSegments<TRest>
+    ? Simplify<InferRoutePatternSegment<TSegment> & InferRoutePatternPathSegments<TRest>>
     : InferRoutePatternSegment<TPattern>;

packages/fresh/src/application/route/_internal/contract-types.ts

 type InferRoutePatternSegment<TSegment extends string> =
   TSegment extends `[[...${infer TParam}]]`
     ? { [TKey in TParam]?: readonly string[] }
     : TSegment extends `[...${infer TParam}]` ? { [TKey in TParam]: readonly string[] }
     : TSegment extends `[${infer TParam}]` ? { [TKey in TParam]: string }
-    : EmptyRecord;
+    : {};

(Line 78 already has Simplify<>, no change needed there.)

Validation cases

The fix must keep these six cases green at type-check:

Pattern Expected InferRoutePatternPath
/channel/[id] { id: string }
/session/[a]/[b] { a: string; b: string }
/[id] (single dynamic, no static prefix) { id: string } (no regression)
/static/only {} (no regression — framework collapses to never for static href() input)
/posts/[[...slug]] { slug?: readonly string[] } (no regression)
/dashboard/products/[id]/edit (existing internal test case) { id: string }

Plus the runtime cases from contract.test.ts:176-235 must keep passing.

Tasks

  • Apply the 2-line patch to packages/fresh/src/application/route/types.ts
  • Apply the 1-line patch to packages/fresh/src/application/route/_internal/contract-types.ts
  • Add type-level regression tests in packages/fresh/src/application/route/contract.test.ts covering all six patterns above (the existing test at line 176 uses as unknown as casts that mask the bug — extend it with non-cast assertions)
  • Run deno task check:packages — must stay green
  • Run deno check --config deno.json apps/playground — must stay green (this is the integration gate that catches the WI-12 cross-link)

Cross-link to WI-12

Once this lands, WI-12's codegen work can proceed without the underlying type-inference regression breaking playground routes/(dashboard)/dashboard/orders/[id].tsx and similar multi-segment dynamic routes. Without this fix, WI-12's typed Link integration would surface the same TS2322 to every page module with a dynamic segment.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions