Skip to content

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.

4 min read
TypeScript code showing template literal types deriving event name unions from domain types

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.

tstypescript
// ❌ 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 immediately

The 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.

tstypescript
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 event

Event 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.

tstypescript
// 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.

tstypescript
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.

tstypescript
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 scale
~

The 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 caseWorth it?Reason
Event bus event names✅ YesDerives from domain types, catches typos at source
Route parameter extraction✅ YesEliminates manually maintained params interfaces
Permission strings✅ YesPrevents stale strings surviving renames
Design token names✅ YesEnforces conventions without custom lint rules
Deep recursive string parsing⚠️ MaybeBenchmark with --diagnostics — can slow tsc significantly
Replacing runtime validation❌ NoTypes 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.

i

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

  1. 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.
  2. infer unlocks structural parsing — recursive conditional types can extract parameter names from path strings and similar grammars into precise typed objects.
  3. Pair with mapped types for full interfaces — turning a template literal union into an interface eliminates entire categories of manual synchronization.
  4. Watch compiler performance — deeply recursive template literal types add measurable tsc cost; use --diagnostics to catch regressions early.
  5. 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.
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX