Skip to main content

TS Ep 42: Distributive Conditional Types

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
typescript - This article is part of a series.
Part 42: This Article
If you pass string | number into a conditional type, TypeScript does not evaluate the union as a single entity. It evaluates string and number individually, and then combines the resulting types back into a new union. This is called Distribution.

1. What is Distribution?
#

When a conditional type acts on a generic type parameter (like T), and that parameter is a naked union type (meaning it isn’t wrapped in an array, a promise, or an object), the conditional type distributes.

Let’s look at a type that wraps its input in an Array:

// The Conditional Type
type ToArray<T> = T extends any ? T[] : never;

// Let's pass a union of two primitives:
type MixedArray = ToArray<string | number>;

The “Logical” Expectation vs the Compiler’s Reality
#

If you evaluate this intuitively, you might expect the result to be (string | number)[] (an array where each element can be either a string or a number).

But because T is a naked generic parameter, TypeScript distributes.

Instead of doing this:

(string | number) extends any ? (string | number)[] : never

TypeScript evaluates it like this:

(string extends any ? string[] : never) | (number extends any ? number[] : never)

Which ultimately resolves to:

string[] | number[]

(An array of ONLY strings, OR an array of ONLY numbers!)


2. Rebuilding Utility Types: Exclude<T, U>
#

This distribution mechanism is exactly how the built-in Exclude<T, U> utility type works under the hood.

Let’s rebuild it ourselves to trace the compiler’s execution steps:

// If T is assignable to U, return never (delete it). 
// Otherwise, return T (keep it).
type CustomExclude<T, U> = T extends U ? never : T;

type AvailableColors = "red" | "green" | "blue";

// 🟢 We want to exclude "red" from the union
type CoolColors = CustomExclude<AvailableColors, "red">;

The Distribution Steps:
#

When TypeScript sees CustomExclude<"red" | "green" | "blue", "red">, it iterates over the union T:

  1. Iteration 1: ("red" extends "red" ? never : "red") ➡️ Evaluates to never
  2. Iteration 2: ("green" extends "red" ? never : "green") ➡️ Evaluates to "green"
  3. Iteration 3: ("blue" extends "red" ? never : "blue") ➡️ Evaluates to "blue"

TypeScript then recombines the results into a new union: Final Result: never | "green" | "blue"

Why does never disappear?
#

In TypeScript’s set theory, never represents the empty set. If you combine an empty set with other sets using a Union, the empty set simply vanishes. It acts exactly like adding zero in mathematics!

never | "green" | "blue" collapses perfectly down to "green" | "blue".


3. Why does TypeScript do this?
#

Distributive conditional types exist because they map perfectly to how functions behave with union types at runtime.

If you have a function that accepts string | number, you are technically saying “this function accepts a string, OR it accepts a number”. You are not passing a mystical “hybrid” string-number object.

Therefore, type-level transformations should apply to the individual underlying variants of the union, rather than attempting to transform the union as a monolithic concept.


Summary & Next Steps
#

In this episode:

  • We defined Distributive Conditional Types.
  • We saw how ToArray<string | number> evaluates to string[] | number[].
  • We traced the exact compiler steps used by the Exclude utility type.
  • We learned that never acts as the “Zero” of union types and vanishes when recombined.

But what happens when you don’t want a union to distribute? What if you genuinely want to evaluate (string | number) as a single monolithic block?

In Episode 43: Preventing Distribution, we will learn how to turn this behavior off!

typescript - This article is part of a series.
Part 42: This Article