DEV Community

Cover image for Distributive Conditional Types: Why T extends X Splits on Unions
Gabriel Anhaia
Gabriel Anhaia

Posted on

Distributive Conditional Types: Why T extends X Splits on Unions


You write a helper that should answer a single yes-or-no question about a type. Is T a string, or is it not? You expect true or false. The compiler hands you back boolean. You stare at it for a minute, then realize T was a union, and TypeScript ran your conditional once per member and joined the results.

That behavior has a name. Distributive conditional types. It is what makes Exclude work, what makes a careful IsString<T> answer true | false instead of boolean, and why library code wraps things in brackets when it needs to ask one question about a union as a whole.

This post walks the rule, three places it earns its keep, and the one foot-gun you fix with brackets.

The rule, in one sentence

When T is a naked type parameter, T extends X ? A : B evaluates per member of T and unions the results.

"Naked" means T appears alone on the left of extends, not wrapped in a tuple, an object, or any other constructor. The TypeScript handbook on distributive conditional types is the reference for the formal definition.

Here is the difference in code.

type IsStringNaked<T> = T extends string ? true : false;
type IsStringWrapped<T> = [T] extends [string] ? true : false;

type A = IsStringNaked<string | number>;
// true | false  (i.e. boolean)

type B = IsStringWrapped<string | number>;
// false
Enter fullscreen mode Exit fullscreen mode

A distributes. The compiler asks the question twice, once for string and once for number, and unions the answers. B does not distribute. The bracket on each side wraps T in a single-element tuple, so the compiler asks the question once about the tuple as a whole. [string | number] is not assignable to [string], so the answer is false.

That is the entire feature. Everything below is a consequence.

Why Exclude works

Open lib.es5.d.ts and find Exclude. The whole definition is one line.

type Exclude<T, U> = T extends U ? never : T;
Enter fullscreen mode Exit fullscreen mode

That looks too short to do anything. The work happens because T is naked, so the conditional fires once per union member. The branch evaluates to never for members that match U and to the member itself otherwise. never disappears when joined back into a union, so what remains is the members that did not match.

type Roles = "admin" | "editor" | "viewer" | "guest";

type Privileged = Exclude<Roles, "viewer" | "guest">;
// "admin" | "editor"
Enter fullscreen mode Exit fullscreen mode

Step through it once. The compiler distributes over Roles:

  • "admin" extends "viewer" | "guest" ? never : "admin" -> "admin"
  • "editor" extends "viewer" | "guest" ? never : "editor" -> "editor"
  • "viewer" extends "viewer" | "guest" ? never : "viewer" -> never
  • "guest" extends "viewer" | "guest" ? never : "guest" -> never

Union the four results, drop never, and you have "admin" | "editor". The same shape powers Extract, which keeps members instead of dropping them, and NonNullable, which excludes null | undefined from a union.

Most TS posts skip this part. People learn Exclude as a primitive and never see why it works. It works because conditional types distribute and never is the union identity. Those two rules and one line of library code carry every filtering pattern in the type system.

Filtering union members by predicate

Once you see the rule, the same pattern shows up everywhere a discriminated union needs slicing.

type AllEvents =
  | { kind: "click"; x: number; y: number }
  | { kind: "key"; code: string }
  | { kind: "scroll"; dy: number }
  | { kind: "focus"; target: string };

type ByKind<U, K> = U extends { kind: K } ? U : never;

type ClickEvent = ByKind<AllEvents, "click">;
// { kind: "click"; x: number; y: number }

type UserInputEvent = ByKind<AllEvents, "click" | "key">;
// { kind: "click"; x: number; y: number }
//   | { kind: "key"; code: string }
Enter fullscreen mode Exit fullscreen mode

ByKind is structural. The predicate is a shape and the helper is reusable across any discriminated union with a kind field. Distribution does the work. The compiler asks for each member of AllEvents whether it has the matching kind, keeps the ones that do, and replaces the rest with never.

This is what the runtime does when you write if (event.kind === "click"). The type system does the same narrowing, lifted out of control flow and applied to a generic.

Mapping each member of a union

Distribution is also how you transform every member of a union without writing a mapped type.

type Action =
  | { type: "add"; payload: { id: string; total: number } }
  | { type: "remove"; payload: { id: string } }
  | { type: "reset"; payload: undefined };

type Handler<A> =
  A extends { type: infer T; payload: infer P }
    ? (event: { type: T; payload: P }) => void
    : never;

type Handlers = Handler<Action>;
// ((event: { type: "add"; payload: { id: string; total: number } }) => void)
//   | ((event: { type: "remove"; payload: { id: string } }) => void)
//   | ((event: { type: "reset"; payload: undefined }) => void)
Enter fullscreen mode Exit fullscreen mode

The conditional fires once per Action member. infer T and infer P open slots the compiler fills from the matching shape, and the result branch builds a function type around them. Three input variants, three output function types, joined into a union the dispatcher can consume.

The same shape gives you ReturnType<T> for overload sets and Awaited<T> walking nested promise chains. Both are conditional types with infer on the slot the caller cares about, and both rely on distribution to hand back a union when the input is one.

The foot-gun: when you want to ask one question about the whole union

Distribution is the right default until you want to ask one question about T as a single thing.

type IsExactlyString<T> = T extends string ? true : false;

type X = IsExactlyString<"hello">;        // true
type Y = IsExactlyString<"hello" | 42>;   // boolean
type Z = IsExactlyString<string | number>; // boolean
Enter fullscreen mode Exit fullscreen mode

Y and Z should be false. They are boolean because the compiler distributes, finds one member that matches and one that does not, and unions true | false. The naked T is the bug.

Wrap both sides in a single-element tuple to turn distribution off.

type IsExactlyStringWrapped<T> = [T] extends [string] ? true : false;

type Xw = IsExactlyStringWrapped<"hello">;          // true
type Yw = IsExactlyStringWrapped<"hello" | 42>;     // false
type Zw = IsExactlyStringWrapped<string | number>;  // false
type Ww = IsExactlyStringWrapped<string>;           // true
Enter fullscreen mode Exit fullscreen mode

[T] is a tuple containing T. A tuple is a constructor, so T is no longer naked. The compiler treats the tuple as one thing, asks the question once, and gives you a single answer.

The same trick is how the standard library writes Equal checks for type-level tests, how an IsAny<T> helper asks whether T is exactly any, and how libraries that branch on whether a type is "a single thing" or "a union of things" stay correct.

type IsUnion<T, U = T> =
  T extends unknown
    ? [U] extends [T] ? false : true
    : never;

type U1 = IsUnion<string>;            // false
type U2 = IsUnion<"a" | "b">;         // true
type U3 = IsUnion<{ x: 1 } | { y: 2 }>; // true
Enter fullscreen mode Exit fullscreen mode

IsUnion uses both halves of the rule. The outer conditional distributes over T, so each member sees itself in the T slot. The inner [U] extends [T] does not distribute, so it asks "is the whole original union assignable to this single member". A non-union answers yes; a real union answers no.

You do not write IsUnion every day. You write conditional types that should ask one question about a union maybe once a quarter. When you do, the bracket trick is the fix.

When a conditional type comes back wider than you expected, look at the left of the extends. If it is naked, you are reading the union of every per-member answer. If you wanted one answer, put the brackets back.


If this was useful

Distributive conditional types are one of the load-bearing chapters in The TypeScript Type System. The book builds from generics through mapped and conditional types into the infer patterns that libraries like zod, Hono, and ts-pattern compose. If reading this made Exclude and the bracket trick click, the next chapters extend the same rule into recursive conditional types and the infer-on-tuple patterns zod uses to derive runtime schemas from types.

If you are coming from JVM languages, Kotlin and Java to TypeScript covers the same bridge with a focus on variance, null safety, and sealed-to-union translations. From PHP 8+, PHP to TypeScript covers the sync-to-async and generics jump from the other side. If you are shipping TS at work, TypeScript in Production covers the build, monorepo, and dual-publish concerns the type system itself does not touch.

The five-book set:

  • TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
  • The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
  • Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
  • PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
  • TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)