Frontend / Typescript / 03_template_literal_types.md

Template Literal Types

Updated 5 interview angles 5 min read source
On this page12
  1. What’s a template literal type?
  2. What are the intrinsic string manipulation types?
  3. How do you combine template literal types with mapped types?
  4. How would you type a route param extractor?
  5. How would you build type-safe event names?
  6. How do you type a CSS variable name pattern?
  7. How would you build a “snake_case to camelCase” type?
  8. Can you use template literal types to constrain a string?
  9. Gotchas
  10. Related
  11. Further reading
  12. Interview angle

Template Literal Types

Template literal types are template strings, but at the type level — `prefix_${T}` builds new string-literal types from existing ones. Combined with key remapping in mapped types, they’re how libraries build type-safe APIs around naming conventions (onClick → onclick, route params → param object types, event names → handler signatures).

What’s a template literal type?

A type that’s a string built from other string-literal types. The ${...} placeholder accepts unions, and the type distributes across them.

ts
type Color = 'red' | 'blue'
type Size = 'sm' | 'lg'

type Variant = `${Color}-${Size}`
// = "red-sm" | "red-lg" | "blue-sm" | "blue-lg"

A literal cross-product, computed at type-check time.

What are the intrinsic string manipulation types?

Four built-ins operate on string literal types:

Type Effect
Uppercase<S> 'abc' → 'ABC'
Lowercase<S> 'ABC' → 'abc'
Capitalize<S> 'name' → 'Name'
Uncapitalize<S> 'Name' → 'name'
ts
type Loud<S extends string> = `${Uppercase<S>}!`
type R = Loud<'hello'>  // "HELLO!"

These are the building blocks for property-name transformations.

How do you combine template literal types with mapped types?

Use key remapping with as to transform property names. The classic example is generating a getter for every property:

ts
type Getters<T> = {
  [K in keyof T as `get${Capitalize<K & string>}`]: () => T[K]
}

interface User { id: number; name: string }
type G = Getters<User>
// = { getId: () => number; getName: () => string }

K & string filters to string keys (since keyof may include number | symbol).

How would you type a route param extractor?

ts
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<'/orgs/:org/users/:user'>
// = { org: string; user: string }

This is how libraries like TanStack Router achieve fully typed params from a path string.

How would you build type-safe event names?

ts
type EventMap = {
  click: { x: number; y: number }
  hover: { target: string }
  scroll: { offset: number }
}

type Handlers = {
  [K in keyof EventMap as `on${Capitalize<K & string>}`]: (payload: EventMap[K]) => void
}
// = { onClick: (p: { x; y }) => void; onHover: ...; onScroll: ... }

This is the pattern under Mantine/MUI prop types and Vue emit typing.

How do you type a CSS variable name pattern?

ts
type CSSVar<Name extends string> = `--${Name}`

const v: CSSVar<'primary-color'> = '--primary-color'
const w: CSSVar<'primary-color'> = '--bad'  // error

Useful for design-token APIs where you want the variable name spelled correctly.

How would you build a “snake_case to camelCase” type?

ts
type SnakeToCamel<S extends string> =
  S extends `${infer Head}_${infer Tail}`
    ? `${Head}${Capitalize<SnakeToCamel<Tail>>}`
    : S

// "userFullName"
type R = SnakeToCamel<'user_full_name'>

Recurses on the underscore. Often combined with a mapped type to transform entire object shapes.

ts
type CamelizeKeys<T> = {
  [K in keyof T as SnakeToCamel<K & string>]: T[K]
}

Can you use template literal types to constrain a string?

Yes — useful for IDs and prefixed enums:

ts
type UserId = `usr_${string}`
type OrderId = `ord_${string}`

function findUser(id: UserId) { /* ... */ }

findUser('usr_abc')   // ok
findUser('ord_xyz')   // error — wrong prefix

This single-handedly catches “I passed an OrderId where a UserId was expected” bugs.

Gotchas

  • Combinatorial explosion — ${A}-${B}-${C} with unions of 50, 50, 50 = 125,000 string types. TS will balk.
  • Distributes over unions — `pre-${A | B}` becomes `pre-A` | `pre-B` automatically.
  • number in template literal types — `${number}` matches any number-shaped string, useful for parsing.
  • infer inside a template literal — yes, that’s how the parsing tricks work (extends `${infer A}-${infer B}`).
  • K & string — when iterating keyof T, intersect with string to drop number | symbol keys before passing to Capitalize.
  • Bivariance and as — as in a mapped type remaps the key, while as in an expression is a runtime-erased cast. Different things, same keyword.

Further reading

Interview angle 5

  • “What are template literal types actually used for?” - building fully typed APIs out of string patterns. It is how a typed router extracts ":id" from a path into a params object, how tRPC and typed event emitters derive names, and how i18n libraries check a translation key exists - all at compile time from a string.
  • “How do you parse a string in the type system?” - infer inside the template: T extends `${infer Head}-${infer Rest}` splits it, and you recurse on Rest. That plus Capitalize/Uncapitalize is the whole snake_case-to-camelCase trick.
  • “What is the limit?” - combinatorial explosion. ${A}-${B}-${C} over three 50-member unions is 125,000 string literal types and the compiler gives up. That is why nobody types “every Tailwind class” this way - those libraries generate the types instead.
    • Follow-up: “Why does it explode so fast?” - template literals distribute over unions, so `pre-${A | B}` automatically becomes `pre-A` | `pre-B`. That is exactly what you want for two small unions and what kills you for three large ones.
  • “You map over keyof T into a template and it errors. Why?” - keyof T includes number | symbol, and a template literal needs a string. Intersect first: K & string. It is the single most common error in this area.
  • “as appears in mapped types and in expressions. Same thing?” - no, and the shared keyword is unfortunate. In a mapped type, as remaps the key and is a real type-level operation. In an expression it is a cast that is erased at runtime and checks nothing.