- Book: The TypeScript Type System
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open a fresh playground tab. You type type R = Exclude<"a" | "b" | "c", "b">. The tooltip says "a" | "c". You nod, paste it into the codebase, and move on. You have used Exclude for years and never once written it.
Ask yourself how Exclude works and the answer is some shape of "it removes things from a union." That answers what Exclude does. It does not answer how. The how is a single rule about conditional types the tooltip never shows you. Once you see the rule, half of the standard library starts looking ordinary. The conditional types chapter of the handbook reads like a different language.
The rule is small. The consequences are large.
Conditional Types Are the Boring Part
Start at the floor. A conditional type in TypeScript is a ternary at the type level.
type IsString<T> = T extends string ? true : false
type A = IsString<"hi"> // true
type B = IsString<42> // false
type C = IsString<boolean> // false
Read it the way you read a runtime ternary. If T is assignable to string, the type is true; otherwise it is false. No surprises. The interesting part is what happens when you hand IsString a union.
type D = IsString<"hi" | 42>
// ^? type D = boolean
You see boolean and assume the compiler picked one branch. It did not. boolean here is true | false, the union of both branches. IsString ran twice, once per member, and returned the union of the results.
That second run has a name: distribution. The rule behind it is precise.
The Rule, Stated Once
Stated as plainly as it gets: when the checked type of a conditional type is a naked type parameter, and the type passed in is a union, the conditional distributes over the members of the union.
Three pieces in that sentence. Pull them apart.
Naked. The type parameter appears bare on the left of extends. T extends U is naked. [T] extends [U] is not. { x: T } extends { x: U } is not. Wrap T in anything and distribution turns off.
Type parameter. It only distributes for a parameter. A fixed type does not trigger distribution. "a" | "b" extends string ? X : Y does not distribute. The left side is a fixed union; the conditional needs a generic that received a union.
Union. Distribution only fires when the type passed in actually is a union. A single type runs the conditional once, like any other ternary.
Every behavior in the rest of this post is a corollary.
Build MyExclude From Scratch
The fastest way to internalize distribution is to write Exclude yourself. Here is the standard-library definition, almost verbatim.
type MyExclude<T, U> = T extends U ? never : T
type R = MyExclude<"a" | "b" | "c", "b">
// ^? type R = "a" | "c"
Three lines. Nothing else. Walk through it.
T is "a" | "b" | "c". U is "b". Because T is a naked type parameter and the input is a union, distribution kicks in. The compiler runs the conditional once per member.
"a" extends "b" ? never : "a" → "a"
"b" extends "b" ? never : "b" → never
"c" extends "b" ? never : "c" → "c"
Then it unions the results: "a" | never | "c". never is the absorbing element of union (X | never collapses to X), so the answer is "a" | "c".
That is Exclude. The same three lines, with the branches swapped, give you Extract.
type MyExtract<T, U> = T extends U ? T : never
type S = MyExtract<"a" | "b" | "c", "a" | "b">
// ^? type S = "a" | "b"
NonNullable<T> is the same idea with null | undefined hard-coded as the thing to remove.
type MyNonNullable<T> = T extends null | undefined ? never : T
Read those three next to each other and the standard library starts looking like a thin layer over one rule.
Why Pick And Omit Care
Pick and Omit look like they are doing something different. They operate on an object. The union is hidden inside the keys, and distribution still shows up there.
type MyPick<T, K extends keyof T> = {
[P in K]: T[P]
}
type MyOmit<T, K extends keyof T> = MyPick<T, Exclude<keyof T, K>>
The mapped type [P in K] iterates over each member of the union K. Exclude<keyof T, K> removes the keys you do not want, distributing over the union of property names. Replace Exclude with a non-distributing variant and MyOmit returns the wrong thing. The standard library leans on distribution at every step where keys, members, or variants need to be filtered.
The Wrapping Trick
You will eventually need a conditional that does not distribute. Two scenarios are common.
The first is checking if a generic is exactly never. never is the empty union, and distribution over an empty union is also empty.
type IsNever<T> = T extends never ? true : false
type X = IsNever<never>
// ^? type X = never
That is not what you wanted. The conditional distributed over zero members and produced zero results. The fix is the wrapping trick — put the type parameter inside a one-tuple.
type IsNever<T> = [T] extends [never] ? true : false
type Y = IsNever<never> // true
type Z = IsNever<string> // false
[T] is no longer a naked type parameter. Distribution turns off. The conditional runs once with the tuple as a whole, and [never] extends [never] is true.
The second scenario is checking equality of two types when one of them might be a union. Without wrapping, distribution will compare each member separately and you will get the wrong answer.
type Equal<A, B> = A extends B ? (B extends A ? true : false) : false
type Bad = Equal<"a" | "b", "a">
// ^? type Bad = boolean
The compiler distributed A over the outer conditional. "a" extends "a" was true, "b" extends "a" was false, the union of branches is true | false, which is boolean. That is a useless answer for an equality check. Wrap both sides.
type Equal<A, B> =
[A] extends [B] ? ([B] extends [A] ? true : false) : false
type Good = Equal<"a" | "b", "a"> // false
type Same = Equal<"a" | "b", "a" | "b"> // true
Wrapping is a switch. T extends U is the distributing version; [T] extends [U] is the holistic version. Pick on purpose, every time.
When You Want Distribution, When You Don't
The choice maps to intent.
You want distribution when the input is a union and you want to do something to each member independently. Filtering, mapping each branch, partitioning. Anything where the answer for A | B should be the answer-for-A unioned with the answer-for-B.
You want to disable distribution when the union should be treated as a single value. Equality checks. Tests for never. Cases where you are reasoning about the union as a whole, not its members.
The mistake is mixing the two without noticing. A type alias that distributes by accident produces unions where you expected scalars; one that fails to distribute produces single values where you expected unions. Both bugs are silent. When the result of a conditional surprises you, the first question is did this distribute, and did I want it to.
Four Patterns You Will Use
Distribution is a tool you reach for. Four patterns show up in production code.
Filter
The same shape as Exclude and Extract, applied to whatever predicate you need. Pull out only the function-typed members of a union, only the string-keyed properties, only the variants of a discriminated union that match a tag.
type FunctionsOnly<T> = T extends (...args: any[]) => any ? T : never
type Mixed = string | number | (() => void) | ((x: number) => string)
type Fns = FunctionsOnly<Mixed>
// ^? type Fns = (() => void) | ((x: number) => string)
Distribution runs the predicate per member. The non-matching branches collapse to never. The result is a clean, filtered union.
Transform
Map each member of a union to something else and union the results. The classic example is Promise-flattening, but any per-member transformation works.
type Boxed<T> = T extends infer U ? { value: U } : never
type B = Boxed<string | number>
// ^? type B = { value: string } | { value: number }
Without distribution, { value: string | number } is a single object whose value is either a string or a number. With distribution, { value: string } | { value: number } is a union of two distinct shapes. The second is what you want when each branch of the original union has its own meaning.
Partition
Split a union into two unions: the members that match a predicate and the members that do not. Extract and Exclude working together.
type Partition<T, U> = {
matched: Extract<T, U>
rest: Exclude<T, U>
}
type Events =
| { kind: "click"; x: number }
| { kind: "key"; code: string }
| { kind: "scroll"; y: number }
type P = Partition<Events, { kind: "click" | "scroll" }>
// matched: { kind: "click"; x: number } | { kind: "scroll"; y: number }
// rest: { kind: "key"; code: string }
Partition lets you describe two related but distinct subsets of a union in one type. Useful for routing, handler tables, and any case where the type system needs to know which events flow down which path.
Exhaustive Switch Over A Discriminated Union
The pattern that makes distribution feel like a programming language. Take a discriminated union and produce, per variant, a different return type — then let the compiler check exhaustiveness for you.
type Event =
| { kind: "click"; x: number; y: number }
| { kind: "key"; code: string }
| { kind: "scroll"; delta: number }
type Handler<E extends Event> =
E extends { kind: "click" } ? (e: E) => { hit: boolean } :
E extends { kind: "key" } ? (e: E) => { handled: boolean } :
E extends { kind: "scroll" } ? (e: E) => { stop: boolean } :
never
type Handlers = { [K in Event["kind"]]: Handler<Extract<Event, { kind: K }>> }
// {
// click: (e: { kind: "click"; x: number; y: number }) => { hit: boolean }
// key: (e: { kind: "key"; code: string }) => { handled: boolean }
// scroll: (e: { kind: "scroll"; delta: number }) => { stop: boolean }
// }
Extract<Event, { kind: K }> distributes over Event, picks the variant whose kind matches K, and hands Handler a precise per-variant input. Add a fourth variant to Event and forget to extend Handler, and the compiler reports never on that key. The type system carries the exhaustiveness check.
That is the real payoff. Once distribution is a tool you reach for, you write logic at the type level instead of just labeling variables.
Forward Motion
Open a TypeScript file you wrote last month. Find the place where you imported Pick, Omit, or Exclude. Replace one of them with a hand-written conditional that does the same thing. Hover the alias. The mechanic that used to feel like a library feature is now four lines you wrote yourself.
After that, the conditional types page of the handbook stops being a wall. The next chapter is infer, template literal types, and recursion at the type level. That is where the type system starts paying back the time you put in. Distribution is the door.
If this was useful
Distribution is one rule out of the small handful that make TypeScript's type system feel like a programming language. The TypeScript Type System is the deep-dive book in The TypeScript Library. The conditional-types chapter sits next to chapters on mapped types, infer, template literal types, branded types, and the patterns that turn a working type system into a domain-modeling tool.
- TypeScript Essentials — entry point if you are a working developer who wants to feel confident across Node, Bun, Deno, and the browser: Amazon
- The TypeScript Type System — the deep-dive on generics, mapped and conditional types, infer, template literals, and brands: Amazon
- Kotlin and Java to TypeScript — the bridge for JVM developers, variance, null safety, sealed-to-unions, coroutines-to-async: Amazon
- PHP to TypeScript — the bridge for PHP 8+ developers, sync-to-async paradigm, generics, discriminated unions: Amazon
- TypeScript in Production — tooling, build, monorepos, library authoring across runtimes, dual ESM/CJS, JSR: Amazon
If you are picking up the language, start with Essentials. If you came from JVM or PHP, start with the bridge that matches you and add The Type System once you want to push further. Production is the one anyone shipping TypeScript at work will end up reading.
All five books ship in ebook, paperback, and hardcover.

Top comments (0)