scalable/craft/ts types in anger/lesson 03 cph / /
lesson 03 / 06 · 12 min · updated ·

template literal types

strings the compiler understands. route patterns, event names, css-in-ts, and parsing — all without a single regex.

strings the compiler can read

template literal types let you compose string types the same way template strings compose string values. the syntax is identical — a backtick string with $… holes — but inside the holes you put types, not values. the result is a type representing every concatenation that fits.

the basic shape
type Greeting = \`hello, ${string}\`;
const a: Greeting = "hello, world";  // OK
const b: Greeting = "hello, sadi";   // OK
const c: Greeting = "hey there";     // type error

type Color = "red" | "green" | "blue";
type Shade = "light" | "dark";
type Token = \`${Shade}-${Color}\`;
//   ^? "light-red" | "light-green" | "light-blue"
//      | "dark-red"  | "dark-green"  | "dark-blue"

when a hole has a literal union, the template literal type is the cartesian product. two-by-three becomes six. the compiler enumerates them. nothing exotic — it's the same combinatorial expansion you'd write by hand, except you don't have to.

route patterns

the obvious use case. you have a router that wants a string like /users/:id/orders/:orderId and you'd like the compiler to know that handlers for that route receive { id, orderId } as parsed params. template literal types — combined with conditional types and infer from lesson 4 — make this a one-liner.

extracting route params
type Params<T extends string> =
  T extends \`${string}:${infer P}/${infer Rest}\`
    ? { [K in P | keyof Params<\`/${Rest}\`>]: string }
    : T extends \`${string}:${infer P}\`
      ? { [K in P]: string }
      : {};

type R1 = Params<"/users/:id">;
//   ^? { id: string }

type R2 = Params<"/users/:id/orders/:orderId">;
//   ^? { id: string; orderId: string }

function get<T extends string>(route: T, handler: (p: Params<T>) => void) { /* … */ }

get("/users/:id/orders/:orderId", ({ id, orderId }) => { /* … */ });
//                                  ^^^^^^^^^^^^ both typed string

that pattern uses infer to pull the segment after each colon out as a type variable, then recurses on the rest. the compiler runs the recursion at compile time. your route table has types.

event names and case transforms

typescript ships four intrinsic helpers that act on template literal types: Uppercase, Lowercase, Capitalize, Uncapitalize. combine them with holes and you can express event-name conventions like dom uses.

dom event naming
type EventNames = "click" | "focus" | "submit";
type Handlers = {
  [K in EventNames as \`on${Capitalize<K>}\`]: (e: Event) => void;
};
//   ^? { onClick: (e: Event) => void;
//        onFocus: (e: Event) => void;
//        onSubmit: (e: Event) => void }

the as clause in the mapped type (lesson 5 covers this in full) remaps each key through the template. one line of types produces the entire on* handler interface — and adding an event to the union adds a handler.

check yourself

template literals — 4 questions

trace the literal types. don't worry about Params recursion — focus on the simple holes.

cartesian product
type Size = "sm" | "lg";
type Color = "red" | "blue";

type Class = `btn-${Size}-${Color}`;
//    ^?

what is `Class`?

where the limits are

template literal types can recurse — that's how Params above works — but the compiler caps recursion depth (currently around 50 levels). long paths and deep parsers will hit it. for production code, this means: yes for route patterns, yes for simple parsers, no for trying to build a full regex engine in the type system. the fallback is always a runtime parser and a hand-typed return shape.

three concrete uses

tabs across three places template literals earn their seat.

type Space = 0 | 1 | 2 | 4 | 8 | 16;
type Side = "t" | "r" | "b" | "l";
type Margin = \`m${Side}-${Space}\`;

const ok: Margin = "mt-4";
const no: Margin = "mt-3";  // type error: 3 not in Space
tsc sees
// Margin =
//   "mt-0" | "mt-1" | "mt-2" | "mt-4"
//   "mt-8" | "mt-16"
//   ... (× 4 sides)
//
// 24 valid strings, every other rejected.

your design system, encoded. typos and off-by-one spacing values become compile errors.

when not to reach for it

template literal types are computed at compile time. every union member is enumerated, every recursion is unrolled. that's fine for small alphabets (a dozen sides × spaces × variants) but expensive for big ones. if your union has thousands of members, expanding it through a template will slow tsc to a crawl. when that happens, fall back to a plain string typed-newtype with a brand (lesson 2), or assert at the boundary with a runtime regex.

rule of thumb if the union size at any hole is bigger than a few dozen, switch off template literals at that layer and use a brand or assertion function instead. the compile-time budget is real.

what you took away

further reading

scalable labs·cvr 30091604·github·linkedin·hello@scalable.dk