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