Type-Level Programming
๐งฎ Type-Level Programming
.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.
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.
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.
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.