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:
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
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:
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.