cheat sheet

Template Literal Types

Template literal types let TypeScript pattern-match and synthesize string types — covering Uppercase/Lowercase intrinsics, infer-based parsers (Split, Join, CamelCase), route-param extraction, JSON path keys, and typed i18n helpers.

Template Literal Types — String Algebra at the Type Level

What it is

Template literal types are string types written with backticks — the type-level analogue of JavaScript template strings. They were added in TypeScript 4.1 and turned the type system into a small string-manipulation language: you can concatenate, distribute unions across literals, pattern-match with infer, and synthesize arbitrarily structured string types. They power everything from typed route parameters in Express to fully type-checked CSS-in-JS, i18n keys, event-handler names, and SQL string builders.

Install

Template literal types are a language feature — they ship with TypeScript itself. You need TS 4.1 or later (current LTS is well past that).

bash
npm install -D typescript

# Verify version
npx tsc --version

Output:

code
Version 5.7.3

Syntax

A template literal type uses backticks with ${...} interpolations that can contain string, number, bigint, boolean, null, undefined, or any union of these. Unlike runtime template literals, the type ${T} interpolation distributes over unions in T.

typescript
type Greeting = `hello, ${string}`;
type Hex      = `#${string}`;
type Direction = `${"north" | "south"}-${"east" | "west"}`;
//   "north-east" | "north-west" | "south-east" | "south-west"

Output: (none — exits 0 on success)

Essential intrinsics

IntrinsicEffectExample
Uppercase<S>All-caps version of literal SUppercase<"foo">"FOO"
Lowercase<S>All-lowercase versionLowercase<"BAR">"bar"
Capitalize<S>Uppercases first character onlyCapitalize<"name">"Name"
Uncapitalize<S>Lowercases first character onlyUncapitalize<"Name">"name"

These four are compiler-implemented and cannot be hand-written; they are the building blocks behind most key-renaming patterns.

Basic concatenation

The simplest template literal type concatenates known string literals. The interpolated parts can be literal strings, literal numbers, or unions of them — and any union "distributes" across the template, producing every combination.

typescript
type Suit = "hearts" | "spades" | "diamonds" | "clubs";
type Rank = "A" | "K" | "Q" | "J";

type Card = `${Rank}-of-${Suit}`;
// "A-of-hearts" | "A-of-spades" | "A-of-diamonds" | "A-of-clubs"
// | "K-of-hearts" | "K-of-spades" | ... 16 total

const card: Card = "Q-of-clubs"; // OK
// const bad: Card = "10-of-hearts"; // Error

Output: (none — exits 0 on success)

When any segment is a non-literal string, the resulting type matches any concrete string with the right prefix/suffix:

typescript
type CssVar = `--${string}`;
const v: CssVar = "--primary-color"; // OK
// const bad: CssVar = "primary-color"; // Error

Output: (none — exits 0 on success)

Built-in intrinsics in action

Uppercase, Lowercase, Capitalize, and Uncapitalize are most useful inside mapped types where they let you transform a key while iterating over it. The classic application is auto-generating event-handler names from an event-map type.

typescript
type EventMap = {
  click: MouseEvent;
  focus: FocusEvent;
  keydown: KeyboardEvent;
};

type Handlers = {
  [K in keyof EventMap as `on${Capitalize<K & string>}`]: (e: EventMap[K]) => void;
};
// {
//   onClick:   (e: MouseEvent) => void;
//   onFocus:   (e: FocusEvent) => void;
//   onKeydown: (e: KeyboardEvent) => void;
// }

const h: Handlers = {
  onClick:   () => console.log("click"),
  onFocus:   () => console.log("focus"),
  onKeydown: () => console.log("key"),
};
h.onClick(new MouseEvent("click"));

Output:

arduino
click

The K & string intersection coerces K from string | number | symbol (the type of keyof T) to just string, which Capitalize requires.

Pattern matching with infer

The real power of template literal types is destructuring strings at the type level with infer. You write a conditional type S extends \prefix${infer Rest}` ? ... : ...and TypeScript fillsRest` with the matched portion of the string.

typescript
type Greet<S extends string> = S extends `Hello, ${infer Name}`
  ? Name
  : never;

type N1 = Greet<"Hello, Alice Dev">;  // "Alice Dev"
type N2 = Greet<"Hi, Alice Dev">;     // never

Output: (none — exits 0 on success)

infer captures as much as possible by default — the captured portion is whatever non-empty string is necessary to make the surrounding pattern match. You can also capture multiple groups in a single template:

typescript
type ParseRange<S extends string> = S extends `${infer Lo}-${infer Hi}`
  ? { lo: Lo; hi: Hi }
  : never;

type R1 = ParseRange<"100-200">; // { lo: "100"; hi: "200" }
type R2 = ParseRange<"foo-bar">; // { lo: "foo"; hi: "bar" }

Output: (none — exits 0 on success)

The captured types are still string literals — they look numeric in the example above but Lo is the type "100", not the number 100. You can convert to numeric types using a helper:

typescript
type ToNumber<S extends string> =
  S extends `${infer N extends number}` ? N : never;

type N3 = ToNumber<"42">; // 42 (numeric literal)

Output: (none — exits 0 on success)

The extends number constraint inside infer was added in TypeScript 4.7 and lets you coerce string literals to numeric literals at the type level — invaluable when parsing strings like "px-4" or "col-3".

Recursive template literal helpers

Like conditional types in general, template literal types can recurse — letting you define Split, Join, Replace, Trim, and more. Recursion depth is capped (currently ~50 frames before instantiation excessive error), so design for shallow input.

Split

Split<S, D> splits string S by delimiter D into a tuple of substrings. It is the type-level equivalent of S.split(D).

typescript
type Split<S extends string, D extends string> =
  S extends `${infer Head}${D}${infer Tail}`
    ? [Head, ...Split<Tail, D>]
    : [S];

type P1 = Split<"a.b.c.d", ".">; // ["a", "b", "c", "d"]
type P2 = Split<"only", ".">;    // ["only"]
type P3 = Split<"", ".">;        // [""]

Output: (none — exits 0 on success)

Join

The inverse — turn a tuple of strings into a single string literal joined by a delimiter:

typescript
type Join<T extends readonly string[], D extends string> =
  T extends readonly [infer F extends string, ...infer R extends string[]]
    ? R["length"] extends 0
      ? F
      : `${F}${D}${Join<R, D>}`
    : "";

type J1 = Join<["a", "b", "c"], "-">; // "a-b-c"
type J2 = Join<["x"], ".">;            // "x"
type J3 = Join<[], "/">;               // ""

Output: (none — exits 0 on success)

Replace

Replace<S, From, To> replaces the first occurrence; ReplaceAll recurses to replace every occurrence:

typescript
type Replace<S extends string, From extends string, To extends string> =
  S extends `${infer L}${From}${infer R}` ? `${L}${To}${R}` : S;

type ReplaceAll<S extends string, From extends string, To extends string> =
  From extends ""
    ? S
    : S extends `${infer L}${From}${infer R}`
      ? `${L}${To}${ReplaceAll<R, From, To>}`
      : S;

type Q1 = Replace<"hello world", "o", "0">;     // "hell0 world"
type Q2 = ReplaceAll<"hello world", "o", "0">;  // "hell0 w0rld"

Output: (none — exits 0 on success)

Trim

Whitespace-trimming at the type level — useful when parsing untrusted input like CSV cells:

typescript
type TrimLeft<S extends string>  = S extends ` ${infer R}` ? TrimLeft<R> : S;
type TrimRight<S extends string> = S extends `${infer L} ` ? TrimRight<L> : S;
type Trim<S extends string>      = TrimLeft<TrimRight<S>>;

type T1 = Trim<"   hello   ">; // "hello"

Output: (none — exits 0 on success)

CamelCase / SnakeCase converters

A small library of case converters is one of the most common uses of recursive template literal types — they enable codebases to share the same source-of-truth keys between snake-case APIs and camel-case TypeScript code without manual mapping.

typescript
type CamelCase<S extends string> =
  S extends `${infer Head}_${infer Tail}`
    ? `${Head}${Capitalize<CamelCase<Tail>>}`
    : S;

type C1 = CamelCase<"user_first_name">;   // "userFirstName"
type C2 = CamelCase<"api_response_data">; // "apiResponseData"
type C3 = CamelCase<"already">;            // "already"

type SnakeCase<S extends string> =
  S extends `${infer Head}${infer Tail}`
    ? Tail extends Uncapitalize<Tail>
      ? `${Lowercase<Head>}${SnakeCase<Tail>}`
      : `${Lowercase<Head>}_${SnakeCase<Tail>}`
    : S;

type S1 = SnakeCase<"userFirstName">;   // "user_first_name"
type S2 = SnakeCase<"APIResponseData">; // "a_p_i_response_data" — limitation

Output: (none — exits 0 on success)

The APIResponseData edge case shows the limit of pure-types case conversion — distinguishing acronyms from CamelCase boundaries requires runtime context. For production code use a library like type-fest's CamelCase, which has more careful word-boundary heuristics.

Express-style route parameter extraction

A canonical real-world use case: given a route literal like "/users/:id/posts/:postId", derive the parameter object { id: string; postId: string }. The result drives type-safe req.params access without manually duplicating the keys.

typescript
type ExtractParams<Path extends string> =
  Path extends `${string}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof ExtractParams<Rest>]: string }
    : Path extends `${string}:${infer Param}`
      ? { [K in Param]: string }
      : {};

type P1 = ExtractParams<"/users/:id">;
// { id: string }

type P2 = ExtractParams<"/users/:id/posts/:postId">;
// { id: string; postId: string }

type P3 = ExtractParams<"/static">;
// {}

function handler<Path extends string>(
  path: Path,
  fn: (params: ExtractParams<Path>) => void
) {
  // demo: fake-call the handler with fake params
  const fakeParams = { id: "42", postId: "99" } as ExtractParams<Path>;
  fn(fakeParams);
}

handler("/users/:id/posts/:postId", ({ id, postId }) => {
  console.log(`${id} ${postId}`);
});

Output:

code
42 99

The same shape powers libraries like hono, elysia, and TanStack Router — they extract route params straight out of the path string literal, so the handler signature can never go out of sync with the route definition.

JSON-path keys and dot notation

A second flagship pattern: generate the full set of dot-notation key paths into a nested object type. Pair this with an Access<T, Path> helper to get type-safe get(obj, "user.profile.name") calls.

typescript
type DotKeys<T, Prefix extends string = ""> = {
  [K in keyof T & string]: T[K] extends Record<string, unknown>
    ? `${Prefix}${K}` | DotKeys<T[K], `${Prefix}${K}.`>
    : `${Prefix}${K}`;
}[keyof T & string];

interface Config {
  server: { host: string; port: number };
  database: { url: string; pool: { min: number; max: number } };
  features: { darkMode: boolean };
}

type ConfigKeys = DotKeys<Config>;
// "server" | "server.host" | "server.port"
// | "database" | "database.url" | "database.pool"
//   | "database.pool.min" | "database.pool.max"
// | "features" | "features.darkMode"

const k: ConfigKeys = "database.pool.min"; // OK
// const bad: ConfigKeys = "database.pool.middle"; // Error

Output: (none — exits 0 on success)

Combine with Split and indexed-access types to get the value type at each path:

typescript
type Access<T, Path extends string> =
  Path extends `${infer Head}.${infer Tail}`
    ? Head extends keyof T
      ? Access<T[Head], Tail>
      : never
    : Path extends keyof T
      ? T[Path]
      : never;

type V1 = Access<Config, "database.pool.min">; // number
type V2 = Access<Config, "features.darkMode">; // boolean
type V3 = Access<Config, "server.nope">;       // never

Output: (none — exits 0 on success)

That Access helper is exactly how lodash.get types and most i18n libraries (next-intl, react-i18next) derive their key-paths.

Typed i18n helper

Putting DotKeys and Access together produces a tiny but powerful translation function. The key argument auto-completes to every dot-path in the translations object, and as never is not required anywhere.

typescript
const translations = {
  user: {
    profile: { name: "Name", email: "Email" },
    actions: { save: "Save", cancel: "Cancel" },
  },
  errors: { notFound: "Not found", forbidden: "Forbidden" },
} as const;

type Translations = typeof translations;
type TranslationKey = DotKeys<Translations>;

function t<K extends TranslationKey>(key: K): string {
  return key.split(".").reduce<unknown>(
    (acc, part) => (acc as Record<string, unknown>)[part],
    translations
  ) as string;
}

console.log(t("user.profile.name"));
console.log(t("user.actions.save"));
console.log(t("errors.notFound"));
// t("user.profile.nope"); // compile error — not assignable to TranslationKey

Output:

code
Name
Save
Not found

The autocomplete experience is what makes this worth doing — without it, every t("user.profile.nmae") typo only fails at runtime.

CSS-in-JS class composition

Tailwind-style class strings can be modeled with template literal types so the compiler catches typos and invalid combinations. Combining as const arrays with template unions gives you a full keyspace.

typescript
const sizes = ["sm", "md", "lg", "xl"] as const;
const colors = ["red", "blue", "green"] as const;

type Size = (typeof sizes)[number];
type Color = (typeof colors)[number];

type BtnClass = `btn-${Size}-${Color}`;
// "btn-sm-red" | "btn-sm-blue" | "btn-sm-green"
// | "btn-md-red" | ... 12 total

const ok:  BtnClass = "btn-md-red";
// const bad: BtnClass = "btn-md-yellow"; // Error

console.log(ok);

Output:

code
btn-md-red

SQL identifier safety

You can encode a small chunk of SQL grammar in the type system to catch silly mistakes like reversing table-name and column-name. This does not replace prepared statements for injection safety — it is purely a developer-ergonomics layer.

typescript
type Column = `${string}.${string}`;

function select<C extends Column>(col: C): C {
  return col;
}

const c1 = select("users.id");      // OK
const c2 = select("users.email");   // OK
// const bad = select("id");        // Error — missing dot
console.log(c1, c2);

Output:

python
users.id users.email

Limits & gotchas

Template literal types are surprisingly powerful but the compiler imposes hard limits that real code occasionally bumps into. Knowing them up front saves debugging time.

LimitWhat happensWorkaround
Union explosionMore than ~100k combinations fail with "type instantiation excessively deep"Constrain inputs, narrow unions, or use string
Recursion depth~50 recursive instantiationsTail-recursion style, or process in chunks
Inference precisioninfer N extends number requires TS 4.7+Stay on a recent TS version
Whitespace ambiguity\${A}-${B}`` is greedy on the first matchUse multiple infers with explicit separators
Non-distributive contextsWrapping in [T] prevents distributionUse this trick when you want union-as-a-whole

Common pitfalls

  1. Union explosion\${a}-${b}-${c}-${d}`where each variable has 50 values creates 6.25M types and the compiler errors. Either narrow the inputs or fall back tostring` in the offending position.
  2. keyof returns string | number | symbol — intersect with string (i.e. keyof T & string) before using in a template; otherwise Capitalize and friends complain.
  3. infer is greedy\${infer A}-${infer B}`on"a-b-c"capturesA="a"andB="b-c", not B="b". For multi-delimiter parsing, write a Split` helper.
  4. String literal vs string typeGreet<string> returns string, not the captured infer variable. Constrain S extends string and pass in a literal type.
  5. Recursion limit — deep DotKeys on a 6-level-nested object can hit "type instantiation excessively deep". Cap recursion with a depth counter or use a runtime helper.
  6. as const is required for inference — without it, ["a", "b"] infers to string[] and [number] becomes string, breaking literal extraction.
  7. Uppercase<string> is string — intrinsics over non-literal string return string, not a literal. Useful occasionally, often surprising.
  8. Number to literal\page-${1}`produces"page-1"` because numeric literals coerce to string literals in templates. Fine for keys, occasionally confusing in error messages.
  9. Empty-string edge caseSplit<"", "."> = [""], not []. Handle the empty case explicitly if it matters.
  10. Output is read-only at type level — you can pattern-match on a template literal type at the type level, but the runtime must do its own split / replace. Always pair type-level transforms with a runtime equivalent.

Real-world recipes

Recipe 1: typed query-string parser

Type the keys of a URL-querystring helper so the consumer auto-completes valid params and the value types match the route definition.

typescript
type Query<S extends string, Acc extends Record<string, string> = {}> =
  S extends `${infer K}=${infer V}&${infer Rest}`
    ? Query<Rest, Acc & { [P in K]: V }>
    : S extends `${infer K}=${infer V}`
      ? Acc & { [P in K]: V }
      : Acc;

type Q1 = Query<"name=Alice&age=30&role=admin">;
// { name: "Alice"; age: "30"; role: "admin" }

function parseQuery<S extends string>(s: S): Query<S> {
  const out: Record<string, string> = {};
  for (const pair of s.split("&")) {
    const [k, v] = pair.split("=");
    out[k] = v;
  }
  return out as Query<S>;
}

const q = parseQuery("name=Alice&role=admin");
console.log(q.name, q.role);

Output:

code
Alice admin

Recipe 2: SQL-flavoured column path

A from(table).select(col) builder where the column literal must include the table prefix. Wrong-table column names fail at compile time without any runtime work.

typescript
type Schema = {
  users:    { id: string; email: string; createdAt: Date };
  posts:    { id: string; authorId: string; title: string };
};

type ColumnsOf<T extends keyof Schema> = `${T & string}.${keyof Schema[T] & string}`;

function selectCol<T extends keyof Schema>(table: T, col: ColumnsOf<T>): string {
  return `SELECT ${col} FROM ${String(table)}`;
}

console.log(selectCol("users", "users.email"));
// console.log(selectCol("users", "posts.title")); // Error

Output:

css
SELECT users.email FROM users

Recipe 3: object key renamer for snake_case → camelCase

Combine CamelCase with a key-remapping mapped type to lift an entire snake-cased API response to camelCase TypeScript shape — type-only, no runtime cost when paired with a library that does the runtime conversion.

typescript
type CamelKeys<T> = {
  [K in keyof T as K extends string ? CamelCase<K> : K]: T[K];
};

interface RawUser {
  user_id: number;
  first_name: string;
  last_name: string;
  is_active: boolean;
}

type CleanUser = CamelKeys<RawUser>;
// {
//   userId: number;
//   firstName: string;
//   lastName: string;
//   isActive: boolean;
// }

const user: CleanUser = {
  userId: 1,
  firstName: "Alice",
  lastName: "Dev",
  isActive: true,
};
console.log(user.firstName);

Output:

code
Alice

Recipe 4: typed event emitter

A pub/sub bus whose emit and on methods are typed by an event-map. Combine template literal types with mapped types so consumers get autocomplete for both event names and payload shapes.

typescript
type Events = {
  "user:created":   { id: string; name: string };
  "user:deleted":   { id: string };
  "post:published": { postId: string; authorId: string };
};

type Bus = {
  emit<E extends keyof Events>(event: E, payload: Events[E]): void;
  on<E extends keyof Events>(event: E, listener: (payload: Events[E]) => void): void;
};

function createBus(): Bus {
  const listeners: Record<string, Array<(payload: unknown) => void>> = {};
  return {
    emit(event, payload) {
      (listeners[event] ?? []).forEach((fn) => fn(payload));
    },
    on(event, listener) {
      (listeners[event] ??= []).push(listener as (p: unknown) => void);
    },
  };
}

const bus = createBus();
bus.on("user:created", ({ id, name }) => console.log(`new user ${id}: ${name}`));
bus.emit("user:created", { id: "u1", name: "Alice Dev" });
// bus.emit("user:created", { id: "u1" }); // Error — missing name

Output:

sql
new user u1: Alice Dev

Recipe 5: type-checked CSS variable map

A theme object whose CSS-variable names auto-derive from token names, and consumers can only reference variables that actually exist.

typescript
const tokens = {
  colorPrimary: "#8a5cff",
  colorAccent:  "#ffce5c",
  spaceSm:      "4px",
  spaceMd:      "8px",
} as const;

type Token = keyof typeof tokens;
type CssVar = `--${SnakeCase<Token>}`;

function cssVar(name: Token): CssVar {
  return `--${name.replace(/([A-Z])/g, "_$1").toLowerCase()}` as CssVar;
}

const v: CssVar = cssVar("colorPrimary");
console.log(v);
// const bad: CssVar = "--nope"; // Error — not in the union

Output:

css
--color_primary