Topic 9.4
Template Literal Types
In one line
Template literal types build string types from pieces, with the same backtick syntax as template strings, so you can describe text patterns like user-${number} and generate names like onClick from a union.
Think of it like this
A name-tag printer at a conference. The tag always reads "Hello, my name is ___" and the blank takes whatever the visitor writes. If three colours and three sizes are allowed, the printer can make every colour-size label. A template literal type is that printer for text types: fixed words with blanks, and each blank filled from a type. A union in a blank makes one label for every member.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Template literal type
- A string type written with backticks and
${}blanks, like `user-${number}`. - Blank (placeholder)
- The
${...}part. It takesstring,number,boolean,bigintor a union of literals. - Literal type
- A type that holds exactly one value, like
"red"or42. - Intrinsic type
- A helper built into the compiler, such as
UppercaseandCapitalize, not written in TypeScript. - Key remapping
- Using
asin a mapped type to rename the keys (Topic 8.5). - Code point
- One Unicode character as a single number. An emoji is one code point, but two 16-bit units in a JavaScript string.
- Pattern matching on strings
- Using
inferinside a template literal type to capture parts of a string type.
Step by step
01A pattern with one blank
A template literal type is a pattern. ` hello ${string} means "hello and then any text". Values that don't start with hello ` are errors.
A number blank accepts text that is a valid number: "user-42" fits `user-${number}, "user-abc"` does not.
type Greeting = `hello ${string}`;
const ok: Greeting = "hello Asha"; // fine
const bad: Greeting = "hi Asha"; // error TS2322
type Id = `user-${number}`;
const a: Id = "user-42"; // fine
const b: Id = "user-abc"; // error02Unions multiply
Put a union in a blank and the compiler writes out every combination. Two colours and three sizes give six exact strings. Hover over Sku in the first live example to see the six.
This turns a list of allowed words into a finite set of allowed strings, so a typo like "blue-M" is rejected.
03Change case with the four helpers
Capitalize<"click"> is "Click". Join it to a fixed word and you get a name the way people write them: ` on${Capitalize<Verb>} `.
type Verbs = "click" | "hover";
type Handlers = `on${Capitalize<Verbs>}`;
// "onClick" | "onHover"04Generate a whole object type
A mapped type can build the key names. ` [K in keyof T & string as on${Capitalize<K>}Change] makes onNameChange out of name`.
The & string keeps only the string keys, because a template literal cannot take a symbol. Without it, the compiler reports an error that K does not satisfy string.
05Take a string apart
Use infer inside the pattern. ` ${infer Head}${D}${infer Tail} splits at the first match of the delimiter D`. A type that calls itself on the tail splits the whole string (Topic 9.5).
The first blank before a literal takes the shortest piece that works, so Cut<"a.b.c"> gives ["a", "b.c"].
Try it yourself
- 1
Add a size
In the first example, add
"XL"toSize. Predict how many membersSkuhas now (8) and check the Types panel. - 2
Handlers for more keys
Add
email: stringtoFormin the second example. Predict the newFormHandlersbefore you look: it gainsonEmailChange: (value: string) => void.Now the object
handlersis missing that handler. Read the error on theconst handlersline:TS2741: Property 'onEmailChange' is missing in type .... AddonEmailChange: (value) => console.log("email is", value)to make it go away. You changed one type, and the compiler found the object that needed updating. - 3
Write a prefix remover
Write
StripPrefix<S>that turns"user-42"into"42"usingS extendsuser-${infer Rest}? Rest : S. Test it on a string that does not have the prefix.
Code & diagrams
Three bad assignments, one bad SKU. Fix them one at a time and watch the errors disappear. The union in Sku is sorted by the compiler, not in the order you wrote it.
Console
(nothing was printed)The handler parameters are typed from the Form fields: value is a string in onNameChange and a number in onAgeChange, with no annotations written.
Console
(nothing was printed)Expected console output
name is ASHA
age is 31The stray on("upload:stop") is an error, yet the code still runs and logs it (types are erased). PathParams reads the route text and returns the parameter names.
Console
(nothing was printed)Expected console output
listening for upload:start
go
listening for upload:stoptype Chars<S extends string> = S extends `${infer H}${infer T}` ? [H, ...Chars<T>] : [];
const whole: Chars<"a😀b"> = ["a", "😀", "b"];
const halves: Chars<"a😀b"> = ["a", "\ud83d", "\ude00", "b"];$ node node_modules/typescript-7/bin/tsc -p .
index.ts(4,37): error TS2322: Type '"\uD83D"' is not assignable to type '"😀"'.
index.ts(4,47): error TS2322: Type '"\uDE00"' is not assignable to type '"b"'.
$ node node_modules/typescript-6/bin/tsc -p .
index.ts(3,36): error TS2322: Type '"😀"' is not assignable to type '"�"'.
index.ts(3,42): error TS2322: Type '"b"' is not assignable to type '"�"'.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Call a handler that was never generated
In the second example, add handlers.onEmailChange("x"); as the last line, although Form has no email field.
Myth vs fact
Myth
Template literal types check strings at runtime.
Fact
No. They only check values the compiler can see. A string that comes from a user is just string unless you narrow it or write a type guard (Topic 4.7).
Myth
I can put any type in a ${} blank.
Fact
Only string, number, bigint, boolean, null, undefined and unions of those. Using keyof T directly fails because keys can be symbols, so write keyof T & string.
Myth
Case helpers are written in TypeScript like other utilities.
Fact
They are intrinsic: implemented inside the compiler. Open lib.es5.d.ts and you will see type Uppercase<S extends string> = intrinsic;.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Unions in blanks multiply: each blank multiplies the count. The checker stops with
TS2590: Expression produces a union type that is too complex to represent.when a union would reach 100,000 members (five blanks of ten digits each are enough). Prefer a pattern (`${string}-${number}`) over writing out huge sets. - ▸
Matching with
inferis greedy only at the end. Inside a pattern the leftmost blank takes the shortest text that lets the rest match. If two blanks touch (${infer A}${infer B}),Atakes exactly one character. - ▸
TypeScript 7 splits at Unicode code points; TypeScript 6 split at 16-bit units. Anything built on character-by-character recursion (
Chars,Reverse, length counters) can differ for emoji and other characters outside the Basic Multilingual Plane.--stableTypeOrderingin 6.0 only fixes union ordering, not this.
Remember this
- 1
The problem this solves: to the checker,
"onClick","user-42"and"/users/7"are all juststring, so a typo like"onClik"slips through. Writing every allowed string out by hand is long and goes stale. A template literal type describes the pattern or builds the whole list from smaller lists, so the compiler can reject the typo. - 2
You write them with backticks, like a JavaScript template string, but in a type position: `
type Greeting =hello ${string}. This type accepts every string that starts withhellofollowed by anything."hello Asha"fits;"hi Asha"is errorTS2322`. The type still erases at runtime, it's only a check on string values. - 3
The blanks can be
string,number,boolean,bigint, or a literal union (Topic 4.2: a union of exact values like"red" | "green"). `user-${number}accepts"user-42"but not"user-abc"or"admin-42". With literal types in the blanks the result is a **finite union**:${Color}-${Size}` with 2 colours and 3 sizes is 6 exact strings. Lists multiply: 10 × 10 × 10 blanks is 1000 members, so keep an eye on size (Topic 9.2, pro notes). - 4
Four built-in helpers change the case of a string type:
Uppercase<S>,Lowercase<S>,Capitalize<S>(first letter up) andUncapitalize<S>(first letter down). They are built into the compiler, not written in TypeScript. They distribute over unions, soUppercase<"click" | "hover">is"CLICK" | "HOVER". - 5
Typed event names are the star use. Combine the helpers with a union: `
on${Capitalize<Verbs>}gives"onClick" | "onHover". With key remapping from Topic 8.5 (asin a mapped type) you can generate a whole handler object from a data type:{ name: string }becomes{ onNameChange: (value: string) => void }`. Typos in the handler name become compile errors. - 6
Template literal types can also take strings apart, using
inferfrom Topic 9.3. `E extends${string}@${infer D}pulls the domain out of an email type.Split<"a-b-c", "-">returns["a", "b", "c"]. A route type like"/users/:id/posts/:postId"can produce the union"id" | "postId"` so a router can demand exactly those parameters. When a pattern has two blanks in a row, the first blank takes one character, and the last blank takes the rest. - 7
Unicode. JavaScript strings are sequences of 16-bit units, so an emoji like
😀is two units. TypeScript 6.0 split a template literal type by those units, which could cut an emoji in half and give a broken character (shown as�, the Unicode replacement character). TypeScript 7.0 keeps each Unicode code point together:"😀abc"splits as"😀"then"abc". The two versions can therefore disagree for emoji and other characters outside the basic range, which is why the Unicode examples below are not live. In the two-line example, 7.0 accepts thewholeline and rejectshalves, and 6.0 does the opposite. For ordinary letters, digits and symbols nothing changes.
Explain it without notes
What is a template literal type and how does it differ from a template string?
How would you generate "onNameChange" style handler names from the keys of a type?
How can a template literal type take a string apart?
What changed for template literal types in TypeScript 7?
Practice
Create a type Cell for spreadsheet cells like "A1" to "C3" with columns A | B | C and rows 1 | 2 | 3.
Write Getters<T> that turns { name: string } into { getName: () => string }.
Write TrimLeft<S> that removes leading spaces from a string type.
Trade-offs
- ↔
Typed strings catch typos in event names, CSS properties and routes, but a big union of combinations slows the checker and gives long error messages. Prefer patterns (`
${string}-${number}`) when the exact list isn't needed.
Done when you can
I can write a template literal type with
string,numberand a union in blanks.I can use the four case helpers.
I can generate handler names from keys with
asremapping.I can extract a piece of a string type with
infer.I can explain how Unicode splitting differs in TypeScript 7.