- Book: The TypeScript Type System — From Generics to DSL-Level Types
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You register a route as /users/:id/posts/:postId. Somewhere
else in the handler you reach for req.params.postID. Wrong
case. The value is undefined at runtime. Nothing flagged it.
The string in the route and the keys in the params object are
two unrelated facts as far as the compiler is concerned, and
keeping them in sync is your job.
The usual answer to this is codegen. A CLI walks your route
table, emits a routes.d.ts, and you run it on every build.
It works, but now you have a generated file in your repo, a
watch step that goes stale, and a merge conflict every time two
people add a route.
Template literal types remove the codegen step. The compiler
reads the route string directly and produces the params type
from it. The route string is the single source of truth, and it
is checked at the call site with no extra tooling.
What template literal types give you
A template literal type looks like a template string, except it
operates on types. The interesting part is that you can pull
type variables out of one with infer inside a conditional
type. That is the whole trick for routes: a path like
/users/:id is a string, and you can pattern-match the :id
piece out of it.
Start with the simplest case — one segment.
type Param<T extends string> =
T extends `:${infer Name}` ? Name : never;
type A = Param<":id">; // "id"
type B = Param<"users">; // never
The :${infer Name} pattern says: if the string starts with a
colon, capture everything after it into Name. A plain segment
like "users" does not match the colon prefix, so it falls to
never. That never is what lets you filter static segments
out in the next step.
Walking the whole path
A real path has slashes. You split it by recursing on the /
separator and collecting the param names as you go. The pattern
${infer Head}/${infer Rest} peels off the first segment and
leaves the remainder for the next recursion.
type PathParams<T extends string> =
T extends `${infer Head}/${infer Rest}`
? Param<Head> | PathParams<Rest>
: Param<T>;
type P = PathParams<"/users/:id/posts/:postId">;
// "id" | "postId"
Read it top down. The path starts with a slash, so the first
Head is the empty string before it — that hits Param<"">,
which is never, and never vanishes from a union. Each
recursion peels one segment. The static segments (users,
posts) resolve to never and drop out. Only the :id and
:postId segments survive, as the strings "id" and
"postId".
The result "id" | "postId" is a union of param names. That
union is what you turn into an object type.
From a union to a params object
You want { id: string; postId: string }, not a bare union. A
mapped type over the union does it.
type Params<T extends string> = {
[K in PathParams<T>]: string;
};
type Q = Params<"/users/:id/posts/:postId">;
// { id: string; postId: string }
[K in PathParams<T>] iterates each name in the union and gives
it a string value type. If a path has no params, PathParams
is never, the mapped type produces {}, and a handler for
that route correctly sees an empty params object.
A typed router signature
Now wire it into something that looks like a router. The handler
receives params whose keys are derived from the path you passed
to get. The path is a generic, captured from the literal you
write at the call site.
type Handler<Path extends string> = (
params: Params<Path>,
) => void;
interface Router {
get<Path extends string>(
path: Path,
handler: Handler<Path>,
): void;
}
declare const router: Router;
The single thing that makes this work is that Path is inferred
from the literal argument, not declared. When you call
router.get("/users/:id", ...), Path is the literal
"/users/:id", and Params<Path> evaluates to
{ id: string } for that one call.
router.get("/users/:id", (params) => {
params.id; // string — ok
params.userId; // error: Property 'userId'
// does not exist
});
router.get("/teams/:teamId/members/:userId", (p) => {
p.teamId; // string
p.userId; // string
p.team; // error
});
The typo from the opening — postID instead of postId — is
now a compile error at the line that reads it. No generated
file, no build step, no route registry to keep in sync. The
route string is the contract and the compiler enforces it.
Handling the query string and optionals
The same pattern stretches a little further. Optional params
(:id?) and a trailing wildcard (*) are two more conditional
branches. Optionals map to string | undefined.
type ParamEntry<T extends string> =
T extends `:${infer Name}?`
? { [K in Name]?: string }
: T extends `:${infer Name}`
? { [K in Name]: string }
: {};
You then intersect the per-segment objects instead of unioning
names. That keeps the optional flag attached to each key. It is
more code, and most apps do not need it on day one, but the
shape is the same: pattern-match the segment, branch on what you
find, build the object.
The limits worth knowing before you ship this
Template literal types are real types, and the compiler treats
them like any other type, which means there are edges.
Recursion depth. The path walk recurses once per segment.
TypeScript caps instantiation depth, and very long or
pathological paths can hit a "Type instantiation is excessively
deep" error. Normal REST paths are nowhere near the limit, but a
route built by concatenating dozens of segments in a type can
trip it.
Only literal strings carry through. The inference depends on
the argument being a string literal. If you build the path
with a variable typed as string, the literal is lost and
Params collapses to { [k: string]: string } or worse. The
path has to be written inline, or stored in a const and read
through typeof.
const path = "/users/:id";
router.get(path, (p) => p.id); // ok: literal inferred
let dyn: string = "/users/:id";
router.get(dyn, (p) => p.id); // p is widened, no check
Editor cost. Deeply recursive conditional types make hovers
and autocompletion slower in large projects. It is rarely a
problem at the scale of a route table, but if you find yourself
reaching for this on every type in a hot path, measure before
you commit.
Runtime is still your job. None of this parses the URL at
runtime. The type tells you the keys exist; you still write the
matcher that fills the params object. The value is that the
type and the runtime read from the same string, so they cannot
drift the way a hand-written union and a route registry do.
Why this beats codegen for most apps
Codegen earns its keep when the source of truth lives outside
your code — an OpenAPI spec, a database schema, a GraphQL SDL.
For routes defined in TypeScript, the source of truth is already
a TypeScript string, and template literal types read it where it
sits. You delete the generator, the generated file, the watch
step, and the class of bug where someone edits a route and
forgets to regenerate.
The cost is a handful of conditional types most teams write once
and import everywhere. That is a good trade. The route string
stays the contract, and the compiler checks every call site
against it for free.
If this was useful
This is one of the patterns The TypeScript Type System builds
toward — infer, conditional types, and template literals
composing into types that read other code instead of trusting a
codegen step to keep up. If the route parser above clicked and
you want the same treatment for branded types, mapped-type
transforms, and DSL-level inference, that is the book.
The TypeScript Library — the 5-book collection. Books 1 and 2 are the core path; 3 and 4 substitute for 1 and 2 if you come from the JVM or PHP; book 5 is for anyone shipping TS at work.
- TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, and the browser.
- The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types.
- Kotlin and Java to TypeScript — variance, null safety, sealed classes to unions, coroutines to async/await.
- PHP to TypeScript — the sync-to-async shift, generics, discriminated unions for PHP 8+ developers.
- TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.
All five books ship in ebook, paperback, and hardcover.

Top comments (0)