diff --git a/packages/esroute-lit/package.json b/packages/esroute-lit/package.json index 311872d..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", @@ -9,6 +9,7 @@ "homepage": "https://github.com/sv2dev/esroute/tree/main/packages/esroute-lit", "scripts": { "build": "tsc -b", + "typecheck": "tsc --noEmit -p tsconfig.check.json", "prepublishOnly": "npm run build" }, "keywords": [ @@ -20,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-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/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. diff --git a/packages/esroute/package.json b/packages/esroute/package.json index 51cef5a..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", @@ -10,6 +10,7 @@ "homepage": "https://github.com/sv2dev/esroute/tree/main/packages/esroute", "scripts": { "build": "tsc -b", + "typecheck": "tsc --noEmit -p tsconfig.check.json", "prepublishOnly": "npm run build" }, "keywords": [ diff --git a/packages/esroute/src/nav-opts.spec.ts b/packages/esroute/src/nav-opts.spec.ts index 6a72f3c..d0813b4 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,11 +116,11 @@ describe("NavOpts", () => { expect(opts2.replace).toBe(true); expect(opts2.search).toEqual({}); - expect(opts2.state).toBeUndefined(); + expect(opts2.state).toBeNull(); }); 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 79f309b..baf2f9b 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 | null; /** 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; @@ -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) as S; if (replace != null) this.replace = replace; if (skipRender != null) this.skipRender = skipRender; this.search ??= {}; @@ -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.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/route-resolver.ts b/packages/esroute/src/route-resolver.ts index 57b0d7d..d6ad3a2 100644 --- a/packages/esroute/src/route-resolver.ts +++ b/packages/esroute/src/route-resolver.ts @@ -1,28 +1,28 @@ 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> => { - let value: NavOpts | T = opts; - const navPath = new Array(); +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) { 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 4d2e5cf..407b16a 100644 --- a/packages/esroute/src/router.spec.ts +++ b/packages/esroute/src/router.spec.ts @@ -1,24 +1,34 @@ 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", + x: { + "": ({ state }: NavOpts<{ a: boolean }>) => (state.a ? "b" : "c"), + y: () => "x", + }, +} satisfies Routes; describe("Router", () => { let onResolve: Mock; - let router: Router; + let router: ReturnType>; beforeEach(() => { onResolve = vi.fn(); 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({ onResolve, - routes: { - "": ({}, next) => next ?? "index", - foo: () => "foo", - fail: () => Promise.reject(), - }, + routes, }); }); @@ -57,9 +67,9 @@ describe("Router", () => { describe("go()", () => { it("should navigate to route and push state", async () => { - await router.go("/foo"); + await router.go("/x", { state: { a: true } }); - expect(history.pushState).toHaveBeenCalledWith(null, "", "/foo"); + expect(history.pushState).toHaveBeenCalledWith({ a: true }, "", "/x"); }); it("should replace the state, if replace flag is set", async () => { @@ -87,7 +97,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"); @@ -102,7 +112,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"); }); @@ -157,4 +167,35 @@ 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 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 2168f6c..a287e7f 100644 --- a/packages/esroute/src/router.ts +++ b/packages/esroute/src/router.ts @@ -1,20 +1,28 @@ import { NavMeta, NavOpts, PathOrHref, StrictNavMeta } from "./nav-opts"; import { Resolved, resolve } from "./route-resolver"; -import { Resolve, Routes } from "./routes"; +import { + Resolve, + Routes, + RoutePaths, + RawRoutes, + HandlerFor, + StateOf, + NeedsState, +} 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 +35,22 @@ 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; + /** 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. * @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 +63,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 +72,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 +92,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 +126,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); @@ -176,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), { + r.go({ + href: target.href.substring(location.origin.length), replace: "replace" in target.dataset, }); e.preventDefault(); @@ -186,7 +207,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 +217,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 +226,7 @@ export const createRouter = ({ } }; - const applyResolution = async (res: Promise>) => { + const applyResolution = async (res: Promise>) => { resolution = res; try { const resolved = await res; @@ -217,9 +238,9 @@ export const createRouter = ({ } }; - const updateState = ({ state, replace, href }: NavOpts) => { - if (replace) history.replaceState(state ?? null, "", href); - else history.pushState(state ?? null, "", href); + const updateState = ({ state, replace, href }: NavOpts) => { + if (replace) history.replaceState(state, "", href); + else history.pushState(state, "", href); }; const waitForPopState = () => { diff --git a/packages/esroute/src/routes.ts b/packages/esroute/src/routes.ts index d5ba8de..ca9563f 100644 --- a/packages/esroute/src/routes.ts +++ b/packages/esroute/src/routes.ts @@ -1,10 +1,98 @@ 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; } + +// Depth counter using a string to track recursion depth (max 10 levels) +type Inc = `${T}x`; +type IsMaxDepth = T extends "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 extends "" ? "/" : Prefix + : K extends "*" + ? R[K] extends RawRoutes + ? RoutePaths> + : `${Prefix}/${string}` + : R[K] extends RawRoutes + ? 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 null | undefined + ? false + : true; 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"] +}