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).
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
| Intrinsic | Effect | Example |
|---|---|---|
Uppercase<S> | All-caps version of literal S | Uppercase<"foo"> → "FOO" |
Lowercase<S> | All-lowercase version | Lowercase<"BAR"> → "bar" |
Capitalize<S> | Uppercases first character only | Capitalize<"name"> → "Name" |
Uncapitalize<S> | Lowercases first character only | Uncapitalize<"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.
| Limit | What happens | Workaround |
|---|---|---|
| Union explosion | More than ~100k combinations fail with "type instantiation excessively deep" | Constrain inputs, narrow unions, or use string |
| Recursion depth | ~50 recursive instantiations | Tail-recursion style, or process in chunks |
| Inference precision | infer N extends number requires TS 4.7+ | Stay on a recent TS version |
| Whitespace ambiguity | \${A}-${B}`` is greedy on the first match | Use multiple infers with explicit separators |
| Non-distributive contexts | Wrapping in [T] prevents distribution | Use this trick when you want union-as-a-whole |
Common pitfalls
- 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. keyofreturnsstring | number | symbol— intersect withstring(i.e.keyof T & string) before using in a template; otherwiseCapitalizeand friends complain.inferis greedy —\${infer A}-${infer B}`on"a-b-c"capturesA="a"andB="b-c", notB="b". For multi-delimiter parsing, write aSplit` helper.- String literal vs string type —
Greet<string>returnsstring, not the captured infer variable. ConstrainS extends stringand pass in a literal type. - Recursion limit — deep
DotKeyson a 6-level-nested object can hit "type instantiation excessively deep". Cap recursion with a depth counter or use a runtime helper. as constis required for inference — without it,["a", "b"]infers tostring[]and[number]becomesstring, breaking literal extraction.Uppercase<string>isstring— intrinsics over non-literalstringreturnstring, not a literal. Useful occasionally, often surprising.- 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. - Empty-string edge case —
Split<"", "."> = [""], not[]. Handle the empty case explicitly if it matters. - 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