Template Literal Types: Expressive, Type-Safe APIs
Use TypeScript template literal types to encode event names, route patterns and permission sets in the type system — no runtime checks, no string drift.

Template literal types landed in TypeScript 4.1 and most teams use them for one or two tricks before moving on. That's leaving most of their power on the table. Used systematically, they let you encode protocol-level contracts — event buses, route registries, permission systems, CSS token naming conventions — directly into the type system. Zero runtime overhead, full IDE autocomplete, errors caught at compile time.
The Gap Template Literal Types Fill
String-heavy APIs are the hardest to keep type-safe. Event emitters, route handlers, CSS-in-JS helpers — they all accept strings, and without template literal types, those strings are either string (useless) or a hand-written union (fragile and verbose). The hand-written union is the real problem: it drifts the moment a domain concept is renamed.
// ❌ Anything compiles — typos become silent runtime bugs
type EventName = string;
emitter.on("user:created", handler);
emitter.on("usr:created", handler); // typo — no error until runtime
// ✅ Derived from domain types — only valid events compile
type UserEvents = `user:${"created" | "updated" | "deleted"}`;
type OrderEvents = `order:${"placed" | "fulfilled" | "cancelled"}`;
type AppEvent = UserEvents | OrderEvents;
emitter.on("user:created", handler); // ✅
emitter.on("usr:created", handler); // ❌ Type error — caught immediatelyThe union is no longer hand-written. It's derived from smaller building blocks, so when you add a new user event, every typed emitter picks it up automatically.
Building a Type-Safe Event Bus
The real payoff comes when you pair template literal types with generics to couple each event name to a specific payload type.
type EventPayloadMap = {
"user:created": { userId: string; email: string };
"user:deleted": { userId: string; deletedAt: Date };
"order:placed": { orderId: string; total: number };
"order:cancelled": { orderId: string; reason: string };
};
type AppEvent = keyof EventPayloadMap;
interface TypedEmitter {
emit<E extends AppEvent>(event: E, payload: EventPayloadMap[E]): void;
on<E extends AppEvent>(event: E, handler: (payload: EventPayloadMap[E]) => void): void;
}
// The compiler enforces the correct payload shape for each event
emitter.emit("user:created", { userId: "u_1", email: "a@example.com" }); // ✅
emitter.emit("user:created", { userId: "u_1", total: 99 }); // ❌ Wrong shape
emitter.emit("order:shipped", { orderId: "o_1" }); // ❌ Unknown eventEvent name and payload are coupled at the type level. Renaming an event key in EventPayloadMap produces errors everywhere the old name was used — no grepping, no hoping you found everything.
Extracting Route Parameters with infer
Template literal types compose with infer inside conditional types to pull structure out of string patterns. This is the mechanism behind every type-safe router.
// Recursively extract parameter names from "/users/:id/posts/:postId"
type ExtractParams<Path extends string> =
Path extends `${infer _Prefix}:${infer Param}/${infer Rest}`
? Param | ExtractParams<`/${Rest}`>
: Path extends `${infer _Prefix}:${infer Param}`
? Param
: never;
type RouteParams<Path extends string> = {
[K in ExtractParams<Path>]: string;
};
function defineRoute<Path extends string>(
path: Path,
handler: (params: RouteParams<Path>, req: Request) => Response,
) {
return { path, handler };
}
defineRoute("/users/:id/posts/:postId", (params) => {
// params.id ✅, params.postId ✅
// params.userId ❌ — Property does not exist on type
return new Response(`Post ${params.postId} by user ${params.id}`);
});The recursive conditional type walks the path string and accumulates parameter names. RouteParams is derived entirely from the path literal — no separate schema object, no decorators, no code generation step.
Permission Systems Without String Drift
Permission strings are another place where hand-written unions constantly drift from reality. A mapped type over a template literal union solves this.
type Resource = "user" | "order" | "product" | "invoice";
type Action = "create" | "read" | "update" | "delete";
type Permission = `${Resource}:${Action}`;
type Role = {
name: string;
permissions: Permission[];
};
const adminRole: Role = {
name: "admin",
permissions: ["user:create", "user:delete", "order:read", "invoice:read"],
};
const brokenRole: Role = {
name: "broken",
permissions: ["user:destroy"], // ❌ "destroy" is not a valid action
};Adding a new resource to the Resource union immediately expands the Permission type. Any hardcoded string that doesn't match a valid combination becomes a compile error — including stale permissions in role definitions that survived a rename.
CSS Design Tokens with Bounded Scales
Design systems that generate CSS custom property names benefit from enforced naming conventions at compile time rather than lint rules that fire after the fact.
type Scale = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
type SpacingToken = `spacing-${Scale}`;
type ColorToken = `color-${"primary" | "neutral" | "danger" | "success"}-${Scale}`;
type DesignToken = SpacingToken | ColorToken;
function token(name: DesignToken): string {
return `var(--${name})`;
}
token("spacing-4"); // ✅
token("color-primary-3"); // ✅
token("color-brand-3"); // ❌ "brand" is not in the color set
token("spacing-13"); // ❌ 13 exceeds the scaleThe numeric literal union 1 | 2 | ... | 12 keeps the type bounded. If your scale is open-ended, ${number} works but allows 1.5 and -3, so validate at runtime when precision matters.
When to Reach for Template Literal Types
They're not free. Recursive types slow the TypeScript compiler, and overly clever type machinery becomes painful to debug six months later.
| Use case | Worth it? | Reason |
|---|---|---|
| Event bus event names | ✅ Yes | Derives from domain types, catches typos at source |
| Route parameter extraction | ✅ Yes | Eliminates manually maintained params interfaces |
| Permission strings | ✅ Yes | Prevents stale strings surviving renames |
| Design token names | ✅ Yes | Enforces conventions without custom lint rules |
| Deep recursive string parsing | ⚠️ Maybe | Benchmark with --diagnostics — can slow tsc significantly |
| Replacing runtime validation | ❌ No | Types are erased; use Zod/Valibot at I/O boundaries |
The last row matters. Template literal types operate entirely at compile time. A permission string from a JWT, a route pattern from a database row, a config value from environment — none of those benefit from the type system directly. Runtime validation is still required at every system boundary; template literal types and runtime validators are complementary, not competing.
Run tsc --diagnostics periodically when your type complexity grows. The Instantiation count metric tells you whether recursive types are becoming expensive. If it climbs past a few million, consider simplifying the recursion or splitting the union.
Key Takeaways
- Derive unions, don't write them — compose event names, permissions, and tokens from smaller domain types so the union stays in sync when anything changes.
inferunlocks structural parsing — recursive conditional types can extract parameter names from path strings and similar grammars into precise typed objects.- Pair with mapped types for full interfaces — turning a template literal union into an interface eliminates entire categories of manual synchronization.
- Watch compiler performance — deeply recursive template literal types add measurable
tsccost; use--diagnosticsto catch regressions early. - Types are erased at runtime — validate external strings with a runtime schema library at I/O boundaries; type-level and runtime safety address different threat surfaces.


