Skip to content
webtype.orgTwelve concepts · 9/12

Concept 9 of 12

Template literal types

Template literal types let the compiler build and match strings. Combined with `infer` and recursion they can parse — which is how a route string becomes a params object, and how typed i18n keys and ORM column names are checked at compile time.

Building and stringifying

type Greeting = `hello ${string}`
type Event = `on${'Click' | 'Focus'}`   // 'onClick' | 'onFocus'
type AsText = `${42}`                    // '42'

// Interpolating a union multiplies out every combination.
type Cell = `${'a' | 'b'}${1 | 2}`       // 'a1' | 'a2' | 'b1' | 'b2'

Interpolating a union produces the cross product, which grows fast — TypeScript caps the result at 100,000 members and errors past that. Stringifying a number with `` `${N}` `` is the standard way to turn an arithmetic problem into a text problem.

Matching is anchored and greedy from the left

type StartsWith<S, P extends string> = S extends `${P}${string}` ? true : false
type EndsWith<S, P extends string> = S extends `${string}${P}` ? true : false
type Contains<S, P extends string> = S extends `${string}${P}${string}` ? true : false

// Greedy from the left: Head binds the SHORTEST prefix that works.
type Split1<S> = S extends `${infer Head}-${infer Tail}` ? [Head, Tail] : never
type X = Split1<'a-b-c'>   // ['a', 'b-c']

A pattern must match the whole string, so where you put `${string}` is where you allow slack. Slack at the front means "contains", at the back means "starts with", and on both sides means you have accidentally written a substring test.

The four intrinsic case types

Uppercase<'abc'>      // 'ABC'
Lowercase<'ABC'>      // 'abc'
Capitalize<'abc'>     // 'Abc'
Uncapitalize<'Abc'>   // 'abc'

These four are implemented natively in the compiler and have no TypeScript source. Reach for them before writing a character-by-character recursion — `CamelCase` and friends are a few lines with them and a nightmare without.

The common wrong answer

type Replace<S extends string, From extends string, To extends string> =
  S extends `${infer H}${From}${infer T}` ? `${H}${To}${T}` : S

Replace<'abc', '', 'x'>   // 'xabc' — not 'abc'

The empty string sits between any two characters, so an empty needle matches immediately with `H` empty. It is the `null` of template literal types: it matches everywhere and quietly breaks patterns that assume a match means something was found.

Takeaway

Patterns are anchored at both ends and greedy from the left. Where you allow slack decides what you are actually asking, and the empty string is always the edge case.

Practiced in