Skip to content
webtype.orgwebtype.org

    ↑↓ move · ⏎ open · esc close

    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.

    Follow the typeCompiler checked
    ExpressionEventName<"name">
    Resolves to"nameChanged"
    ExpressionEventName<"name" | "age">
    Resolves to"nameChanged" | "ageChanged"

    A union in a template slot produces one string literal per member. This happens in the type system, without running JavaScript.

    Type definition
    type EventName<T extends string> = `${T}Changed`

    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.

    Exercise

    Build an event handler name: click becomes onClick.

    Try it

    2
    • Handler<'click'>—
      → 'onClick'
    • Handler<'focus' | 'blur'>—
      → 'onFocus' | 'onBlur'

    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