feat: type-safe router labels via per-label userData map#3747
Merged
Conversation
25c063b to
4db2235
Compare
Allow declaring a `label -> userData` map as the router's `Routes` type argument (e.g. `createCheerioRouter<Context, Routes>()`). Route handlers registered with `addHandler` then get `request.userData` typed by label, and unknown labels are rejected at compile time when a map is declared. The change is fully backwards compatible: the default `Routes` is an open `Record<string, ...>`, so existing untyped usage and the per-handler `addHandler<UserData>` generic keep working unchanged. Relates to #3082
…oads The documented `createXRouter<Ctx, Routes>()` two-argument form bound `Routes` to the released `UserData` type parameter, so it silently fell back to an open map instead of giving per-label `userData` typing. Making `Routes` the second parameter would change the meaning of a released positional type argument. Instead, `Router.create` and the `createXRouter` factories now expose two overloads — a route-map form (F-bounded, per-label typing) and the legacy flat-`userData` form. The second type argument selects between them, so the documented form works while existing `<Ctx, UserData>` callers fall through to the original behavior. Runtime behavior is unchanged.
Add a "Type-safe router labels and `userData`" section to the TypeScript projects guide explaining how to declare a route map and pass it as the second type argument to a `createXRouter` factory for per-label `userData` typing. Mirrored into the latest (3.17) docs snapshot for the patch release.
Regenerate the latest-stable (3.17) `api-typedoc.json` from current source via `docusaurus api:version 3.17`, so the API reference reflects the typed router-label map changes (per-label `RouterLabel`/`RouterHandlerContext`, the `createXRouter`/`Router.create` overloads, and the removed `RouteMap`) for the patch release.
janbuchar
reviewed
Jun 18, 2026
`addDefaultHandler` is a fallback that can receive any request (including labels not in the route map), so defaulting its `userData` to the union of declared route-map shapes over-promised and forced casting. Default it to the context's `userData` type instead (matching `addHandler`'s fallback overload and the pre-existing behavior) — which is `Record<string, unknown>` for a typed-route-map router.
janbuchar
approved these changes
Jun 22, 2026
janbuchar
left a comment
Contributor
There was a problem hiding this comment.
LGTM, please address the one docs-related comment.
Address review feedback: reword the intro so it describes that different labels usually carry different `request.userData` shapes (with examples) instead of claiming every handler reads `userData`. Co-authored-by: Jan Buchar (review feedback)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Lets users declare a
label → userDatamap once and pass it as the router'sRoutestype argument, so route handlers getrequest.userDatatyped by label — and unknown labels become a compile-time error.How
Routesgeneric onRouter,RouterHandlerandRouterRoutes, defaulting to an openRecord<string, …>so existing untyped usage is unchanged.Router.createand all 10createXRouterfactories expose two overloads — a route-map form (per-label typing) and the legacy flat-userDataform. The second type argument selects between them: a route map matches the first overload, any other shape falls through to the second. This makes the documentedcreateXRouter<Ctx, Routes>()form work without changing the meaning of the released<Ctx, UserData>type argument, keeping it fully backwards compatible. (The sole ambiguous case — a flatuserDatawhose every field is itself an object — is read as a route map; a single scalar field disambiguates it.)addHandlergains two overloads: a label-strict one (infersuserDatafrom the label) tried first, and the existing<UserData>-generic one as the fallback. The default handler receives the union of all declareduserDatashapes.Record<keyof Routes, Dictionary>form so bothinterfaceandtyperoute maps are accepted (a plainRecord<string, …>constraint silently rejectsinterfacedeclarations).This is a compile-time-only change (zero runtime cost). A follow-up targeting
v4adds an opt-in Standard Schema variant that also validatesuserDataat runtime.Relates to #3082
🤖 Generated with Claude Code