- 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
Open any post about template literal types from the last three years. The example is a router. /users/:id, parse it, get back { id: string }. Type-safe Express. Type-safe Hono. Type-safe tRPC. The pattern is real and the routers are good, but the demo has eaten the feature. People read those posts and walk away thinking template literals are a URL trick.
They are not. The router demo is the easiest example to make legible in a tutorial. The interesting uses live elsewhere, in the boring parts of a codebase where you build strings out of smaller strings and want the compiler to know what you built.
Four of those places, with the code, and a note at the end on where the cost shows up.
What template literal types actually are
One sentence so the rest of the post makes sense.
A template literal type is a string type whose shape is defined by interpolating other string types into a backtick-quoted pattern. The type-level version of `${a}.${b}`.
type Greeting = `hello, ${string}`;
const a: Greeting = "hello, world"; // ok
const b: Greeting = "hello"; // error
The interpolated slot can be string, a literal type, or a union of literals. When it is a union, the result is the union of all combinations.
type Color = "red" | "blue";
type Size = "sm" | "md" | "lg";
type Token = `${Color}-${Size}`;
// "red-sm" | "red-md" | "red-lg"
// "blue-sm" | "blue-md" | "blue-lg"
Six combinations from two unions. That cross-product is the whole feature. Once you see strings as types you can multiply, the four uses below stop looking exotic.
Use 1 — Typed event names from a registry
Event buses get sloppy fast. You start with userCreated, then user.created shows up in another file, then someone publishes users:created and the subscriber list quietly drifts.
A template literal type pins the shape.
type EventMap = {
order: {
created: { id: string; total: number };
shipped: { id: string; carrier: string };
};
user: {
signed_up: { id: string; email: string };
deleted: { id: string };
};
};
type EventName<A extends keyof EventMap> =
`${A & string}.${keyof EventMap[A] & string}`;
type OrderEvent = EventName<"order">;
// "order.created" | "order.shipped"
type AnyEvent = EventName<keyof EventMap>;
// "order.created" | "order.shipped"
// | "user.signed_up" | "user.deleted"
The intersection with string is the load-bearing detail. keyof EventMap is string | number | symbol; template literals only accept the string portion, so the intersection narrows the union.
Now build a publish that knows its own payload.
type PayloadOf<E extends AnyEvent> =
E extends `${infer A}.${infer K}`
? A extends keyof EventMap
? K extends keyof EventMap[A]
? EventMap[A][K]
: never
: never
: never;
declare function publish<E extends AnyEvent>(
name: E,
payload: PayloadOf<E>,
): void;
publish("order.created", { id: "o-1", total: 99 }); // ok
publish("user.signed_up", { id: "u-1", email: "a" }); // ok
publish("user.deleted", { id: "u-1", email: "a" });
// ^ error: 'email' not in payload
publish("order.boom", { id: "x", total: 1 });
// ^ error: not assignable to AnyEvent
// (inline `// ^ error: ...` comments paraphrase the TS error for brevity.)
The two infer slots inside the template literal split "order.created" into "order" and "created" at the type level, which the rest of the conditional uses to look up the payload. No registry of strings. The strings are the registry.
Use 2 — Typed Tailwind-style class composition
Design systems lean on string concatenation. bg-blue-500, text-zinc-900, border-rose-300. A typo in any of those produces a class your CSS does not ship and a div that quietly looks wrong.
The shape is a cross-product of three small unions.
type Prop = "text" | "bg" | "border";
type Color = "zinc" | "rose" | "blue" | "amber";
type Shade = 100 | 300 | 500 | 700 | 900;
type Class = `${Prop}-${Color}-${Shade}`;
const ok: Class = "bg-rose-500";
const bad: Class = "bg-rose-501";
// ^ error: not assignable
const wrong: Class = "background-rose-500";
// ^ error: not assignable
Three unions, sixty combinations, every one a literal the editor will autocomplete. You do not have to enumerate them; the compiler does.
The same shape covers any string-typed design token. CSS variable names, icon identifiers, telemetry metric keys, feature-flag IDs. Anywhere a string identifier follows a stable pattern, the pattern can be a type.
A small caveat. The cross-product can grow. Three unions of size 3, 8, and 5 give you 120 strings, which is fine. Five unions of size 10 give you 100,000, which is not. The compiler has internal limits on how many string literal types it will materialize before it gives up and widens to string. For the kinds of design-system unions you ship, you are nowhere near that ceiling. For larger spaces, you keep the unions narrow at the leaves and assemble them lazily inside the function that needs the typed slice.
Use 3 — Typed SQL fragments
SQL is the use case people skip because they reach for an ORM and never look back. The ORM is fine. But for the layer of SQL you write by hand (migrations, ad-hoc reports, the one query the ORM cannot express), typing the fragment shape is worth the five lines.
type Table = "orders" | "users" | "products";
type Column = "id" | "created_at" | "deleted_at";
type SelectAll<T extends Table> = `SELECT * FROM ${T}`;
type SelectCols<T extends Table, C extends Column> =
`SELECT ${C} FROM ${T}`;
type Q1 = SelectAll<"orders">;
// "SELECT * FROM orders"
type Q2 = SelectCols<"users", "id" | "created_at">;
// "SELECT id FROM users" | "SELECT created_at FROM users"
A query builder for a real codebase needs joins, filters, and parameter binding, so you would not stop here. The point is the shape — query is no longer string, it is one of a finite set of strings the type system can name.
A pattern that pays off is a typed name for the shape of an identifier. Schema-qualified table names look like "public.orders" or "analytics.events", and that is exactly the kind of thing template literal types pin without effort.
type Schema = "public" | "analytics";
type FQN<S extends Schema, T extends string> = `${S}.${T}`;
type Orders = FQN<"public", "orders">;
// "public.orders"
function read<S extends Schema, T extends string>(
fqn: FQN<S, T>,
): void {
// ...
}
read("public.orders"); // ok
read("analytisc.orders");
// ^ error: not assignable to FQN<Schema, string>
The compiler is now your linter for identifier names that follow a structure. That is the whole win.
Use 4 — Typed CSS custom-property names
CSS variables share the worst trait with event names: a typo at the call site fails silently. var(--space-md) and var(--space-mid) both render; one of them is just unset and your layout shifts a little.
Pin the shape.
type Space = "xs" | "sm" | "md" | "lg" | "xl";
type Color = "fg" | "bg" | "muted" | "accent";
type CssVar =
| `--space-${Space}`
| `--color-${Color}`;
function cssVar(name: CssVar): string {
return `var(${name})`;
}
cssVar("--space-md"); // ok
cssVar("--color-fg"); // ok
cssVar("--space-mid");
// ^ error: not assignable
The same shape works for any structured naming convention you adopted three years ago and have been hand-checking ever since. Once a string name has a pattern, give the pattern a type and the editor turns into a list of valid options.
Where the cost shows up
None of this is free. Two places to watch.
Compile-time perf. Template literal types build their inhabitants eagerly when the slots are unions of literals. A pattern that crosses three small unions is fast. Crossing five unions of moderate size, or recursing over a string with infer for nontrivial inputs, will land in your tsc numbers. When you see a type alias that takes a measurable fraction of --diagnostics time, the first thing to check is whether a template literal is silently materializing a few thousand combinations.
Error messages. When the compiler decides a string is not assignable to a template literal type, it shows you the union it expected. For a small union, that message is helpful. For a 200-element union built from a cross-product, it is a wall of strings the editor will truncate and the user will not read. The mitigation is to keep the leaves narrow at the call site and let the broader pattern be a derived type used only where it pays. Same advice as for any other type: name the shape that matters at the boundary; the universe of possibilities behind it stays implicit.
A practical rule of thumb. If your template-literal type lives inside a library and the consumer sees a clean autocomplete and a precise error, you are doing it right. If the consumer sees a 12-line error on a typo, the shape is too wide and wants splitting.
The router demo is fine. It is also the smallest fraction of what template literal types are for. The four uses above are the ones that pay back the typing time on a codebase you keep working on for years. Event names, design tokens, SQL identifiers, CSS variables: anywhere a string is structured, the structure can be a type, and the compiler can stop you from typing a name that does not exist.
Pick one of those four in the codebase you have open right now. The version that uses a template literal type is the one that does not silently break the next time someone adds a column, a color, an event, or a variable.
If this was useful
Template literal types are one chapter of The TypeScript Type System, sitting alongside the conditional types and infer machinery they compose with. The book builds from generics through mapped and conditional types into the DSL-level patterns that libraries like zod, Hono, and ts-pattern make heavy use of in their public APIs. If the four uses above made you want to write your own typed query builder or event bus, that is the chapter to read next.
If you are coming from JVM languages, Kotlin and Java to TypeScript covers the same bridge with a focus on variance, null safety, and sealed-to-union translations. From PHP 8+, PHP to TypeScript covers the sync-to-async and generics jump from the other side. If you are shipping TS at work, TypeScript in Production covers the build, monorepo, and dual-publish concerns the type system itself does not touch.
The five-book set:
- TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
- The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
- Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
- PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
- TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471
All five books ship in ebook, paperback, and hardcover.

Top comments (0)