template literal types, in practice
template literal types let typescript treat a string type as a pattern it can read, instead of an opaque value it skips over. they shipped in typescript 4.1 and for two years i used them for nothing more interesting than `on${Capitalize<E>}` in a component library. then a typed router landed in our codebase and i finally understood what they were for.
the bug they fix
every router in every codebase has this shape:
function link(path: string, params: Record<string, string>) { /* ... */ }
link("/users/:id/orders/:orderId", { id: "u_1" }); // missing orderId. runtime crash.
the path string and the params object are two unrelated values. the compiler has no idea they’re supposed to agree. you find out when a customer hits a 404.
the pattern: extract params from the path
type ExtractParams<P extends string> =
P extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<`/${Rest}`>
: P extends `${string}:${infer Param}`
? Param
: never;
type Params<P extends string> = { [K in ExtractParams<P>]: string };
function link<P extends string>(path: P, params: Params<P>) { /* ... */ }
link("/users/:id/orders/:orderId", { id: "u_1" });
// ^ Property 'orderId' is missing
that’s the whole move. the path is no longer a string the compiler skips over — it’s a value the compiler reads. autocomplete on the params object now lists id and orderId. rename :id to :userId and every call site lights up red. the link between the two values is enforced by the type system, not by a comment or a code review.
where else it earns its keep
- prefixed keys.
type EnvKey = `PUBLIC_${string}`to stop a server-only secret from leaking into aimport.meta.env.PUBLIC_*lookup. - css variable names.
type CssVar = `--${string}`sogetComputedStyle(el).getPropertyValue(name)only accepts strings the css side actually defines. - event names.
`${Domain}.${Action}`("order.created","user.deleted") as a closed union, with a handler map keyed by the same template — exhaustiveness check for free. - sql-ish builders. drizzle and friends use them to type column names inside a query string. same trick.
the cost
template literal types run inside the type checker. they have limits.
- error messages get long and bad.
Type '"/users/:id/posts/:postId"' does not satisfy the constraint...is the first line; the next twenty are the unrolled recursion. reading them takes practice. - ts caps recursion depth (around 50 levels of
infer). unbounded patterns silently fall back tostring— and you wonder why autocomplete went quiet. - if you’re allergic to clever types in shared code, write a helper file, document the input/output shape with a comment, and never look inside again. that’s a fair trade.
a path string and a params object should not be allowed to disagree. a stringly-typed event name and its handler shouldn’t either. a css variable that doesn’t exist shouldn’t typecheck. template literal types are the smallest tool in the typescript box that makes any of those true.