# 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.

- Published: 2026-09-27
- Tags: typescript
- Source: https://jsledger.com/blog/conditional-types-distribute/
- Language: en-US
- Author: Jonah Vail

---
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](/blog/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](/blog/type-level-narrowing-in-typescript/). 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 `typescript@5.9.3` and `typescript@7.0.2` 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.
