skip to content

In TypeScript, why does `type Check<T> = T extends string ? 'yes' : 'no'` evaluate `Check<never>` to `never`, and how do you write a type that reports whether T is `never`?

level: seniorimportance: should knowfreq 34%

answer

  1. think empty set, not error value
  2. zero members means zero evaluations
  3. branches are not entered at all
  4. the direct check answers differently
  5. tuple wrapper makes it visible

basics

~20 s

never is the empty union, and a distributive conditional maps itself over each union member. With zero members there is nothing to map, so the result is the empty union again: never. Detect it non-distributively with [T] extends [never].

solid answer

~50 s

`never` is the empty union — a union with no members at all. A distributive conditional works by mapping itself over each member of the union it receives, so with zero members there is nothing to evaluate and the result is the empty union again, `never`. Neither branch runs. That behaviour is specific to distribution: written directly with no type parameter, `never extends string ? 'yes' : 'no'` is `'yes'`, because `never` is assignable to every type. So to ask whether `T` is `never` you must block distribution: `type IsNever<T> = [T] extends [never] ? true : false`. The naive `T extends never ? true : false` is the classic broken version — for `T = never` it returns `never` rather than `true`, and callers then see a downstream type collapse with no obvious cause.

code

typescript · 10 lines
typescript
type Check<T> = T extends string ? 'yes' : 'no';
type Collapsed = Check<never>;   // never — no member to evaluate
type Direct = never extends string ? 'yes' : 'no'; // 'yes'

type IsNever<T> = [T] extends [never] ? true : false;
type A = IsNever<never>;  // true
type B = IsNever<string>; // false

const a: A = true;
const b: B = false;

go deeper

for a junior

Know that never means no possible value, and that seeing it as a result usually signals something was filtered away entirely rather than an error being thrown.

for a middle

Be ready to explain the empty-union arithmetic — zero members, zero evaluations — and to contrast it with the direct never extends string check that returns the true branch.

for a senior

Expect to trace a never that surfaces far from its cause through a chain of helpers, and to place an explicit non-distributive guard so the empty case is named rather than silently propagated.

for a principal

Own the failure mode across a shared type library: an uninhabited type spreads without an error at the definition site, so require empty-union cases in type-level tests and pick a documented fallback rather than letting collapse be the default.

## `never` is a union with no members The cleanest way to hold this is as set theory. A union type is a set of alternatives. `string | number` has two members. `string` has one. `never` has zero — it is the empty union, the type with no possible values, which is why nothing is ever assignable *to* it and why it is assignable *from* nowhere but itself. Distribution is defined as: substitute each member of the union into the conditional, evaluate, union the results. Apply that to a union with zero members and the arithmetic is unavoidable — zero evaluations, zero results, and the union of nothing is `never`: ```ts type Check<T> = T extends string ? 'yes' : 'no'; type R1 = Check<string>; // 'yes' type R2 = Check<number>; // 'no' type R3 = Check<string | number>; // 'yes' | 'no' type R4 = Check<never>; // never — no branch ran ``` The important part of that trace is the last line: the branches were not evaluated and then discarded, they were never entered. `Check<never>` cannot be `'no'` no matter what the condition says. ## The contrast that proves it is distribution The behaviour belongs to distribution, not to `never` itself. Remove the type parameter and the answer flips: ```ts type Direct = never extends string ? 'yes' : 'no'; // 'yes' ``` Here no substitution happens, so the compiler simply asks whether `never` is assignable to `string`. It is — `never` is the bottom type, assignable to everything — so the true branch is taken. Same with a blocked conditional: ```ts type Blocked<T> = [T] extends [string] ? 'yes' : 'no'; type R5 = Blocked<never>; // 'yes', because [never] is assignable to [string] ``` A candidate who can produce both contrasting results has genuinely understood the mechanism rather than memorised a fact. ## Writing `IsNever` The naive predicate fails exactly where you need it: ```ts type NaiveIsNever<T> = T extends never ? true : false; type N1 = NaiveIsNever<string>; // false type N2 = NaiveIsNever<never>; // never — not true, and not even a boolean ``` Wrap both sides in one-element tuples to suppress distribution and the predicate works: ```ts type IsNever<T> = [T] extends [never] ? true : false; type N3 = IsNever<never>; // true type N4 = IsNever<string>; // false type N5 = IsNever<string | never>; // false — never vanishes, leaving string ``` That last line is a second consequence worth internalising: `never` absorbs into any union it is written into. `string | never` simplifies to `string` before anything else looks at it, because adding the empty set to a set changes nothing. ## Where this actually bites The empty-union rule turns up as a debugging problem far more often than as a quiz question, because `never` is what filtering produces when nothing matches: ```ts type OnlyStrings<T> = T extends string ? T : never; type Filtered = OnlyStrings<1 | 2>; // never — everything was filtered out ``` A helper chain then propagates that `never` silently. Every downstream distributive conditional receives `never`, returns `never` without complaint, and the first visible symptom is an error hundreds of lines away where a value cannot be assigned to a parameter of type `never`. Nothing errors at the definition site; the type just quietly becomes uninhabited. The defensive shape is to name the empty case explicitly at the top of a helper, rather than letting it fall through the pipeline: ```ts type Describe<T> = [T] extends [never] ? 'nothing to describe' : T extends string ? 'string member' : 'other member'; ``` The outer check is deliberately non-distributive so it can see the empty union at all; the inner one is naked so it still maps over real members. ## Two related edge inputs `any` is the other special instantiation: a distributive conditional given `any` returns the union of *both* branches. So a helper that starts reporting both outcomes has usually been handed an `any`, while a helper that collapses to `never` has usually been handed an empty union. Recognising which symptom means which is most of the debugging. ## Erasure As always in the type layer, none of this exists at runtime — `never`, the tuple wrappers and the conditionals all disappear on emit. `never` is a statement about which values are possible, checked by the compiler, and it never becomes a value you can inspect at run time.

  • Is `never extends string ? true : false` also `never`?
    No — it is `true`. Written that way there is no type parameter to substitute, so no distribution occurs and the compiler simply asks whether `never` is assignable to `string`. It is, since `never` is the bottom type assignable to everything, so the true branch wins. Only the distributive form collapses to `never`.
  • How would you stop a helper from silently collapsing when someone passes `never`?
    Guard the empty case first, non-distributively: `type H<T> = [T] extends [never] ? Fallback : (T extends ... )`. The outer tuple check is the only form that can observe an empty union; the inner naked conditional still maps over real members. That converts a silent collapse into an explicit, documented result.
  • Why does `string | never` show as `string`?
    Because `never` is the empty union, and unioning the empty set into a set adds nothing. TypeScript normalises it away immediately, so `never` members are absorbed. That is also why the filtering idiom works: the members you map to `never` do not appear as `never` entries in the result, they simply disappear.

saying these in an interview costs you the question

  • Claims never extends string is false
  • Expects Check<never> to take the false branch
  • Uses T extends never ? true : false as a never test
  • Treats never as a runtime value similar to null
  • Thinks a union can contain never as a visible member

context