Strings Are a Type, Not Just a Value

Most people's mental model of stringstops at "any sequence of characters." Template literal types break that model on purpose. `change:${K}`isn't a hint or a naming convention you have to remember โ€” it's a real type, checked at every call site, that happens to be built out of other types glued together with string syntax. Event names, route params, CSS custom properties: anywhere you've been passing a "just trust me" string, there's a decent chance a template literal type can make the compiler enforce it instead.

๐Ÿค” Sound familiar?
  • Your event emitter takes a plain string for the event name and you find out about typos at runtime, in production
  • You've hand-written a route param type instead of deriving it from the actual route string, and the two have already drifted once
  • You've seen error TS2590: Expression produces a union type that is too complex to represent and worked around it by just widening everything to string
  • You want compile-time checked CSS variable names or Tailwind-style class combinations without a runtime library doing the work

Template literal types are combinatorial by nature โ€” that's what makes them powerful and also what makes them slow tsserver down if you're not paying attention to how many unions you're multiplying together.

The Basics: Combinations, Not Concatenation

type Size = 'sm' | 'md' | 'lg';
type Color = 'primary' | 'secondary' | 'danger';

type ButtonClass = `btn-${Color}-${Size}`;
// 'btn-primary-sm' | 'btn-primary-md' | 'btn-primary-lg'
// | 'btn-secondary-sm' | 'btn-secondary-md' | 'btn-secondary-lg'
// | 'btn-danger-sm' | 'btn-danger-md' | 'btn-danger-lg'   โ† 9 members, not 1

const cls: ButtonClass = 'btn-primary-md'; // โœ…
const bad: ButtonClass = 'btn-primary-xl'; // โŒ 'xl' isn't a Size

Every union you cross into a template literal multiplies the member count. Two unions of three each gives nine. That's fine. It stops being fine faster than people expect.

The Combinatorial Explosion

type Entity = 'user' | 'post' | 'comment' | 'like' | 'follow' | 'notification' /* + 20 more */;
type Action = 'created' | 'updated' | 'deleted' | 'archived' /* + 10 more */;
type Actor  = 'system' | 'admin' | 'user' | 'api' /* + 8 more */;

// 26 ร— 14 ร— 12 โ‰ˆ 4,368 literal members โ€” TypeScript has to materialize every single one
type EventName = `${Entity}.${Action}.by-${Actor}`;

// Cross a large enough set of unions and the compiler gives up outright:
// error TS2590: Expression produces a union type that is too complex to represent.

Nobody hits this with two ten-member unions. It shows up when a third or fourth union gets multiplied in โ€” someone adds an Actordimension to an already-large event name type and tsserver goes from instant to visibly laggy on every keystroke in that file, sometimes well before TS2590 actually fires. Past a few hundred combinations, the type safety usually isn't worth what it costs the editor. A plain string validated at the boundary with a Zod regex or enum does the same job without asking the compiler to enumerate every possibility up front.

Type-Safe Event Emitter Keys

interface UserState {
  name: string;
  email: string;
  role: 'admin' | 'member';
}

class TypedEmitter<T extends Record<string, unknown>> {
  private listeners = new Map<string, Set<(value: unknown) => void>>();

  on<K extends Extract<keyof T, string>>(event: `change:${K}`, handler: (value: T[K]) => void): void {
    const set = this.listeners.get(event) ?? new Set();
    set.add(handler as (value: unknown) => void);
    this.listeners.set(event, set);
  }

  emit<K extends Extract<keyof T, string>>(event: `change:${K}`, value: T[K]): void {
    this.listeners.get(event)?.forEach((fn) => fn(value));
  }
}

const state = new TypedEmitter<UserState>();
state.on('change:role', (value) => console.log(value)); // value: 'admin' | 'member'
state.on('change:rolee', () => {});                      // โŒ not assignable โ€” no such key on UserState

Pulling Data Back Out With infer

Template literal types run in both directions. You can build a string from parts, or take a string pattern apart and capture pieces of it with infer inside a conditional type โ€” which is how you get a route string to produce its own params type instead of writing one by hand and hoping it stays in sync.


flowchart LR
    R["'/users/:id/orders/:orderId'"] --> E["ExtractParams<Path>"]
    E --> S1["Split on first ':'<br/>infer Param, infer Rest"]
    S1 --> S2["Recurse into Rest<br/>until no ':' remains"]
    S2 --> O["{ id: string; orderId: string }"]

    style R fill:#6b7280,color:#fff,stroke:#4b5563
    style O fill:#16a34a,color:#fff,stroke:#15803d
type ExtractParams<Path extends string> = Path extends `${string}:${infer Param}/${infer Rest}`
  ? { [K in Param]: string } & ExtractParams<`/${Rest}`>
  : Path extends `${string}:${infer Param}`
    ? { [K in Param]: string }
    : Record<string, never>;

type OrderParams = ExtractParams<'/users/:id/orders/:orderId'>;
// { id: string } & { orderId: string }  โ†’  effectively { id: string; orderId: string }

function route<Path extends string>(path: Path, handler: (params: ExtractParams<Path>) => void) {
  /* ... */
}

route('/users/:id/orders/:orderId', (params) => {
  params.id;      // โœ… string
  params.orderId; // โœ… string
  params.missing; // โŒ Property 'missing' does not exist
});

The Built-In Intrinsics

TypeScript ships four string-manipulation types that operate purely at the type level โ€” no runtime cost, because they never run, they just rewrite a literal type during checking: Uppercase, Lowercase, Capitalize, Uncapitalize.

type Entity = { name: string; age: number; email: string };

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type EntityGetters = Getters<Entity>;
// { getName: () => string; getAge: () => number; getEmail: () => string }

type CSSVarName<T extends string> = `--${Lowercase<T>}`;
type ThemeVar = CSSVarName<'PrimaryColor'>; // '--primarycolor'

Putting It Together: A Type-Safe Route Builder

type Route = '/users/:id' | '/users/:id/orders/:orderId' | '/health';

function buildPath<R extends Route>(route: R, params: ExtractParams<R>): string {
  let path: string = route;
  for (const key in params) {
    path = path.replace(`:${key}`, String(params[key as keyof typeof params]));
  }
  return path;
}

buildPath('/users/:id', { id: '42' });                 // โœ…
buildPath('/users/:id/orders/:orderId', { id: '42' });  // โŒ missing 'orderId'
buildPath('/health', {});                               // โœ… Record<string, never> accepts {}

One More Pitfall Worth Knowing

The `${number}`pattern matches TypeScript's idea of a numeric literal, not what your business logic considers a valid number. It happily accepts decimals.

type UserId = `user-${number}`;

const a: UserId = 'user-42';  // โœ…
const b: UserId = 'user-4.2'; // โœ… still matches โ€” 4.2 is a valid numeric literal, decimal or not
const c: UserId = 'user-NaN'; // โŒ 'NaN' isn't a numeric literal token

Key Takeaways

  • Every union crossed into a template literal type multiplies the total member count โ€” three medium unions is often already too many.
  • TS2590 isn't a bug to work around with a cast; it's the compiler telling you the type is doing a runtime validator's job.
  • infer works in reverse just as well as forward โ€” derive param types from the route string instead of writing both by hand.
  • A number template segment checks "is this a numeric literal," not "is this the integer my code expects" โ€” decimals slip through.