Skip to content

route: createRouteReference infers never path params for multi-segment patterns (EmptyRecord intersection) #177

Description

@rickylabs

Summary

createRouteReference('/a/[id]') infers a broken path-param type for any pattern that has a static segment before a dynamic one. The reference works at runtime (string substitution is correct), but .href({ path: { id } }) fails to type-check because the inferred path type collapses the dynamic param to never.

Repro

import { createRouteReference } from '@netscript/fresh/route';

const channel = createRouteReference('/channel/[id]', { id: 'channel', kind: 'page' });

// Runtime: correct → "/channel/c-123"
channel.href({ path: { id: 'c-123' } } as never);

// Type-check: ERROR
channel.href({ path: { id: 'c-123' } });
// TS2322: Type '{ id: string; }' is not assignable to type 'EmptyRecord & { id: string; }'.
//   Property 'id' is incompatible with index signature.
//   Type 'string' is not assignable to type 'never'.

(@netscript/fresh@0.0.1-alpha.12, Deno 2.9.)

Root cause

From deno doc @netscript/fresh/route:

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

type InferRoutePatternSegment<TSegment> =
  TSegment extends `[[...${infer P}]]` ? { [K in P]?: readonly string[] }
  : TSegment extends `[...${infer P}]`  ? { [K in P]: readonly string[] }
  : TSegment extends `[${infer P}]`     ? { [K in P]: string }
  : EmptyRecord;

For /channel/[id] → strip slash → channel/[id]InferRoutePatternSegment<'channel'> & InferRoutePatternPathSegments<'[id]'> = EmptyRecord & { id: string }.

EmptyRecord is Record<string, never> (has an index signature [k: string]: never). Intersecting it with { id: string } forces id: string & never = never, so any value for id is rejected. Every multi-segment route with a leading static segment (the common case — /channel/[id], /session/[a]/[b], …) is affected. A single-segment dynamic pattern (/[id]) is fine because there is no static EmptyRecord to intersect.

Suggested fix

Make static segments contribute {} (no index signature) instead of EmptyRecord, or accumulate only dynamic segments so the intersection never includes an index-signature type. Either of these makes /channel/[id] infer { id: string }:

// static segment → {} (not Record<string, never>)
type InferRoutePatternSegment<TSegment> =
  TSegment extends `[[...${infer P}]]` ? { [K in P]?: readonly string[] }
  : TSegment extends `[...${infer P}]`  ? { [K in P]: readonly string[] }
  : TSegment extends `[${infer P}]`     ? { [K in P]: string }
  : {}; // ← was EmptyRecord

(Then Prettify/clean the final intersection if you want a flat object type.)

Workaround in the meantime

Re-typing the reference with the intended path shape (runtime is already correct):

function pathRef<TPath extends object>(pattern: string, meta: { id: string; kind: 'page' }) {
  return createRouteReference(pattern, meta) as unknown as RouteReference<TPath, SearchParamInput>;
}
const channel = pathRef<{ id: string }>('/channel/[id]', { id: 'channel', kind: 'page' });

Secondary question (docs)

The routing docs imply withRoute(createRouteReference('/a/[id]')) yields a typed ctx.path, but an inference-only reference appears to carry no runtime path schema, so ctx.path is empty in the page loader (we read ctx.params.id instead). If that's expected, a note in web-layer/route.md distinguishing "typed outbound href" from "inbound ctx.path requires an explicit pathSchema/withParams" would help — happy to PR the doc clarification.

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