skip to content

Template Literal Types — String Algebra at the Type Level

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.

14 min read 35 snippets deep dive

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

npm install -D typescript

# Verify version
npx tsc --version

Output:

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.

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.

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:

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.

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:

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.

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:

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:

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

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:

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:

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:

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.

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.

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:

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.

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:

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.

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:

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.

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:

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.

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:

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.

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:

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.

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:

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.

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:

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.

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:

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.

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:

--color_primary