skip to content

In TypeScript, what do the built-in utility types `Exclude<T, U>`, `Extract<T, U>` and `NonNullable<T>` produce when you apply them to a union type?

level: juniorimportance: must knowfreq 72%

answer

  1. set operations over union members
  2. difference, intersection, drop nullish
  3. assignability decides, not equality
  4. rejected members become never
  5. erased — no runtime filtering

basics

~10 s

They are set operations over unions. Exclude<T, U> drops every member of T assignable to U, Extract<T, U> keeps only those members, and NonNullable<T> removes null and undefined from T.

solid answer

~40 s

All three filter the members of a union at compile time. `Exclude<T, U>` walks the union `T` member by member and throws away any member assignable to `U`, so `Exclude<'draft' | 'live' | 'archived', 'archived'>` is `'draft' | 'live'`. `Extract<T, U>` is the mirror image — it keeps only the members assignable to `U`, so `Extract<string | number | boolean, string>` is `string`. `NonNullable<T>` is the special case everyone reaches for under `strictNullChecks`: it strips `null` and `undefined`, so `NonNullable<User | null | undefined>` is `User`. Members that filter out contribute `never`, and `never` vanishes inside a union, which is why the result is just the surviving members. If nothing survives, the whole result is `never`. All of this is erased at compile time — no runtime filtering happens.

code

typescript · 13 lines
typescript
type Status = 'draft' | 'live' | 'archived';

type Editable = Exclude<Status, 'archived'>; // 'draft' | 'live'
type Frozen = Extract<Status, 'archived' | 'deleted'>; // 'archived'
type Loaded = NonNullable<{ id: string } | null | undefined>; // { id: string }

// A non-union type behaves as a one-member union:
type Wide = Exclude<string, 'a'>; // string, not "string without 'a'"
type Narrow = Extract<string, 'a'>; // never — string is not assignable to 'a'

declare function render(status: Editable): string;
render('draft'); // ok
// render('archived'); // Error: 'archived' is not assignable to 'draft' | 'live'

go deeper

for a junior

Be able to state plainly what each of the three does to a union and give a one-line example, such as Exclude of a status union dropping 'archived'. Say clearly that they operate on union members.

for a middle

Explain that the test is assignability rather than equality, that rejected members become never and disappear from the union, and that a fully filtered union collapses to never.

for a senior

Show where these earn their keep in a codebase: deriving narrow parameter types from a single source-of-truth union so call sites fail early, and knowing that NonNullable is a claim to the checker, not a runtime guard.

for a principal

Own the modelling question of whether a derived union should be computed with these operators or declared explicitly. Derivation keeps one source of truth but makes errors surface far from the declaration, so weigh readability against drift.

## The mental model: unions are sets A union type like `'draft' | 'live' | 'archived'` behaves like a set of three possibilities. `Exclude`, `Extract` and `NonNullable` are the set operations over that set: difference, intersection, and "remove the two nullish members". They live in TypeScript's standard library (`lib.es5.d.ts`), so they are always available without an import, and like everything in the type layer they exist only during compilation — the emitted JavaScript contains nothing from them. ## Exclude<T, U> — set difference `Exclude<T, U>` keeps the members of `T` that are **not** assignable to `U`: ```ts type Status = 'draft' | 'live' | 'archived'; type Editable = Exclude<Status, 'archived'>; // 'draft' | 'live' type NotFn = Exclude<string | number | (() => void), Function>; // string | number ``` The test is *assignability*, not string equality. `Exclude<string | number | (() => void), Function>` removes the function member because a `() => void` is assignable to `Function`. That also means excluding a supertype removes everything under it. One consequence surprises people: the filter is applied to each **member** of a union, and a non-union type is treated as a one-member union. So `Exclude<string, 'a'>` is `string` — `string` is not assignable to the literal `'a'`, so nothing is removed. It does *not* mean "string minus the letter a"; there is no such type. ## Extract<T, U> — set intersection `Extract<T, U>` is the complement: it keeps exactly the members `Exclude` would have thrown away. ```ts type Primitives = string | number | boolean | null; type Textish = Extract<Primitives, string | number>; // string | number type Nope = Extract<string, 'a'>; // never — string is not assignable to 'a' ``` The last line is the mirror image of the `Exclude<string, 'a'>` case and catches candidates out: direction matters. `Extract<'a' | 'b', string>` is `'a' | 'b'` because each literal *is* assignable to `string`, but `Extract<string, 'a'>` is `never` because the wide `string` is not assignable to the narrow `'a'`. ## NonNullable<T> — drop null and undefined ```ts type MaybeUser = { id: string } | null | undefined; type User = NonNullable<MaybeUser>; // { id: string } ``` This matters only when `strictNullChecks` is on; without it, `null` and `undefined` are already absorbed into every type and there is nothing to strip. In current TypeScript (the 5.x line) the standard library defines it as an intersection, `type NonNullable<T> = T & {}`, because the type `{}` means "any value except `null` and `undefined`". The observable behaviour is the same as removing the nullish members. ## Why the results look the way they do When a member fails the filter it becomes `never`, the empty type. `never` is the identity element of a union: `string | never` reduces to `string`. So filtering three members and rejecting one leaves `'draft' | 'live' | never`, which the checker immediately reduces to `'draft' | 'live'`. If **every** member is rejected you are left with `never` alone, and `never` has no values — the first assignment downstream will fail with a confusing error, which is usually the sign that a filter was too aggressive or a literal was misspelled. ## Where you actually use them - Deriving a narrower state union from a wider one: `type OpenTicket = Exclude<TicketState, 'closed' | 'archived'>`. - Pulling one variant out of a tagged union: `Extract<Action, { type: 'add' }>`. - Cleaning up an optional lookup result so downstream code is not forced to re-check for `null`. - Narrowing a callback parameter type after you have already validated it. ```ts function render(status: Exclude<Status, 'archived'>): string { return status === 'draft' ? 'Draft' : 'Live'; } ``` The compiler now rejects `render('archived')` at the call site, and the `switch` inside needs only two branches. ## The runtime boundary None of these does any filtering at run time. `NonNullable<T>` does not check for `null`; it only tells the checker that you have already ruled it out. If the value can actually be `null` at run time, you still need a real check — the utility type just stops the compiler from complaining, which is exactly the failure mode to avoid.

  • What does `Exclude<string, 'a'>` evaluate to, and why does that surprise people?
    It evaluates to `string`. A non-union type is treated as a single-member union, and `string` is not assignable to the literal `'a'`, so nothing is removed. People read it as "string without the letter a", but there is no such type — `Exclude` only ever removes whole union members.
  • What happens if every member of the union is filtered out?
    You get `never`. Each rejected member contributes `never`, and a union of nothing but `never` reduces to `never`. Nothing is assignable to `never`, so the first place you try to use the type produces an error that looks unrelated to the filter — usually the sign of a too-broad `U` or a misspelled literal.
  • Does `NonNullable<T>` do anything if `strictNullChecks` is off?
    Effectively no. Without `strictNullChecks`, `null` and `undefined` are assignable to almost every type and are not tracked as separate union members, so there is nothing for it to strip. The utility is only meaningful in a strict codebase, which is one more argument for turning the flag on.

saying these in an interview costs you the question

  • Thinking Exclude removes characters or keys, not union members
  • Believing NonNullable adds a runtime null check
  • Assuming Extract<string, 'a'> gives 'a' rather than never
  • Saying these need an import from a library
  • Expecting an error when the filtered type is unrelated

context