Your Conditional Type Answered Twice. Both Answers Are Right.

A naked type parameter splits its union before the conditional runs, so IsArray<string[] | number> comes back as 'no' | 'yes'. Read out of TypeScript 7.0.2.

A machined steel shaft lying on a scratched bench plate, a triangular link plate splitting its drive between two separate gear trains, with a single brass bushing collar around the right-hand pin.

Here is a type test that works, checked against TypeScript 7.0.2:

ts
type IsArray<T> = T extends unknown[] ? 'yes' : 'no';
//   IsArray<string[] | number[]>  ->  'yes'

Two members, both arrays, one answer. Change one member and the same type answers twice:

ts
//   IsArray<string[] | number>  ->  'no' | 'yes'

Nothing about IsArray changed. It was never asking the question its author thought it was asking.

Read the type instead of hovering over it

Before any of this is worth arguing about, the types need to come out of the checker rather than out of a tooltip. The technique is one line: assign the type to something it cannot possibly be, and let the error message name it.

ts
declare function probe<T>(): T;
const a: 0 = probe<IsArray<string[] | number>>();
$ npx tsc --noEmit --strict --ignoreConfig probe.ts    # typescript 7.0.2
probe.ts(7,7): error TS2322: Type '"no" | "yes"' is not assignable to type '0'.

The diagnostic prints the computed type, in the terminal, where it can be pasted into an article. This is the same habit as reading what await compiles to rather than reasoning about it: ask the tool what it did.

The union is split before the conditional runs

When the checked type of a conditional is a bare type parameter, the checker applies the conditional to each union member separately and unions the results. That is the whole rule. string[] | number becomes two questions (is string[] an array, is number an array) and the answers 'yes' and 'no' come back as a union.

The homogeneous case works by accident. Both members answer 'yes', the union of 'yes' and 'yes' is 'yes', and the author concludes the type does what they wanted. It does not; it agreed with itself.

You can watch the split happen in a conditional that returns something built from T:

ts
type Boxed<T> = T extends unknown ? T[] : never;
//   Boxed<string | number>  ->  string[] | number[]

The true branch runs twice, once per member, and T is the member rather than the union. That is why this is the idiomatic way to map over a union — and it is also why Boxed<string | number> is not (string | number)[], which is what someone reading the source for the first time usually expects.

Bare is the operative word

Distribution is a property of the syntax, not of the type. Put the type parameter anywhere other than the checked position and it stops:

ts
type Wrapped<T> = { v: T } extends { v: unknown[] } ? 'yes' : 'no';
//   Wrapped<string[] | number>  ->  'no'

Here the checked type is { v: T }, an object type, not a naked parameter. No split. The checker asks one question about the whole union, { v: string[] | number } is not assignable to { v: unknown[] }, and the answer is 'no'.

The deliberate version of this is the tuple wrapper, and it is worth knowing as a switch rather than as a spell:

ts
type IsArrayTupled<T> = [T] extends [unknown[]] ? 'yes' : 'no';
//   IsArrayTupled<string[] | number>  ->  'no'

[T] is not a bare T, so nothing distributes, and the conditional finally asks what its name claims: is this whole thing an array type. Two characters on each side, and the answer changes from 'no' | 'yes' to 'no'.

never is the union with nothing in it

The rule has a consequence that reads like a bug the first time it costs you an afternoon:

ts
const c: IsArray<never> = 'yes';
probe2.ts(10,7): error TS2322: Type '"yes"' is not assignable to type 'never'.

IsArray<never> is never. Distribution iterates the members of the union; never is the union with no members; the iteration produces nothing, and nothing is never. The conditional’s branches are not evaluated at all — neither 'yes' nor 'no' is reachable.

The tuple wrapper flips it, and the flip is worth staring at:

ts
//   IsArrayTupled<never>  ->  'yes'

With distribution off, the checker asks whether [never] extends [unknown[]]. never is assignable to every type, so it is assignable to unknown[], so the tuple matches and the answer is 'yes'. The same input produces never, 'yes' or 'no' depending on two brackets.

boolean is the other type that surprises people here, for the same reason: it is true | false internally, so it distributes.

ts
type IsTrue<T> = T extends true ? 'T' : 'F';
//   IsTrue<boolean>  ->  'F' | 'T'

any is the third, and it is the one most likely to be in your codebase already:

ts
//   IsArray<any>         ->  'no' | 'yes'
//   IsArrayTupled<any>   ->  'yes'
//   IsArray<unknown>     ->  'no'

any is not a union, so this is a separate rule rather than the same one: a distributive conditional given any returns the union of both branches, because any is simultaneously assignable and not assignable to the check. A single untyped value entering a type-level helper turns every downstream answer into a union of everything the helper can say. unknown does not do this — it takes the false branch and stops.

What infer sees is a member, not the union

infer sits in the true branch, so it inherits whatever the checked type was split into:

ts
type ElementOf<T> = T extends (infer E)[] ? E : never;
//   ElementOf<string[] | number[]>     ->  string | number
//   ElementOf<(string | number)[]>     ->  string | number

Two different inputs, the same output, by two different routes. The first distributes: E is inferred as string for one member and number for the other, and the results are unioned at the end. The second does not distribute at all — there is one member, E is inferred once, and the union was already inside the array type.

They agree here. They stop agreeing the moment the true branch does anything other than return E unchanged, because in the first case the branch runs twice.

The standard library is built on this

Exclude and NonNullable are not compiler intrinsics. They are one-line conditionals that only work because of distribution:

ts
//   Exclude<'x' | 'y' | 'z', 'y'>              ->  'x' | 'z'
//   NonNullable<string | null | undefined>     ->  string

Exclude<T, U> is T extends U ? never : T. Each member is tested; the ones that match become never; never vanishes from a union. Remove the distribution, writing [T] extends [U] ? never : T, and Exclude stops filtering and starts answering a yes-or-no question about the whole union. The utility type is the rule wearing a name.

This is the type-level twin of what narrowing does to a value. Narrowing splits a union along control flow and reasons about one branch at a time; distribution splits a union along a conditional and evaluates one member at a time. Both leave you with a union at the join, and both surprise people at exactly that join.

What to do with this

When a conditional type gives an answer that is a union and you wanted one branch, the type parameter is bare and you did not want it to be. Wrap both sides in a tuple. When a conditional type gives never for an input you thought was ordinary, check whether the input is never before checking anything else.

One question worth settling before anyone blames the rewrite: the Go compiler computes the same types the old one did. Running every probe in this article through [email protected] and [email protected] gives identical results on every line but one, and that one is not a type difference:

probe3.ts(7,7)  7.0.2  Type '"no" | "yes"' is not assignable to type '0'.
probe3.ts(7,7)  5.9.3  Type '"yes" | "no"' is not assignable to type '0'.

Same union, printed in the other order, and only for the any case — the two compilers agreed on member order everywhere else. It matters to exactly one kind of reader: anyone snapshot-testing type output or diffing generated .d.ts files, who will see churn that means nothing.

What I did not test: conditional types that recurse, where distribution decides how many times the recursion runs and the instantiation-depth limit becomes reachable. Everything above was run with --strict on both compilers, and every type quoted is the checker’s own diagnostic rather than a hover tooltip.

FAQ

Frequently asked

How do I see what type TypeScript actually computed?

Assign the type to something it cannot be and read the error. `const probe: 0 = value as MyType` reports "Type X is not assignable to type 0", where X is the computed type. This works when hover output is truncated, and it leaves evidence in the terminal rather than in a tooltip.

Why does my conditional type return never for one input?

Almost always because the input is never. Distribution iterates the union members, never is the union with no members, so the loop produces nothing and the result is never. Wrapping the check in a tuple — [T] extends [U] — stops the distribution and gives you a real branch.

Arrow keys to move, Enter to open.