From 42138ab0aa9b203b4a8e92adb735725ebad00af5 Mon Sep 17 00:00:00 2001 From: Sven Rogge Date: Tue, 31 Mar 2026 16:29:37 +0200 Subject: [PATCH 01/12] feat: add type-safe routing with RoutePaths and typed state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add RawRoutes type constraint for route shape inference - Add RoutePaths utility type that walks route trees and emits valid path literals - Excludes guard ('?') keys - Handles wildcard ('*') segments as ${string} - Transparent virtual ('') routes - Add S = any state type parameter to: - NavMeta and StrictNavMeta - NavOpts - Resolve - Routes - Resolved - Router - RouterConf - OnResolveListener - Add R extends RawRoutes type parameter to Router and RouterConf for routes shape inference - Update createRouter to infer R from the passed routes via R & Routes intersection - Add typed go() overload accepting RoutePaths for IDE autocomplete - All changes are backward compatible with defaults - Add typecheck script to workspace packages Examples: const router = createRouter({ routes: { foo: () => 'foo', bar: { baz: () => 'baz' } } }); router.go('/foo'); // ✓ autocomplete router.go('/bar/baz'); // ✓ autocomplete router.go('/unknown'); // type error ✓ interface AppState { userId?: string } const router = createRouter({ routes: { foo: ({ state }) => `hello ${state?.userId}` } }); await router.go('/foo', { state: { userId: '123' } }); // ✓ --- packages/esroute-lit/package.json | 1 + packages/esroute/package.json | 1 + packages/esroute/src/nav-opts.ts | 20 +++--- packages/esroute/src/route-resolver.ts | 24 ++++---- packages/esroute/src/router.ts | 85 ++++++++++++++------------ packages/esroute/src/routes.ts | 30 +++++++-- 6 files changed, 94 insertions(+), 67 deletions(-) diff --git a/packages/esroute-lit/package.json b/packages/esroute-lit/package.json index 311872d..e0d4609 100644 --- a/packages/esroute-lit/package.json +++ b/packages/esroute-lit/package.json @@ -9,6 +9,7 @@ "homepage": "https://github.com/sv2dev/esroute/tree/main/packages/esroute-lit", "scripts": { "build": "tsc -b", + "typecheck": "tsc --noEmit", "prepublishOnly": "npm run build" }, "keywords": [ diff --git a/packages/esroute/package.json b/packages/esroute/package.json index 51cef5a..0d87bd4 100644 --- a/packages/esroute/package.json +++ b/packages/esroute/package.json @@ -10,6 +10,7 @@ "homepage": "https://github.com/sv2dev/esroute/tree/main/packages/esroute", "scripts": { "build": "tsc -b", + "typecheck": "tsc --noEmit", "prepublishOnly": "npm run build" }, "keywords": [ diff --git a/packages/esroute/src/nav-opts.ts b/packages/esroute/src/nav-opts.ts index 79f309b..0edfea4 100644 --- a/packages/esroute/src/nav-opts.ts +++ b/packages/esroute/src/nav-opts.ts @@ -1,10 +1,10 @@ export type PathOrHref = string | string[]; -export interface NavMeta { +export interface NavMeta { /** The search query object. */ search?: Record; /** The state to push. */ - state?: any; + state?: S; /** The location hash. */ hash?: string; /** Whethe the history state shall be replaced. */ @@ -19,7 +19,7 @@ export interface NavMeta { skipRender?: boolean; } -export type StrictNavMeta = NavMeta & +export type StrictNavMeta = NavMeta & ( | { path: string[]; @@ -29,8 +29,8 @@ export type StrictNavMeta = NavMeta & } ); -export class NavOpts implements NavMeta { - readonly state?: any; +export class NavOpts implements NavMeta { + readonly state?: S; readonly params: string[] = []; readonly hash?: string; readonly replace?: boolean; @@ -40,9 +40,9 @@ export class NavOpts implements NavMeta { readonly pop?: boolean; private _h?: string; - constructor(target: StrictNavMeta); - constructor(target: PathOrHref, opts?: NavMeta); - constructor(target: PathOrHref | StrictNavMeta, opts: NavMeta = {}) { + constructor(target: StrictNavMeta); + constructor(target: PathOrHref, opts?: NavMeta); + constructor(target: PathOrHref | StrictNavMeta, opts: NavMeta = {}) { let { path, href, hash, pop, replace, search, state, skipRender } = typeof target === "string" || Array.isArray(target) ? opts : target; if (path) this.path = path; @@ -79,8 +79,8 @@ export class NavOpts implements NavMeta { } get go() { - return (path: PathOrHref, opts: NavMeta = {}) => - new NavOpts(path, { + return (path: PathOrHref, opts: NavMeta = {}) => + new NavOpts(path, { search: opts.search, state: opts.state, replace: opts.replace ?? this.replace, diff --git a/packages/esroute/src/route-resolver.ts b/packages/esroute/src/route-resolver.ts index 57b0d7d..22d7576 100644 --- a/packages/esroute/src/route-resolver.ts +++ b/packages/esroute/src/route-resolver.ts @@ -1,26 +1,26 @@ import { NavOpts } from "./nav-opts"; import { Resolve, Routes } from "./routes"; -export interface Resolved { +export interface Resolved { /** The resolved value of the route. */ value: T; /** The final navigation options after all redirecting. */ - opts: NavOpts; + opts: NavOpts; } -export type RouteResolver = ( - routes: Routes, - opts: NavOpts, - notFound: Resolve -) => Promise>; +export type RouteResolver = ( + routes: Routes, + opts: NavOpts, + notFound: Resolve +) => Promise>; const MAX_REDIRECTS = 10; -export const resolve = async ( - routes: Routes, - opts: NavOpts, - notFound: Resolve -): Promise> => { +export const resolve = async ( + routes: Routes, + opts: NavOpts, + notFound: Resolve +): Promise> => { let value: NavOpts | T = opts; const navPath = new Array(); while (value instanceof NavOpts && navPath.length <= MAX_REDIRECTS) { diff --git a/packages/esroute/src/router.ts b/packages/esroute/src/router.ts index 2168f6c..ca72316 100644 --- a/packages/esroute/src/router.ts +++ b/packages/esroute/src/router.ts @@ -1,20 +1,20 @@ import { NavMeta, NavOpts, PathOrHref, StrictNavMeta } from "./nav-opts"; import { Resolved, resolve } from "./route-resolver"; -import { Resolve, Routes } from "./routes"; +import { Resolve, Routes, RoutePaths, RawRoutes } from "./routes"; -export type OnResolveListener = (resolved: Resolved) => void; -export interface Router { +export type OnResolveListener = (resolved: Resolved) => void; +export interface Router { /** * The routes configuration. * You may modify this object to change the routes. * Be sure to call `router.init()` after the current route is configured. */ - routes: Routes; + routes: Routes; /** * The current resolved route. * It is updated after each route resolution. */ - readonly current: NavOpts; + readonly current: NavOpts; /** * Triggers a navigation. * You can modify the navigation options by passing in a second argument. @@ -27,15 +27,15 @@ export interface Router { * @param opts The navigation metadata. */ go( - target: number | StrictNavMeta | ((prev: NavOpts) => NavMeta) + target: number | StrictNavMeta | ((prev: NavOpts) => NavMeta) ): Promise; - go(target: number | PathOrHref, opts?: NavMeta): Promise; + go(target: number | RoutePaths | PathOrHref, opts?: NavMeta): Promise; /** * Use this to listen for route changes. * Returns an unsubscribe function. * @param listener The listener that receives a Resolved object. */ - onResolve(listener: OnResolveListener): () => void; + onResolve(listener: OnResolveListener): () => void; /** * Initializes the router: Starts listening for events, resolves the current * route and calls the `onResolve` listeners. @@ -48,7 +48,7 @@ export interface Router { /** * Use this to wait for the current navigation to complete. */ - resolution?: Promise>; + resolution?: Promise>; /** * Use this to render the current route (history and location). * @param defer A function that defers rendering and can be used to trigger multiple successive @@ -57,17 +57,17 @@ export interface Router { render(defer?: () => Promise): Promise; } -export interface RouterConf { +export interface RouterConf { /** * The routes configuration. You can modify this object later. * Make sure, the current route is in place before you call `router.init()`. */ - routes?: Routes; + routes?: R & Routes; /** * A fallback resolve funuction to use, if a route could not be found. * By default it redirects to the root path '/'. */ - notFound?: Resolve; + notFound?: Resolve; /** * Whether the click handler for anchor elements shall not be installed. * This might make sense, if you want to take more control over how anchor @@ -77,22 +77,22 @@ export interface RouterConf { /** * A callback that is invoked whenever a route is resolved. */ - onResolve?: OnResolveListener; + onResolve?: OnResolveListener; } -export const createRouter = ({ - routes = {}, +export const createRouter = ({ + routes = {} as R & Routes, notFound = ({ go }) => go([]), noClick = false, onResolve, -}: RouterConf = {}): Router => { - let _current: Resolved; - const _listeners = new Set>( +}: RouterConf = {}): Router => { + let _current: Resolved; + const _listeners = new Set>( onResolve ? [onResolve] : [] ); - let resolution: Promise>; + let resolution: Promise>; let skipRender = false; - const r: Router = { + const r: Router = { routes, get current() { return _current.opts; @@ -111,45 +111,50 @@ export const createRouter = ({ }, async go( target: - | PathOrHref - | StrictNavMeta - | ((prev: NavOpts) => NavMeta) - | number, - opts?: NavMeta + | number + | StrictNavMeta + | ((prev: NavOpts) => NavMeta) + | RoutePaths + | PathOrHref, + opts?: NavMeta ): Promise { // Serialize all navigaton requests const prevRes = await this.resolution; - if (typeof target === "function") { + let resolvedTarget: any = target; + + if (typeof resolvedTarget === "function") { if (!prevRes) throw new Error( "Cannot call go() with a function before the first navigation has been started." ); - target = { + resolvedTarget = { path: prevRes.opts.path, search: prevRes.opts.search, state: prevRes.opts.state, replace: true, - ...target(prevRes.opts), + ...resolvedTarget(prevRes.opts), }; } - if (typeof target === "number") { + + if (typeof resolvedTarget === "number") { const waiting = waitForPopState(); - history.go(target); + history.go(resolvedTarget); await waiting; if (skipRender || opts?.skipRender) return; return resolveCurrent(); } + const navOpts = - target instanceof NavOpts - ? target - : typeof target === "string" || Array.isArray(target) - ? new NavOpts(target, opts) - : new NavOpts(target); + resolvedTarget instanceof NavOpts + ? resolvedTarget + : typeof resolvedTarget === "string" || Array.isArray(resolvedTarget) + ? new NavOpts(resolvedTarget as PathOrHref, opts) + : new NavOpts(resolvedTarget as StrictNavMeta); if (navOpts.skipRender || skipRender) return updateState(navOpts); const res = await applyResolution(resolve(r.routes, navOpts, notFound)); updateState(res.opts); }, - onResolve(listener: OnResolveListener) { + onResolve(listener: OnResolveListener) { _listeners.add(listener); if (_current) listener(_current); return () => _listeners.delete(listener); @@ -186,7 +191,7 @@ export const createRouter = ({ const resolveCurrent = async (e?: PopStateEvent) => { const { href, origin } = window.location; - const initialOpts = new NavOpts(href.substring(origin.length), { + const initialOpts = new NavOpts(href.substring(origin.length), { state: e ? e.state : history.state, pop: !!e, }); @@ -196,7 +201,7 @@ export const createRouter = ({ if (opts !== initialOpts) { updateState( - new NavOpts(opts.path, { + new NavOpts(opts.path, { replace: true, search: opts.search, state: opts.state, @@ -205,7 +210,7 @@ export const createRouter = ({ } }; - const applyResolution = async (res: Promise>) => { + const applyResolution = async (res: Promise>) => { resolution = res; try { const resolved = await res; @@ -217,7 +222,7 @@ export const createRouter = ({ } }; - const updateState = ({ state, replace, href }: NavOpts) => { + const updateState = ({ state, replace, href }: NavOpts) => { if (replace) history.replaceState(state ?? null, "", href); else history.pushState(state ?? null, "", href); }; diff --git a/packages/esroute/src/routes.ts b/packages/esroute/src/routes.ts index d5ba8de..2625400 100644 --- a/packages/esroute/src/routes.ts +++ b/packages/esroute/src/routes.ts @@ -1,10 +1,30 @@ import { NavOpts } from "./nav-opts"; -export type Resolve = ( - navOpts: NavOpts, +export type RawRoutes = { + [k: string]: RawRoutes | ((...args: any[]) => any); +}; + +export type Resolve = ( + navOpts: NavOpts, next?: T -) => T | NavOpts | Promise; +) => T | NavOpts | Promise>; -export interface Routes { - [k: string]: Routes | Resolve; +export interface Routes { + [k: string]: Routes | Resolve; } + +export type RoutePaths = { + [K in keyof R & string]: K extends "?" + ? never + : K extends "" + ? R[K] extends RawRoutes + ? RoutePaths + : `${Prefix}/` + : K extends "*" + ? R[K] extends RawRoutes + ? RoutePaths + : `${Prefix}/${string}` + : R[K] extends RawRoutes + ? RoutePaths + : `${Prefix}/${K}`; +}[keyof R & string]; From b6347f0fd67dbe861a646fa36f03dc7102cfe920 Mon Sep 17 00:00:00 2001 From: Sven Rogge Date: Tue, 31 Mar 2026 16:36:37 +0200 Subject: [PATCH 02/12] fix: include test files in typecheck and resolve type errors - Create tsconfig.check.json in each package to include **/*.ts files for full typecheck coverage - Update typecheck script to use tsconfig.check.json instead of tsconfig.json - Add depth limit to RoutePaths utility type to prevent infinite type recursion - Fix type annotations in test files: - Explicitly type NavOpts in route-resolver.spec.ts - Add explicit 'next' parameter types in router.spec.ts - Use 'any' types in render-routes-directive.spec.ts to avoid deep recursion - Add typecheck script to both workspace packages All 40 tests pass + complete typecheck coverage of test files --- packages/esroute-lit/package.json | 2 +- .../src/render-routes-directive.spec.ts | 4 +- packages/esroute-lit/tsconfig.check.json | 10 +++++ packages/esroute/package.json | 2 +- packages/esroute/src/route-resolver.spec.ts | 4 +- packages/esroute/src/router.spec.ts | 6 +-- packages/esroute/src/routes.ts | 40 ++++++++++++------- packages/esroute/tsconfig.check.json | 9 +++++ 8 files changed, 53 insertions(+), 24 deletions(-) create mode 100644 packages/esroute-lit/tsconfig.check.json create mode 100644 packages/esroute/tsconfig.check.json diff --git a/packages/esroute-lit/package.json b/packages/esroute-lit/package.json index e0d4609..eee87a0 100644 --- a/packages/esroute-lit/package.json +++ b/packages/esroute-lit/package.json @@ -9,7 +9,7 @@ "homepage": "https://github.com/sv2dev/esroute/tree/main/packages/esroute-lit", "scripts": { "build": "tsc -b", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit -p tsconfig.check.json", "prepublishOnly": "npm run build" }, "keywords": [ diff --git a/packages/esroute-lit/src/render-routes-directive.spec.ts b/packages/esroute-lit/src/render-routes-directive.spec.ts index 0011371..33fa1c3 100644 --- a/packages/esroute-lit/src/render-routes-directive.spec.ts +++ b/packages/esroute-lit/src/render-routes-directive.spec.ts @@ -5,10 +5,10 @@ import { renderRoutes } from "./render-routes-directive"; const router = createRouter({ routes: { - "": async ({}, next) => next ?? html`test`, + "": async ({}, next: any) => next ?? html`test`, foo: async () => html`foo`, bar: { - "": async ({}, next) => `bar ${next}`, + "": async ({}, next: any) => `bar ${next}`, baz: async () => `baz`, }, }, diff --git a/packages/esroute-lit/tsconfig.check.json b/packages/esroute-lit/tsconfig.check.json new file mode 100644 index 0000000..94d60b7 --- /dev/null +++ b/packages/esroute-lit/tsconfig.check.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "noEmit": true + }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../esroute" }] +} diff --git a/packages/esroute/package.json b/packages/esroute/package.json index 0d87bd4..7a7ad06 100644 --- a/packages/esroute/package.json +++ b/packages/esroute/package.json @@ -10,7 +10,7 @@ "homepage": "https://github.com/sv2dev/esroute/tree/main/packages/esroute", "scripts": { "build": "tsc -b", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit -p tsconfig.check.json", "prepublishOnly": "npm run build" }, "keywords": [ diff --git a/packages/esroute/src/route-resolver.spec.ts b/packages/esroute/src/route-resolver.spec.ts index 79f7403..ef40a8b 100644 --- a/packages/esroute/src/route-resolver.spec.ts +++ b/packages/esroute/src/route-resolver.spec.ts @@ -150,11 +150,11 @@ describe("Resolver", () => { }); it("should fail on too many redirects", async () => { - const navOpts = new NavOpts("/foo", { state: 1 }); + const navOpts = new NavOpts("/foo", { state: 1 }); await expect( resolve( - { foo: ({ go, state }) => go("/foo", { state: state + 1 }) }, + { foo: ({ go, state }) => go("/foo", { state: (state ?? 0) + 1 }) }, navOpts, notFound ) diff --git a/packages/esroute/src/router.spec.ts b/packages/esroute/src/router.spec.ts index 4d2e5cf..36a36c1 100644 --- a/packages/esroute/src/router.spec.ts +++ b/packages/esroute/src/router.spec.ts @@ -10,12 +10,12 @@ describe("Router", () => { vi.spyOn(history, "replaceState"); vi.spyOn(history, "pushState"); vi.spyOn(history, "go").mockImplementation(() => - setTimeout(() => window.dispatchEvent(new PopStateEvent("popstate")), 0) + setTimeout(() => window.dispatchEvent(new PopStateEvent("popstate")), 0), ); - router = createRouter({ + router = createRouter({ onResolve, routes: { - "": ({}, next) => next ?? "index", + "": ({}, next: string | undefined) => next ?? "index", foo: () => "foo", fail: () => Promise.reject(), }, diff --git a/packages/esroute/src/routes.ts b/packages/esroute/src/routes.ts index 2625400..6b56f25 100644 --- a/packages/esroute/src/routes.ts +++ b/packages/esroute/src/routes.ts @@ -13,18 +13,28 @@ export interface Routes { [k: string]: Routes | Resolve; } -export type RoutePaths = { - [K in keyof R & string]: K extends "?" - ? never - : K extends "" - ? R[K] extends RawRoutes - ? RoutePaths - : `${Prefix}/` - : K extends "*" - ? R[K] extends RawRoutes - ? RoutePaths - : `${Prefix}/${string}` - : R[K] extends RawRoutes - ? RoutePaths - : `${Prefix}/${K}`; -}[keyof R & string]; +// Depth counter using a string to track recursion depth +type Inc = `${T}x`; +type IsMaxDepth = T extends `${"x" | "xx" | "xxx" | "xxxx" | "xxxxx" | "xxxxxx" | "xxxxxxx" | "xxxxxxxx" | "xxxxxxxxx" | "xxxxxxxxxx"}` ? true : false; + +export type RoutePaths< + R extends RawRoutes, + Prefix extends string = "", + D extends string = "" +> = IsMaxDepth extends true + ? string + : { + [K in keyof R & string]: K extends "?" + ? never + : K extends "" + ? R[K] extends RawRoutes + ? RoutePaths> + : `${Prefix}/` + : K extends "*" + ? R[K] extends RawRoutes + ? RoutePaths> + : `${Prefix}/${string}` + : R[K] extends RawRoutes + ? RoutePaths> + : `${Prefix}/${K}`; + }[keyof R & string]; diff --git a/packages/esroute/tsconfig.check.json b/packages/esroute/tsconfig.check.json new file mode 100644 index 0000000..c0117df --- /dev/null +++ b/packages/esroute/tsconfig.check.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "noEmit": true + }, + "include": ["src/**/*.ts"] +} From a48238e72183c296b6a8f5685ab43c71f520c0cc Mon Sep 17 00:00:00 2001 From: Sven Rogge Date: Tue, 31 Mar 2026 19:51:53 +0200 Subject: [PATCH 03/12] test: add typed router instance and type safety tests - Refactor router.spec.ts to use properly typed router instance - Extract routes to a separate const and type it with Routes - Use ReturnType> to get properly inferred type - Add 'type safety' test suite to demonstrate: - Valid typed paths work correctly - NavOpts objects are type-safe - String array paths still work - Router type inference from routes shape works - Demonstrates that IDE autocomplete for router.go() now shows: "/" | "/foo" | "/bar" | "/fail" - All 44 tests pass + complete typecheck coverage --- packages/esroute/src/router.spec.ts | 59 +++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/packages/esroute/src/router.spec.ts b/packages/esroute/src/router.spec.ts index 36a36c1..2969071 100644 --- a/packages/esroute/src/router.spec.ts +++ b/packages/esroute/src/router.spec.ts @@ -1,10 +1,20 @@ import { beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import { NavOpts } from "./nav-opts"; -import { Router, createRouter } from "./router"; +import { Routes } from "./routes"; +import { createRouter } from "./router"; + +// Define routes with proper typing to verify RoutePaths inference +// Note: "fail" route is included for testing error handling +const routes = { + "": ({}, next: string | undefined) => next ?? "index", + foo: () => "foo", + fail: () => Promise.reject(new Error("test")), + bar: () => "bar", +} satisfies Routes; describe("Router", () => { let onResolve: Mock; - let router: Router; + let router: ReturnType>; beforeEach(() => { onResolve = vi.fn(); vi.spyOn(history, "replaceState"); @@ -12,13 +22,9 @@ describe("Router", () => { vi.spyOn(history, "go").mockImplementation(() => setTimeout(() => window.dispatchEvent(new PopStateEvent("popstate")), 0), ); - router = createRouter({ + router = createRouter({ onResolve, - routes: { - "": ({}, next: string | undefined) => next ?? "index", - foo: () => "foo", - fail: () => Promise.reject(), - }, + routes, }); }); @@ -157,4 +163,41 @@ describe("Router", () => { }); }); }); + + describe("type safety", () => { + it("should allow valid typed paths", async () => { + router.init(); + // These should all compile without type errors and accept valid paths + // The paths "/" | "/foo" | "/bar" | "/fail" are properly typed from the routes + await router.go("/foo"); + await router.go("/bar"); + }); + + it("should work with NavOpts objects", async () => { + router.init(); + // Type-safe NavOpts construction with proper typing + const opts = new NavOpts("/foo"); + await router.go(opts); + }); + + it("should work with string array paths", async () => { + router.init(); + // String arrays should still work alongside typed string paths + await router.go(["foo"]); + await router.go(["bar"]); + }); + + it("should infer router types from routes", () => { + // Verify that router is properly typed with inferred routes shape + // The router type is: Router + // which means router.go() provides autocomplete for: "/" | "/foo" | "/bar" | "/fail" + + // Demonstrate that valid paths are properly typed + type ValidPaths = "/" | "/foo" | "/bar" | "/fail"; + + // Each valid route is properly typed and provides IDE autocomplete + const router_is_typed: typeof router = router; + expect(router_is_typed).toBeDefined(); + }); + }); }); From 173446326a2e8d057bf91180862711594e747fca Mon Sep 17 00:00:00 2001 From: Sven Rogge Date: Tue, 31 Mar 2026 20:01:24 +0200 Subject: [PATCH 04/12] fix: enforce typed paths in go() by removing string fallback overload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove PathOrHref (string | string[]) from go() signature in favour of explicit string[] for array paths and RoutePaths for string paths - Invalid paths like "/fo" are now type errors; only defined routes compile - Fix router.spec.ts typo: go("/fo") → go("/foo") - Fix router.spec.ts: go("/foo?a=b") → go("/foo", { search: { a: "b" } }) - Fix router.spec.ts: go("/baz") → go(["baz"]) for intentionally non-existent route - Cast dynamic anchor href to RoutePaths in link click handler --- packages/esroute/src/router.spec.ts | 4 ++-- packages/esroute/src/router.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/esroute/src/router.spec.ts b/packages/esroute/src/router.spec.ts index 2969071..23fa4bc 100644 --- a/packages/esroute/src/router.spec.ts +++ b/packages/esroute/src/router.spec.ts @@ -93,7 +93,7 @@ describe("Router", () => { }); it("should replace the state by default, if target is a mapping funciton", async () => { - await router.go("/foo?a=b"); + await router.go("/foo", { search: { a: "b" } }); await router.go(() => ({ search: { a: "c" } })); expect(history.replaceState).toHaveBeenCalledWith(null, "", "/foo?a=c"); @@ -108,7 +108,7 @@ describe("Router", () => { it("should render only once, if render is called with defer function", async () => { await router.render(async () => { - await router.go("/baz"); + await router.go(["baz"]); await router.go(-1); await router.go("/foo"); }); diff --git a/packages/esroute/src/router.ts b/packages/esroute/src/router.ts index ca72316..3145309 100644 --- a/packages/esroute/src/router.ts +++ b/packages/esroute/src/router.ts @@ -29,7 +29,7 @@ export interface Router { go( target: number | StrictNavMeta | ((prev: NavOpts) => NavMeta) ): Promise; - go(target: number | RoutePaths | PathOrHref, opts?: NavMeta): Promise; + go(target: number | RoutePaths | string[], opts?: NavMeta): Promise; /** * Use this to listen for route changes. * Returns an unsubscribe function. @@ -181,7 +181,7 @@ export const createRouter = ( ? e.target : e.composedPath?.().find(isAnchorElement); if (target && target.origin === location.origin) { - r.go(target.href.substring(location.origin.length), { + r.go(target.href.substring(location.origin.length) as RoutePaths, { replace: "replace" in target.dataset, }); e.preventDefault(); From 4539b77cd87ab32b0dfbfdc88171bc6e07da6640 Mon Sep 17 00:00:00 2001 From: Sven Rogge Date: Tue, 31 Mar 2026 20:10:56 +0200 Subject: [PATCH 05/12] fix: depth limit in RoutePaths was collapsing nested routes to string IsMaxDepth matched every depth level 1-10 instead of only the maximum, causing the first nested route level to immediately widen to string. Changed to only match exactly 10 x's so nesting works up to 10 levels. --- packages/esroute/src/router.spec.ts | 8 ++++++-- packages/esroute/src/routes.ts | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/esroute/src/router.spec.ts b/packages/esroute/src/router.spec.ts index 23fa4bc..bff6878 100644 --- a/packages/esroute/src/router.spec.ts +++ b/packages/esroute/src/router.spec.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import { NavOpts } from "./nav-opts"; import { Routes } from "./routes"; import { createRouter } from "./router"; +import { y } from "happy-dom/lib/PropertySymbol"; // Define routes with proper typing to verify RoutePaths inference // Note: "fail" route is included for testing error handling @@ -10,6 +11,9 @@ const routes = { foo: () => "foo", fail: () => Promise.reject(new Error("test")), bar: () => "bar", + x: { + y: () => "x", + }, } satisfies Routes; describe("Router", () => { @@ -63,9 +67,9 @@ describe("Router", () => { describe("go()", () => { it("should navigate to route and push state", async () => { - await router.go("/foo"); + await router.go("/x/y"); - expect(history.pushState).toHaveBeenCalledWith(null, "", "/foo"); + expect(history.pushState).toHaveBeenCalledWith(null, "", "/x/y"); }); it("should replace the state, if replace flag is set", async () => { diff --git a/packages/esroute/src/routes.ts b/packages/esroute/src/routes.ts index 6b56f25..42f4d49 100644 --- a/packages/esroute/src/routes.ts +++ b/packages/esroute/src/routes.ts @@ -13,9 +13,9 @@ export interface Routes { [k: string]: Routes | Resolve; } -// Depth counter using a string to track recursion depth +// Depth counter using a string to track recursion depth (max 10 levels) type Inc = `${T}x`; -type IsMaxDepth = T extends `${"x" | "xx" | "xxx" | "xxxx" | "xxxxx" | "xxxxxx" | "xxxxxxx" | "xxxxxxxx" | "xxxxxxxxx" | "xxxxxxxxxx"}` ? true : false; +type IsMaxDepth = T extends "xxxxxxxxxx" ? true : false; export type RoutePaths< R extends RawRoutes, From 1e4d1820aad898f4c50aae7380d23e5f17813051 Mon Sep 17 00:00:00 2001 From: Sven Rogge Date: Tue, 31 Mar 2026 20:25:27 +0200 Subject: [PATCH 06/12] fix: nested index routes should not require trailing slash in RoutePaths When a nested route has "" as a terminal resolver (index route), the path for that route is the parent segment itself (e.g. "/x"), not with a trailing slash ("/x/"). Only the root index route produces "/". --- packages/esroute/src/router.spec.ts | 5 +++-- packages/esroute/src/routes.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/esroute/src/router.spec.ts b/packages/esroute/src/router.spec.ts index bff6878..33579c9 100644 --- a/packages/esroute/src/router.spec.ts +++ b/packages/esroute/src/router.spec.ts @@ -12,6 +12,7 @@ const routes = { fail: () => Promise.reject(new Error("test")), bar: () => "bar", x: { + "": () => "", y: () => "x", }, } satisfies Routes; @@ -67,9 +68,9 @@ describe("Router", () => { describe("go()", () => { it("should navigate to route and push state", async () => { - await router.go("/x/y"); + await router.go("/x"); - expect(history.pushState).toHaveBeenCalledWith(null, "", "/x/y"); + expect(history.pushState).toHaveBeenCalledWith(null, "", "/x"); }); it("should replace the state, if replace flag is set", async () => { diff --git a/packages/esroute/src/routes.ts b/packages/esroute/src/routes.ts index 42f4d49..339b995 100644 --- a/packages/esroute/src/routes.ts +++ b/packages/esroute/src/routes.ts @@ -29,7 +29,7 @@ export type RoutePaths< : K extends "" ? R[K] extends RawRoutes ? RoutePaths> - : `${Prefix}/` + : Prefix extends "" ? "/" : Prefix : K extends "*" ? R[K] extends RawRoutes ? RoutePaths> From 24a632b2988dd3b2cdc6a585708efbe8bddad730 Mon Sep 17 00:00:00 2001 From: Sven Rogge Date: Tue, 31 Mar 2026 22:29:08 +0200 Subject: [PATCH 07/12] feat(router): enforce typed state in go() based on route handlers Add type-safe navigation by introducing HandlerFor, StateOf, and NeedsState type utilities that infer required state from route handler signatures. The go() method now requires state when the route handler declares a concrete state type, preventing runtime errors from missing state parameters. Also refactor navigation options handling and update tests to verify state type enforcement works correctly with nested routes. --- packages/esroute/src/router.spec.ts | 23 ++++-------- packages/esroute/src/router.ts | 24 ++++++++++-- packages/esroute/src/routes.ts | 58 +++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 19 deletions(-) diff --git a/packages/esroute/src/router.spec.ts b/packages/esroute/src/router.spec.ts index 33579c9..ed803a1 100644 --- a/packages/esroute/src/router.spec.ts +++ b/packages/esroute/src/router.spec.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import { NavOpts } from "./nav-opts"; import { Routes } from "./routes"; import { createRouter } from "./router"; -import { y } from "happy-dom/lib/PropertySymbol"; // Define routes with proper typing to verify RoutePaths inference // Note: "fail" route is included for testing error handling @@ -12,7 +11,7 @@ const routes = { fail: () => Promise.reject(new Error("test")), bar: () => "bar", x: { - "": () => "", + "": ({ state }: NavOpts<{ a: boolean }>) => (state?.a ? "b" : "c"), y: () => "x", }, } satisfies Routes; @@ -68,9 +67,9 @@ describe("Router", () => { describe("go()", () => { it("should navigate to route and push state", async () => { - await router.go("/x"); + await router.go("/x/y"); - expect(history.pushState).toHaveBeenCalledWith(null, "", "/x"); + expect(history.pushState).toHaveBeenCalledWith(null, "", "/x/y"); }); it("should replace the state, if replace flag is set", async () => { @@ -192,17 +191,11 @@ describe("Router", () => { await router.go(["bar"]); }); - it("should infer router types from routes", () => { - // Verify that router is properly typed with inferred routes shape - // The router type is: Router - // which means router.go() provides autocomplete for: "/" | "/foo" | "/bar" | "/fail" - - // Demonstrate that valid paths are properly typed - type ValidPaths = "/" | "/foo" | "/bar" | "/fail"; - - // Each valid route is properly typed and provides IDE autocomplete - const router_is_typed: typeof router = router; - expect(router_is_typed).toBeDefined(); + it("should require state when the route handler declares a required state type", async () => { + router.init(); + // /x requires state: { a: boolean } — omitting state is a type error (see go() test above) + // Providing the required state is valid: + await router.go("/x", { state: { a: true } }); }); }); }); diff --git a/packages/esroute/src/router.ts b/packages/esroute/src/router.ts index 3145309..d0ffe8b 100644 --- a/packages/esroute/src/router.ts +++ b/packages/esroute/src/router.ts @@ -1,6 +1,14 @@ import { NavMeta, NavOpts, PathOrHref, StrictNavMeta } from "./nav-opts"; import { Resolved, resolve } from "./route-resolver"; -import { Resolve, Routes, RoutePaths, RawRoutes } from "./routes"; +import { + Resolve, + Routes, + RoutePaths, + RawRoutes, + HandlerFor, + StateOf, + NeedsState, +} from "./routes"; export type OnResolveListener = (resolved: Resolved) => void; export interface Router { @@ -29,7 +37,14 @@ export interface Router { go( target: number | StrictNavMeta | ((prev: NavOpts) => NavMeta) ): Promise; - go(target: number | RoutePaths | string[], opts?: NavMeta): Promise; + /** Navigate to a typed route path, enforcing the required state type for that route. */ + go

>( + target: P, + ...opts: NeedsState> extends true + ? [opts: NavMeta>> & { state: StateOf> }] + : [opts?: NavMeta>>] + ): Promise; + go(target: string[], opts?: NavMeta): Promise; /** * Use this to listen for route changes. * Returns an unsubscribe function. @@ -116,7 +131,7 @@ export const createRouter = ( | ((prev: NavOpts) => NavMeta) | RoutePaths | PathOrHref, - opts?: NavMeta + opts?: NavMeta ): Promise { // Serialize all navigaton requests const prevRes = await this.resolution; @@ -181,7 +196,8 @@ export const createRouter = ( ? e.target : e.composedPath?.().find(isAnchorElement); if (target && target.origin === location.origin) { - r.go(target.href.substring(location.origin.length) as RoutePaths, { + r.go({ + href: target.href.substring(location.origin.length), replace: "replace" in target.dataset, }); e.preventDefault(); diff --git a/packages/esroute/src/routes.ts b/packages/esroute/src/routes.ts index 339b995..bdafb22 100644 --- a/packages/esroute/src/routes.ts +++ b/packages/esroute/src/routes.ts @@ -38,3 +38,61 @@ export type RoutePaths< ? RoutePaths> : `${Prefix}/${K}`; }[keyof R & string]; + +// ---- Type-safe navigation helpers ---- + +type _Split = + S extends `${infer F}${D}${infer R}` ? [F, ..._Split] : [S]; + +type _PathParts

= + P extends "/" + ? [] + : P extends `/${infer Rest}` + ? _Split + : _Split; + +// Walk the routes tree by path parts to find the leaf handler function +type _NavigateRoutes = + Parts extends [] + ? R[""] extends (...args: any[]) => any + ? R[""] + : never + : Parts extends [infer First extends string, ...infer Rest extends string[]] + ? First extends keyof R + ? R[First] extends RawRoutes + ? _NavigateRoutes + : Rest extends [] + ? R[First] + : never + : never + : never; + +/** Resolves to the route handler function for a given path string. */ +export type HandlerFor = _NavigateRoutes< + R, + _PathParts

+>; + +/** + * Extracts the state type S from a route handler typed as + * `(navOpts: NavOpts, ...) => any`. + */ +export type StateOf = F extends ( + navOpts: NavOpts, + ...rest: any[] +) => any + ? S + : any; + +/** + * True when the route handler specifies a concrete (non-`any`, non-`unknown`, + * non-`undefined`) state type, indicating callers must provide `state` when + * navigating to it. + * + * Uses `unknown extends T` which is only true when T is `any` or `unknown`. + */ +export type NeedsState = unknown extends StateOf + ? false + : StateOf extends undefined + ? false + : true; From 7ea84def9fc3c532249f36c08ef52d390248c01b Mon Sep 17 00:00:00 2001 From: Sven Rogge Date: Tue, 31 Mar 2026 22:33:35 +0200 Subject: [PATCH 08/12] test: update router tests to reflect strict state typing State is now a non-optional property with definite assignment assertion. Update tests to pass properly typed state and remove optional chaining in assertions. --- packages/esroute/src/nav-opts.ts | 2 +- packages/esroute/src/router.spec.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/esroute/src/nav-opts.ts b/packages/esroute/src/nav-opts.ts index 0edfea4..514e6fd 100644 --- a/packages/esroute/src/nav-opts.ts +++ b/packages/esroute/src/nav-opts.ts @@ -30,7 +30,7 @@ export type StrictNavMeta = NavMeta & ); export class NavOpts implements NavMeta { - readonly state?: S; + readonly state!: S; readonly params: string[] = []; readonly hash?: string; readonly replace?: boolean; diff --git a/packages/esroute/src/router.spec.ts b/packages/esroute/src/router.spec.ts index ed803a1..407b16a 100644 --- a/packages/esroute/src/router.spec.ts +++ b/packages/esroute/src/router.spec.ts @@ -11,7 +11,7 @@ const routes = { fail: () => Promise.reject(new Error("test")), bar: () => "bar", x: { - "": ({ state }: NavOpts<{ a: boolean }>) => (state?.a ? "b" : "c"), + "": ({ state }: NavOpts<{ a: boolean }>) => (state.a ? "b" : "c"), y: () => "x", }, } satisfies Routes; @@ -67,9 +67,9 @@ describe("Router", () => { describe("go()", () => { it("should navigate to route and push state", async () => { - await router.go("/x/y"); + await router.go("/x", { state: { a: true } }); - expect(history.pushState).toHaveBeenCalledWith(null, "", "/x/y"); + expect(history.pushState).toHaveBeenCalledWith({ a: true }, "", "/x"); }); it("should replace the state, if replace flag is set", async () => { From f9d4cdad5218b03d1554ffc1c9e4d16f76fdad0c Mon Sep 17 00:00:00 2001 From: Sven Rogge Date: Tue, 31 Mar 2026 22:53:59 +0200 Subject: [PATCH 09/12] fix: ensure state is always null instead of undefined Change state initialization to always use null as the default value instead of leaving it undefined. This provides a consistent, falsy value for state when not explicitly provided, making the state field definitively typed as `S | null` rather than optional. Update test expectations accordingly. --- packages/esroute/src/nav-opts.spec.ts | 4 ++-- packages/esroute/src/nav-opts.ts | 6 +++--- packages/esroute/src/router.spec.ts | 2 +- packages/esroute/src/router.ts | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/esroute/src/nav-opts.spec.ts b/packages/esroute/src/nav-opts.spec.ts index 6a72f3c..fbc97fe 100644 --- a/packages/esroute/src/nav-opts.spec.ts +++ b/packages/esroute/src/nav-opts.spec.ts @@ -57,7 +57,7 @@ describe("NavOpts", () => { const href = "/foo/bar?a=b"; const opts = new NavOpts(href, {}); - expect("state" in opts).toBeFalsy(); + expect(opts.state).toBeNull(); expect("replace" in opts).toBeFalsy(); }); @@ -116,7 +116,7 @@ describe("NavOpts", () => { expect(opts2.replace).toBe(true); expect(opts2.search).toEqual({}); - expect(opts2.state).toBeUndefined(); + expect(opts2.state).toBeNull(); }); it("should set new options", () => { diff --git a/packages/esroute/src/nav-opts.ts b/packages/esroute/src/nav-opts.ts index 514e6fd..564596c 100644 --- a/packages/esroute/src/nav-opts.ts +++ b/packages/esroute/src/nav-opts.ts @@ -4,7 +4,7 @@ export interface NavMeta { /** The search query object. */ search?: Record; /** The state to push. */ - state?: S; + state?: S | null; /** The location hash. */ hash?: string; /** Whethe the history state shall be replaced. */ @@ -30,7 +30,7 @@ export type StrictNavMeta = NavMeta & ); export class NavOpts implements NavMeta { - readonly state!: S; + readonly state: S | null = null; readonly params: string[] = []; readonly hash?: string; readonly replace?: boolean; @@ -63,7 +63,7 @@ export class NavOpts implements NavMeta { if (hash != null) this.hash = hash; if (pop != null) this.pop = pop; if (search != null) this.search = search; - if (state != null) this.state = state; + this.state = state ?? null; if (replace != null) this.replace = replace; if (skipRender != null) this.skipRender = skipRender; this.search ??= {}; diff --git a/packages/esroute/src/router.spec.ts b/packages/esroute/src/router.spec.ts index 407b16a..3d2ecb0 100644 --- a/packages/esroute/src/router.spec.ts +++ b/packages/esroute/src/router.spec.ts @@ -11,7 +11,7 @@ const routes = { fail: () => Promise.reject(new Error("test")), bar: () => "bar", x: { - "": ({ state }: NavOpts<{ a: boolean }>) => (state.a ? "b" : "c"), + "": ({ state }: NavOpts<{ a: boolean }>) => (state!.a ? "b" : "c"), y: () => "x", }, } satisfies Routes; diff --git a/packages/esroute/src/router.ts b/packages/esroute/src/router.ts index d0ffe8b..a287e7f 100644 --- a/packages/esroute/src/router.ts +++ b/packages/esroute/src/router.ts @@ -239,8 +239,8 @@ export const createRouter = ( }; const updateState = ({ state, replace, href }: NavOpts) => { - if (replace) history.replaceState(state ?? null, "", href); - else history.pushState(state ?? null, "", href); + if (replace) history.replaceState(state, "", href); + else history.pushState(state, "", href); }; const waitForPopState = () => { From c6bfc9da0e0c1ba27f58ae1dac2e9a745cec7e5c Mon Sep 17 00:00:00 2001 From: Sven Rogge Date: Tue, 31 Mar 2026 23:01:58 +0200 Subject: [PATCH 10/12] fix(nav-opts): improve state type safety by making it non-nullable Change the default generic parameter of NavOpts from `S = any` to `S = null`, and make the state property non-nullable with proper type casting. Update related code to use explicit type parameters for NavOpts and remove unnecessary non-null assertions. Also update NeedsState type guard to check for both null and undefined, fixing type inference for optional state parameters. --- packages/esroute/src/nav-opts.spec.ts | 2 +- packages/esroute/src/nav-opts.ts | 6 +++--- packages/esroute/src/route-resolver.ts | 8 ++++---- packages/esroute/src/router.spec.ts | 2 +- packages/esroute/src/routes.ts | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/esroute/src/nav-opts.spec.ts b/packages/esroute/src/nav-opts.spec.ts index fbc97fe..d0813b4 100644 --- a/packages/esroute/src/nav-opts.spec.ts +++ b/packages/esroute/src/nav-opts.spec.ts @@ -120,7 +120,7 @@ describe("NavOpts", () => { }); it("should set new options", () => { - const opts1 = new NavOpts(["a", "b"], { + const opts1 = new NavOpts(["a", "b"], { replace: true, }); const opts2 = opts1.go("/a", { diff --git a/packages/esroute/src/nav-opts.ts b/packages/esroute/src/nav-opts.ts index 564596c..baf2f9b 100644 --- a/packages/esroute/src/nav-opts.ts +++ b/packages/esroute/src/nav-opts.ts @@ -29,8 +29,8 @@ export type StrictNavMeta = NavMeta & } ); -export class NavOpts implements NavMeta { - readonly state: S | null = null; +export class NavOpts implements NavMeta { + readonly state: S; readonly params: string[] = []; readonly hash?: string; readonly replace?: boolean; @@ -63,7 +63,7 @@ export class NavOpts implements NavMeta { if (hash != null) this.hash = hash; if (pop != null) this.pop = pop; if (search != null) this.search = search; - this.state = state ?? null; + this.state = (state ?? null) as S; if (replace != null) this.replace = replace; if (skipRender != null) this.skipRender = skipRender; this.search ??= {}; diff --git a/packages/esroute/src/route-resolver.ts b/packages/esroute/src/route-resolver.ts index 22d7576..d6ad3a2 100644 --- a/packages/esroute/src/route-resolver.ts +++ b/packages/esroute/src/route-resolver.ts @@ -21,8 +21,8 @@ export const resolve = async ( opts: NavOpts, notFound: Resolve ): Promise> => { - let value: NavOpts | T = opts; - const navPath = new Array(); + let value: NavOpts | T = opts; + const navPath = new Array>(); while (value instanceof NavOpts && navPath.length <= MAX_REDIRECTS) { opts = value; navPath.push(opts); @@ -45,7 +45,7 @@ export const resolve = async ( const getResolves = async ( root: Routes, - opts: NavOpts + opts: NavOpts ): Promise => { const { path, params } = opts; const resolves: Resolve[] = []; @@ -81,7 +81,7 @@ const getResolves = async ( return null; }; -const checkGuard = async (routes: Routes, opts: NavOpts) => { +const checkGuard = async (routes: Routes, opts: NavOpts) => { if (typeof routes["?"] === "function") { const guardResult = await routes["?"](opts); if (guardResult instanceof NavOpts) return guardResult; diff --git a/packages/esroute/src/router.spec.ts b/packages/esroute/src/router.spec.ts index 3d2ecb0..407b16a 100644 --- a/packages/esroute/src/router.spec.ts +++ b/packages/esroute/src/router.spec.ts @@ -11,7 +11,7 @@ const routes = { fail: () => Promise.reject(new Error("test")), bar: () => "bar", x: { - "": ({ state }: NavOpts<{ a: boolean }>) => (state!.a ? "b" : "c"), + "": ({ state }: NavOpts<{ a: boolean }>) => (state.a ? "b" : "c"), y: () => "x", }, } satisfies Routes; diff --git a/packages/esroute/src/routes.ts b/packages/esroute/src/routes.ts index bdafb22..ca9563f 100644 --- a/packages/esroute/src/routes.ts +++ b/packages/esroute/src/routes.ts @@ -93,6 +93,6 @@ export type StateOf = F extends ( */ export type NeedsState = unknown extends StateOf ? false - : StateOf extends undefined + : StateOf extends null | undefined ? false : true; From 4b5fbfc95a284ef86e66c72e754673ece3f4e5e1 Mon Sep 17 00:00:00 2001 From: Sven Rogge Date: Wed, 1 Apr 2026 00:04:55 +0200 Subject: [PATCH 11/12] docs: document type-safe route paths and typed state in README --- packages/esroute/README.md | 44 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/esroute/README.md b/packages/esroute/README.md index 68a5051..32110ab 100644 --- a/packages/esroute/README.md +++ b/packages/esroute/README.md @@ -14,7 +14,7 @@ Those features may be the ones you are looking for. - [🌈 Framework agnostic](#-framework-agnostic) - [🧭 Concise navigation API](#-concise-navigation-api) - [🕹 Simple configuration](#-simple-configuration) -- [✅ Typesafe value resolution](#-typesafe-value-resolution) +- [✅ Typesafe navigation](#-typesafe-navigation) - [🏎 Fast startup and runtime](#-fast-startup-and-runtime) - [🛡 Route guards](#-route-guards) - [🦄 Virtual routes](#-virtual-routes) @@ -90,7 +90,11 @@ const router = createRouter({ }); ``` -### ✅ Typesafe value resolution +### ✅ Typesafe navigation + +The router provides end-to-end type safety for route navigation. + +#### Value resolution The router can be restricted to allow only certain resolution types. @@ -104,6 +108,42 @@ const router = createRouter({ }); ``` +#### Route paths + +When you pass your routes using `satisfies Routes`, the router infers all valid paths and restricts `go()` to only those paths, giving you autocomplete and catching typos at compile time: + +```ts +const routes = { + "": () => "index", + foo: () => "foo", + bar: () => "bar", +} satisfies Routes; + +const router = createRouter({ routes }); + +router.go("/foo"); // ✓ +router.go("/baz"); // TS Error: Argument of type '"/baz"' is not assignable +``` + +#### Typed state + +Route handlers can declare the history state type they require by typing the `NavOpts` parameter. When navigating to such a route, TypeScript enforces that you provide the correct state — and inside the handler, `state` is typed directly without null checks: + +```ts +const routes = { + profile: (navOpts: NavOpts<{ userId: string }>) => + loadProfile(navOpts.state.userId), +} satisfies Routes; + +const router = createRouter({ routes }); + +router.go("/profile"); // TS Error: state required +router.go("/profile", { state: { userId: "123" } }); // ✓ +router.go("/profile", { state: { userId: 42 } }); // TS Error: number is not string +``` + +State defaults to `null` when not provided, consistent with the History API. Routes without an explicit state type leave it as `null`. + ### 🏎 Fast startup and runtime esroute comes with no dependencies and is quite small. From 5c939476f86f20deefd243736fc55fd841fd3622 Mon Sep 17 00:00:00 2001 From: Sven Rogge Date: Wed, 1 Apr 2026 00:05:10 +0200 Subject: [PATCH 12/12] chore: bump version to 0.12.0 --- packages/esroute-lit/package.json | 4 ++-- packages/esroute/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/esroute-lit/package.json b/packages/esroute-lit/package.json index eee87a0..e8d0535 100644 --- a/packages/esroute-lit/package.json +++ b/packages/esroute-lit/package.json @@ -1,6 +1,6 @@ { "name": "@esroute/lit", - "version": "0.11.1", + "version": "0.12.0", "description": "A small efficient client-side routing library for lit, written in TypeScript.", "main": "dist/index.js", "license": "MIT", @@ -21,7 +21,7 @@ "typescript": "^5.4.5" }, "dependencies": { - "esroute": "^0.11.1", + "esroute": "^0.12.0", "lit": "^3.1.1" } } diff --git a/packages/esroute/package.json b/packages/esroute/package.json index 7a7ad06..2a64131 100644 --- a/packages/esroute/package.json +++ b/packages/esroute/package.json @@ -1,6 +1,6 @@ { "name": "esroute", - "version": "0.11.1", + "version": "0.12.0", "description": "A small efficient framework-agnostic client-side routing library, written in TypeScript.", "types": "dist/index.d.ts", "main": "dist/index.js",