Command Palette

Search for a command to run...

Hectal
PHASE 9Advanced TypeScript 4.1+ ~26 min· topic 4 of 5

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 takes string, number, boolean, bigint or a union of literals.
Literal type
A type that holds exactly one value, like "red" or 42.
Intrinsic type
A helper built into the compiler, such as Uppercase and Capitalize, not written in TypeScript.
Key remapping
Using as in 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 infer inside 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.

patterns.tswhole filets
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";   // error

02Unions 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>} `.

helpers.tswhole filets
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.

Generate a whole object typediagram
Rendering diagram…

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

    Add a size

    In the first example, add "XL" to Size. Predict how many members Sku has now (8) and check the Types panel.

  2. 2

    Handlers for more keys

    Add email: string to Form in the second example. Predict the new FormHandlers before you look: it gains onEmailChange: (value: string) => void.

    Now the object handlers is missing that handler. Read the error on the const handlers line: TS2741: Property 'onEmailChange' is missing in type .... Add onEmailChange: (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. 3

    Write a prefix remover

    Write StripPrefix<S> that turns "user-42" into "42" using S extends user-${infer Rest} ? Rest : S. Test it on a string that does not have the prefix.

Code & diagrams

String patterns and combining unions TypeScript 4.1+

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 four case helpers and typed handler names

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 31
Typed event names and taking strings apart

The 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:stop
Unicode: where TypeScript 6 and 7 differ (not live)ts
type 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"];
What the two compilers say about itbash
$ 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.

terminal
$ Types panel
── what you'll see ──
index.ts(28,10): error TS2339: Property 'onEmailChange' does not exist on type 'FormHandlers'.

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 infer is 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}), A takes 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. --stableTypeOrdering in 6.0 only fixes union ordering, not this.

Remember this

  1. 1

    The problem this solves: to the checker, "onClick", "user-42" and "/users/7" are all just string, 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. 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 with hello followed by anything. "hello Asha" fits; "hi Asha" is error TS2322`. The type still erases at runtime, it's only a check on string values.

  3. 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. 4

    Four built-in helpers change the case of a string type: Uppercase<S>, Lowercase<S>, Capitalize<S> (first letter up) and Uncapitalize<S> (first letter down). They are built into the compiler, not written in TypeScript. They distribute over unions, so Uppercase<"click" | "hover"> is "CLICK" | "HOVER".

  5. 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 (as in 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. 6

    Template literal types can also take strings apart, using infer from 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. 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 the whole line and rejects halves, and 6.0 does the opposite. For ordinary letters, digits and symbols nothing changes.

Explain it without notes

01

What is a template literal type and how does it differ from a template string?

02

How would you generate "onNameChange" style handler names from the keys of a type?

03

How can a template literal type take a string apart?

04

What changed for template literal types in TypeScript 7?

Practice

01

Create a type Cell for spreadsheet cells like "A1" to "C3" with columns A | B | C and rows 1 | 2 | 3.

02

Write Getters<T> that turns { name: string } into { getName: () => string }.

03

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, number and a union in blanks.

  • I can use the four case helpers.

  • I can generate handler names from keys with as remapping.

  • I can extract a piece of a string type with infer.

  • I can explain how Unicode splitting differs in TypeScript 7.