13.3 Distributive Conditional Types and How to Disable Them
Right, so you’ve finally met conditional types. You’re feeling clever, building types that shift and change like magic. And then you hit this. You write what looks like a perfectly reasonable type, pass it a union, and suddenly it’s like your type has been put through a blender. You get back a union of results instead of the single, unified type you expected. Welcome to the world of distributive conditional types. It’s the language’s most common “gotcha,” and it’s about to make a lot more sense.
Here’s the thing: TypeScript isn’t being difficult on purpose. This behavior is actually a feature, not a bug. It’s designed this way for a very good reason. When you write a conditional type where the type you’re checking (the part before extends) is a naked type parameter, TypeScript automatically distributes that condition over each member of a union.
Let’s look at the classic, almost-too-simple example that makes everyone’s head spin at least once.
type ToArray<T> = T extends any ? T[] : never;
// What you might naively expect:
type Result = ToArray<string | number>; // (string | number)[]
// What you *actually* get:
type ActualResult = ToArray<string | number>; // string[] | number[]
See that? We didn’t get an array that can hold both strings and numbers (string | number)[]. We got a union of array types: string[] | number[]. This is distribution in action. It’s as if TypeScript did this:
ToArray<string | number>
// becomes...
ToArray<string> | ToArray<number>
// which becomes...
(string extends any ? string[] : never) | (number extends any ? number[] : never)
// which becomes...
string[] | number[]
This is incredibly useful! It’s the mechanism that makes types like Exclude and Extract possible. You’re essentially mapping over each element of the union.
Why in the world would I want to disable this?
Because sometimes you don’t want to map over the union. Sometimes you want to treat the entire union as a single, monolithic type. The most common use case is when you’re doing an infer extraction inside a conditional type. Distribution will often pull the rug out from under you.
Imagine you’re trying to write a type that gets the return type of a function, but also handles unions of functions.
type ReturnTypeUnion<T> = T extends (...args: any[]) => infer R ? R : never;
declare function getString(): string;
declare function getNumber(): number;
type UnionOfReturns = ReturnTypeUnion<typeof getString | typeof getNumber>;
// Evaluates to: string | number
This works perfectly. But watch what happens if we try to get the return type of a function that itself returns a union.
declare function getStringOrNumber(): string | number;
type SingleReturn = ReturnTypeUnion<typeof getStringOrNumber>;
// You want: string | number
// You get: string | number
// It works! So what's the problem?
It works here because T is a single function type, not a union. The distribution doesn’t trigger. The problem arises when your conditional type becomes more complex and you need to prevent distribution to avoid a double-union scenario.
The trick: boxing your type parameter
So how do you turn this “feature” off? You break the rule for distribution. Remember, distribution only happens if the type being checked is a naked type parameter. The solution is to clothe it. Put it in a tuple, an array, a promise, or any other surrounding type. This makes it no longer “naked.”
The most common and elegant pattern is to use a tuple:
type ToArrayNonDistributive<T> = [T] extends [any] ? T[] : never;
type Result = ToArrayNonDistributive<string | number>;
// Type is (string | number)[]
Boom. Distribution disabled. By wrapping T in [T] and checking if it extends [any], we’ve sidestepped the automatic behavior. We’re now asking “Does the tuple type [T] extend the tuple type [any]?” Since [any] is a very broad tuple type, the condition is true, and we return T[], where T is the full, untouched union string | number.
Let’s apply this to a more realistic example. Let’s say you want a type that checks if a type is exactly never. You can’t just do T extends never because that distributes over a union, and never is the empty union, so it… does nothing. It’s weird.
type IsNever<T> = T extends never ? true : false;
type X = IsNever<never>; // type X = never
Well, that’s useless. The distribution has nowhere to go, so the whole type evaluates to never. Let’s box it:
type IsNeverFixed<T> = [T] extends [never] ? true : false;
type X = IsNeverFixed<never>; // type X = true
type Y = IsNeverFixed<string>; // type Y = false
Perfect. Now it works because we’re checking the entire construct, not distributing over its (non-existent) members.
The key takeaway is this: distribution is your friend 90% of the time. It’s what gives conditional types their power over unions. But for that other 10%, when you need to treat the union as a single entity, remember the boxing trick. Wrap your type parameter in a tuple [T] to make it play by your rules. It’s the difference between mapping over a list and looking at the whole list at once. And now you know how to do both.