Type-Level Programming

Advanced TypeScript — Type-Level Programming
Advanced TypeScript
Course 4 · Chapter 6 · Type-Level Programming

🧮 Type-Level Programming

Every earlier chapter in this course — conditionals, recursion, tuples, guards, brands — was really building toward one idea: the type system is a small, pure, functional language that runs entirely at compile time. This chapter puts it all to work: string manipulation with template literal types, a builder whose .build() only compiles once every required field is set, and a taste of encoding a tiny DSL directly in types.

Template Literal Types: String Computation

Template literal types apply string interpolation to types, not just values — combined with a union, they generate every combination automatically:

type EventName<T extends string> = `on${Capitalize<T>}`; type A = EventName<"click">; // "onClick" type B = EventName<"hover">; // "onHover" type Field = "name" | "email" | "age"; type Setter = `set${Capitalize<Field>}`; // "setName" | "setEmail" | "setAge" — one union in, three strings out, all at once

Built-in String Types

Uppercase<T>, Lowercase<T>, Capitalize<T>, Uncapitalize<T> — compile-time equivalents of the runtime string methods.

Distributes Over Unions

Just like conditional types (Chapter 1), a template literal type applied to a union type produces a union of every combination.

Pulling a String Type Apart With infer

infer works inside a template literal pattern too — this is how you go from "a route string" to "the parameters it contains," entirely at the type level:

Extracting Route Params From a Path String

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 Params = ExtractParams<"/users/:userId/posts/:postId">; // { userId: string; postId: string } — derived directly from the route string, // no manual interface written by hand. function buildUrl<Path extends string>(path: Path, params: ExtractParams<Path>): string { let url: string = path; for (const [key, value] of Object.entries(params)) { url = url.replace(`:${key}`, value as string); } return url; } buildUrl("/users/:userId/posts/:postId", { userId: "42", postId: "7" }); // ✅ "/users/42/posts/7" — and TypeScript would reject a call missing "postId"

Each recursive step of ExtractParams peels off one :paramName/ segment using infer Param and infer Rest, recurses on Rest, and merges the results — the same tuple/string recursion pattern from Chapters 2 and 3, just walking a template literal instead of a tuple.

🏗️ A Type-Safe Builder

A fluent builder normally lets you call .build() before every required field is set — the mistake is only caught at runtime. Tracking "which fields have been set" as a type parameter moves that check to compile time:

type RequestBuilderState = { url?: string; method?: string }; class RequestBuilder<State extends RequestBuilderState = {}> { private state: State; constructor(state: State) { this.state = state; } url(value: string): RequestBuilder<State & { url: string }> { return new RequestBuilder({ ...this.state, url: value }); } method(value: string): RequestBuilder<State & { method: string }> { return new RequestBuilder({ ...this.state, method: value }); } build(this: RequestBuilder<Required<RequestBuilderState>>): { url: string; method: string } { return this.state as { url: string; method: string }; } } const request = new RequestBuilder({}) .url("/users") .method("GET") .build(); // ✅ both url and method were set // new RequestBuilder({}).url("/users").build(); // ❌ `this` type requires `method` to be set — .build() itself doesn't exist // on a RequestBuilder that's missing a required field.

Polymorphic this Parameter

build(this: RequestBuilder<Required<...>>) restricts which specific instantiation of the builder .build() is even callable on.

State Accumulates via Intersection

Each setter method returns State & { newField: ... } — the same accumulator pattern as Chapter 2's counting tuple, just intersecting object shapes instead of appending tuple elements.

Runtime-Checked vs Compile-Time-Tracked Builder

Runtime-Checked

A missing field is caught only when .build() actually runs — often deep in a test suite, or worse, in production.

Compile-Time-Tracked

Calling .build() before every setter has run is a red squiggly line the moment it's typed — the error never survives to be run at all.

💻 Coding Challenges

Challenge 1: Build a Query-String Type

Write a template literal type QueryString<T extends Record<string, string>> that isn't required to reconstruct a literal string exactly, but instead write a type Join<Words extends string[], Sep extends string> that joins a tuple of string literals with a separator (e.g. Join<["a", "b", "c"], "-">"a-b-c").

Goal: Practice recursive template literal types that consume a tuple one element at a time.

→ Solution

Challenge 2: Extend ExtractParams

The chapter's ExtractParams only handles path params written as :name. Extend it (or write a variant) that also recognizes a trailing wildcard segment, e.g. "/files/*", producing a params object with a wildcard: string field.

Goal: Practice adding an additional template-literal pattern branch to a recursive extraction type.

→ Solution

Challenge 3: Add a Third Required Field to the Builder

Extend RequestBuilder with a third setter, headers(value: Record<string, string>), make it required for .build(), and confirm that omitting any one of the three setters makes .build() uncallable.

Goal: Practice extending the compile-time-tracked builder pattern to more required fields.

→ Solution

⚠️ Gotcha: Type-Level Code Still Has a Cost

Every recursive conditional type, every template literal expansion over a large union, is work the compiler has to redo on every keystroke in an editor and every build in CI. A type-level "program" that's clever but slow can make an entire team's editor lag on every file that imports it. Reach for these techniques where they prevent a real class of bugs (the route-param extractor, the tracked builder) — not as a default way to write ordinary types, where a plain interface would be faster to type-check and easier for the next person to read.

🎯 What's Next

Type-level programming so far has stayed inside code you control. The next chapter looks outward: Module Augmentation & Global Types — extending third-party library types you don't own, declaration merging, and safely adding to the global namespace.