skip to content

In TypeScript, `type IsString<T> = T extends string ? true : false` gives `boolean` for `IsString<string | number>`. Explain that result and how you would make the check apply to the whole union instead.

level: middleimportance: should knowfreq 50%

answer

  1. boolean is not a failure result
  2. true | false is boolean
  3. two answers, not one
  4. wrap both sides, not one
  5. tuples suppress the member-by-member split

basics

~20 s

The conditional distributes over the union: string yields true, number yields false, and true | false is exactly how boolean is defined. Wrapping both sides in one-element tuples, [T] extends [string], disables distribution and returns false.

solid answer

~50 s

`IsString<string | number>` distributes, because the checked type is the naked parameter `T`. The compiler evaluates `string extends string ? true : false` and `number extends string ? true : false` separately, giving `true | false` — and `true | false` is precisely how `boolean` is defined, so the editor displays it as `boolean`. It is not a failed check; it is two answers unioned. To ask one question about the union as a whole, block distribution by wrapping both sides in one-element tuples: `[T] extends [string] ? true : false`. Now the compiler asks whether `[string | number]` is assignable to `[string]`, which it is not, so the answer is `false`. The mirror-image gotcha is passing `boolean` *in*: since `boolean` is the union `true | false`, it splits into two members and any distributive conditional over it returns both branches.

code

typescript · 9 lines
typescript
type IsString<T> = T extends string ? true : false;
type Distributed = IsString<string | number>; // true | false, shown as boolean

type IsStringWhole<T> = [T] extends [string] ? true : false;
type Whole = IsStringWhole<string | number>; // false
type AllStrings = IsStringWhole<'a' | 'b'>;  // true

const check: Whole = false;
const all: AllStrings = true;

go deeper

for a junior

Recognise that boolean and true | false are the same type, so a type that reports boolean is reporting both outcomes at once rather than erroring.

for a middle

Be ready to trace the substitution that produced both branches and to write the [T] extends [U] form from memory, including wrapping both sides rather than one.

for a senior

Expect to diagnose a helper that passes its single-type tests and widens in production, and to choose deliberately between per-member evaluation and a whole-union judgment when defining it.

for a principal

Own the convention: predicates that answer about a whole type should be non-distributive by default and named so callers know which they get, since a stray boolean result propagates quietly through downstream inference.

## Reading the result honestly When a type-level predicate returns `boolean` instead of `true` or `false`, the instinct is to assume the check broke. It didn't. `boolean` in TypeScript *is* the union `true | false` — the two boolean literal types unioned — and the checker collapses `true | false` back to the display name `boolean` whenever it sees it. So a `boolean` result from a predicate means the predicate produced **both** answers. Both answers appear because the conditional distributed: ```ts type IsString<T> = T extends string ? true : false; type R = IsString<string | number>; // IsString<string> | IsString<number> // true | false // boolean ``` Distribution kicks in whenever the checked type — the part written before `extends` — is a **naked type parameter** and the argument supplied for it is a union. TypeScript substitutes each member in turn, evaluates the conditional once per member, and unions the results. ## The opt-out: one-element tuples The fix is to make the checked type not be a naked parameter. Any wrapper technically works, but the idiomatic one is a one-element tuple, because it wraps the type without changing the assignability relationship you actually care about: ```ts type IsStringWhole<T> = [T] extends [string] ? true : false; type S1 = IsStringWhole<string>; // true type S2 = IsStringWhole<string | number>; // false type S3 = IsStringWhole<'a' | 'b'>; // true — both members are strings ``` Three details matter here. **Both sides get wrapped.** `T extends [string]` is a different question entirely — it asks whether `T` is a tuple. The tuple is a wrapper for the comparison, not part of the thing being compared, so it goes on both sides. **The answer is now about the union as a whole.** `IsStringWhole<'a' | 'b'>` is still `true`, because every member of that union is assignable to `string`, so the tuple `['a' | 'b']` is assignable to `[string]`. Blocking distribution does not mean "only single types pass"; it means one assignability question is asked about the entire type. **Parentheses do nothing.** `(T) extends (string)` is the same type as `T extends string`; parentheses are grouping syntax with no effect on nakedness. Only an actual wrapping construct — a tuple, an array, an object type — changes the position. ## The same trap in the other direction Because `boolean` is a union, it is also *split* when it goes in: ```ts type YesNo<T> = T extends true ? 'yes' : 'no'; type B1 = YesNo<true>; // 'yes' type B2 = YesNo<false>; // 'no' type B3 = YesNo<boolean>; // 'yes' | 'no' ``` This bites when a flag type is `boolean` rather than a specific literal — for example a helper that is supposed to pick a strict or loose result shape based on an options flag, which silently returns the union of both shapes for any caller who did not pass a literal. Two common repairs: block distribution with `[T] extends [true]`, or make the call site produce a literal type (an `as const` object, or a `T extends boolean` parameter inferred from a literal argument). ## Building an exact-equality check Once you can block distribution, an "is `T` exactly `U`" predicate follows from mutual assignability, still non-distributively: ```ts type IsExactly<T, U> = [T] extends [U] ? ([U] extends [T] ? true : false) : false; type E1 = IsExactly<string, string>; // true type E2 = IsExactly<'a', string>; // false type E3 = IsExactly<string | number, string>;// false ``` That is enough for most day-to-day use. It is deliberately not a perfect equality test — `any` in particular is assignable in both directions to everything, so it slips through — which is why library-grade equality helpers use more elaborate tricks. Knowing the limitation is more valuable in an interview than reciting the trick. ## Where this shows up in real code The usual sighting is a helper that behaves correctly in the tests, where it is always instantiated with a single concrete type, and then produces a uselessly wide type the moment a caller passes a union. A `boolean` where you expected `false`, a `'yes' | 'no'` where you expected one branch, a result type carrying properties from members that should have been excluded — those are all one diagnosis: the conditional distributed and you wanted it to judge the whole type. ## Erasure None of this survives compilation. `IsString` and `IsStringWhole` emit no JavaScript, and the tuple in `[T] extends [string]` never allocates an array — it exists only as a comparison wrapper inside the checker. The choice between distributing and not is purely about what type the compiler reports.

  • Why does `type YesNo<T> = T extends true ? 'yes' : 'no'` give `'yes' | 'no'` for `YesNo<boolean>`?
    Because `boolean` is not an atomic type — it is the union `true | false`. A naked `T` distributes over it, so the conditional runs once for `true` and once for `false`, yielding `'yes'` and `'no'` unioned. Blocking distribution with `[T] extends [true]` returns `'no'` instead, judging `boolean` as a whole.
  • Besides suppressing distribution, does tuple-wrapping change the assignability answer?
    For ordinary types, no — a one-element tuple's element position mirrors the direct comparison, so `[T] extends [U]` agrees with `T extends U` on single types. It does change the special-cased inputs: `never` and `any` no longer get their distribution-specific treatment, which is exactly why the wrapping is used to detect `never`.
  • How would you check that T is exactly `string`, not just assignable to it?
    Test assignability in both directions with distribution blocked: `[T] extends [string] ? ([string] extends [T] ? true : false) : false`. A literal like `'a'` fails the second test, and a union like `string | number` fails the first. It is not airtight — `any` passes both directions — but it covers normal cases.

saying these in an interview costs you the question

  • Reads a boolean result as the check having failed
  • Thinks true | false is a different type from boolean
  • Wraps only the left side in a tuple
  • Believes parentheses around T disable distribution
  • Assumes boolean is atomic and never splits

context