Type-Level Programming
Prerequisite: this module builds on Conditional Types and Inference and infer — read those first if you have not, since every lesson here assumes you already know what a conditional type and infer are.
Types are a language
Section titled “Types are a language”So far types have described data. In this module they start to compute over it. Given one type you can derive another: make every field optional, pick a subset of keys, extract the return type of a function, or parse a string into a union — all at the type level, evaluated by the compiler before your code runs.
This is real programming. The type system has variables (type parameters), branching (conditional types), iteration (mapped types and recursion), and functions (generic type aliases). People have implemented arithmetic and even parsers purely in types. You will not do that at work — but understanding the machinery is what lets you read library types and write the handful of clever types that genuinely pay off.
What this module covers
Section titled “What this module covers”| Lesson | What you’ll learn |
|---|---|
| Mapped types | Iterate over a type’s keys to transform it — build Partial and Readonly yourself |
| keyof & indexed access | keyof T and T[K] — the lookups that power everything else |
| Template literal types | Compute string types, and pattern-match strings with infer |
| The utility types | The built-ins, and how they are actually implemented |
| Recursive types | Types that call themselves — and where the compiler draws the line |
The building blocks fit together
Section titled “The building blocks fit together”flowchart TB keyof["keyof T (get the keys)"] --> mapped["Mapped types (iterate the keys)"] indexed["T[K] (look up value types)"] --> mapped cond["Conditional types + infer (branch and capture)"] --> util["Utility types (Pick, Exclude, ReturnType)"] mapped --> util tmpl["Template literals (compute strings)"] --> util util --> rec["Recursion (repeat until done)"]
Almost every advanced type in the wild is a combination of five primitives: keyof to get keys, indexed access to look up value types, mapped types to iterate, conditional types with infer to branch and capture, and recursion to repeat. Learn these five and library .d.ts files stop being hieroglyphics.
Type-level testing: proving what a type resolved to
Section titled “Type-level testing: proving what a type resolved to”Every claim in this module — “Foo<Bar> resolves to Baz” — needs to be checked, not eyeballed. // ^? comments are a hint for a human reading a diff; they are not something tsc verifies. The standard way to make a type-level claim into something the compiler actually checks is two tiny type aliases:
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
// If Equal<A, B> is not literally `true`, this line fails to compile.type Case1 = Expect<Equal<1 | 2, 1 | 2>>;Expect<T extends true> is a type parameter with a constraint: you can only instantiate it with something assignable to true. Feed it anything that is not the literal true — false, or the wider boolean — and the compiler rejects it with TS2344. That rejection is the test failing. A type-level test suite is nothing more than a file full of Expect<Equal<..., ...>> lines that must all compile.
Why the obvious Equal is wrong
Section titled “Why the obvious Equal is wrong”The tempting way to write Equal is “each side extends the other”:
type EqualNaive<A, B> = A extends B ? (B extends A ? true : false) : false;This looks reasonable and passes every simple test you would think to write by hand. It falls apart on exactly the two cases that matter most: any, and unions.
type EqualNaive<A, B> = A extends B ? (B extends A ? true : false) : false;
// EqualNaive<any, string> SHOULD be false -- any and string are not the same// type -- but both of these compile with zero errors:type NaiveAnyString = EqualNaive<any, string>;const proof1: NaiveAnyString = true;const proof2: NaiveAnyString = false;Both assignments type-check, which is only possible if NaiveAnyString is boolean — not a decidable true or false at all. any extends string and string extends any are both true (that is precisely what makes any unsound), so the naive definition cannot tell “equal” apart from “one side is any.”
Unions break it for a different reason — the naive form’s own extends checks distribute over a naked union, silently re-running the comparison member-by-member instead of once:
type EqualNaive<A, B> = A extends B ? (B extends A ? true : false) : false;
// The exact same type compared to itself. This has to be `true`.type NaiveSame = EqualNaive<string | number, string | number>;const proof3: NaiveSame = true;const proof4: NaiveSame = false;Again both compile — string | number compared against itself resolves to boolean, not true, because A extends B distributes over A’s members while B is still the whole union, and B extends A inside each branch distributes again over B. Two identical types produce an undecidable answer.
The correct Equal — the (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false form — sidesteps both failures by never putting A or B in a checked position directly. Each side is wrapped in a generic function type first, and function types are compared as a whole (not distributed, and any does not get the special-cased both-branches treatment it gets when it is itself a naked checked type). This is the form this course’s own verify:fences harness, and every Equal/Expect pair you have already seen in earlier lessons, actually use.
The naive Equal collapses to boolean; the real one does not
type EqualNaive<A, B> = A extends B ? (B extends A ? true : false) : false;type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
// naive: both compile -- proof that the result is `boolean`, not a real answertype NaiveAnyString = EqualNaive<any, string>;const naive1: NaiveAnyString = true;const naive2: NaiveAnyString = false;
// correct: any is genuinely not string -- only `false` is assignabletype CorrectAnyString = Equal<any, string>;const correct1: CorrectAnyString = false;// const correct2: CorrectAnyString = true; -- would be a real TS2322 error
// naive: the exact same union compared to itself also collapses to booleantype NaiveSame = EqualNaive<string | number, string | number>;const naive3: NaiveSame = true;const naive4: NaiveSame = false;
// correct: two identical types resolve to the literal `true`type CorrectSame = Equal<string | number, string | number>;const correct3: CorrectSame = true; @expect-error as the assertion
Section titled “@expect-error as the assertion”The other half of this course’s harness — // @expect-error TSxxxx — is itself a type-level test, just aimed at the compiler’s diagnostics instead of a resolved type. A fence that intentionally should not compile declares which error code it expects on line one; verify:fences fails the build if that code does not appear, or if any other code appears alongside it. Combined with Expect, a single file can assert both “this resolves to exactly this type” and “this is intentionally a compile error”:
// @expect-error TS2344type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;type Expect<T extends true> = T;
// passes: two identical unions really are equaltype CaseOk = Expect<Equal<string | number, string | number>>;
// intentionally wrong -- any is NOT string, so Equal<any, string> is `false`,// and Expect<false> is a TS2344 constraint violation. The failure to compile// is the test catching the (deliberately) incorrect claim.type CaseWrong = Expect<Equal<any, string>>;This is the smallest version of what Type-Level Testing covers in full — writing a real type-level test suite, organizing Expect cases per utility, and reading the constraint-violation error as a failed assertion rather than a bug in your code. Every Equal/Expect pair used earlier in this course (Conditional Types, Inference & infer) and everywhere ahead in this module is this exact pattern.
Mental Model
Section titled “Mental Model”flowchart TD
claim["Claim: Foo of Bar resolves to Baz"] --> wrap["Expect of Equal of the two types"]
wrap --> compiles{"Does it compile?"}
compiles -->|yes| holds["Claim holds -- Equal produced the literal true"]
compiles -->|no, TS2344| wrong["Claim is false -- Equal produced false, or boolean"] Think of Expect<Equal<A, B>> as a runtime assert(a === b), just evaluated by the type checker instead of node. A green build is the passing test suite. // ^? comments stay useful as a reading aid, but they prove nothing by themselves — only a red squiggle (or its absence) is verified.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
EqualNaive (A extends B ? (B extends A ? ...)) | Reads intuitively, no exotic syntax | Collapses to boolean — not a decidable answer — for any and for unions compared to themselves; the two cases you most need a type test to get right |
The function-type Equal | Correctly rejects any and handles unions; the form every real type-testing library (tsd, expect-type, this course’s harness) ships | Looks unmotivated until you have seen it fail on the naive form — worth keeping the comment explaining why |
Expect<T extends true> + @expect-error | Turns a resolved-type claim into a real compiler error, checked on every run | Only as good as the Equal it wraps — a broken Equal produces false confidence, not a bug report |
Common Misconceptions
Section titled “Common Misconceptions”// ^?comments in this course’s lessons are checked by the compiler. They are not — they are a note for a human reader. OnlyExpect<Equal<...>>(or an intentional@expect-error) is actually verified bytsc.A extends B ? (B extends A ? true : false) : falseis a correct, just slightly clunkier, way to writeEqual. It is not slightly wrong — it is completely undecidable for two of the most common cases a type test needs to cover:anyon either side, and comparing a union to itself.- A type-level test that compiles must mean the types really are equal. It means
Equal(whichever definition you used) producedtrue. IfEqualitself is the naive, broken version, a “passing” test can hide a real difference — the correctness ofEqualis not optional, it is the entire test.
Reading AI-generated code
Section titled “Reading AI-generated code”Generated type-level utilities routinely ship without any test proving they resolve to what their name claims — and when a test does exist, it is worth checking which Equal it uses:
- A hand-rolled
Equal<A, B>usingA extends B ? (B extends A ? true : false) : falsecompiles fine and looks like it works on the examples in the same file — the failure only shows up onanyor on two identical unions, exactly the cases least likely to appear in a quick smoke test. - A type utility with zero
Expect/Equalprobes anywhere near it is trusting// ^?comments (or nothing at all) as proof — ask what actually verifies the claim in the PR description. @ts-expect-error(not@ts-ignore) on a type-level test that is supposed to fail is correct and intentional — the opposite of the@ts-ignoresmell covered in the glossary; check that the file still fails loudly if the code stops being wrong.
🐛 Spot the bug
type IsEqual<A, B> = A extends B ? (B extends A ? true : false) : false;type Expect<T extends true> = T;
// "prove" that a generic cache key helper only ever returns string keystype CacheKey<T> = T extends string | number ? `key:${T}` : never;
type Test1 = Expect<IsEqual<CacheKey<any>, never>>;Show the answer
This compiles with zero errors, but it proves nothing about CacheKey<any>. IsEqual<CacheKey<any>, never> uses the naive, mutual-extends definition — and because any is on the left, CacheKey<any> extends never and never extends CacheKey<any> are both true regardless of what CacheKey<any> actually resolves to (any short-circuits both directions of a naive equality check the same way it did for EqualNaive<any, string> above). The “test” would pass even if CacheKey<any> were string, boolean, or anything else — it is not exercising the claim at all. Swapping in the real Equal (the function-type form) makes the test meaningful: Equal<CacheKey<any>, never> correctly resolves to false, because CacheKey<any> is actually `key:${any}` collapsing to any-flavored string, not never — which is itself useful information the naive check was hiding.