DEV Community

Cover image for Distributive Conditional Types: The Gateway to Advanced TS
Gabriel Anhaia
Gabriel Anhaia

Posted on

Distributive Conditional Types: The Gateway to Advanced TS


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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

[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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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 }
// }
Enter fullscreen mode Exit fullscreen mode

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.

The TypeScript Library — the 5-book collection

Top comments (0)