skip to content

Exclude, Extract & NonNullable

Set operations over unions: remove members, keep members, or strip null and undefined. They are the friendliest way into distributive conditional types, which is exactly why interviewers ask you to implement Exclude from scratch.

part ofTypeScriptoverview, primer and where to startread it →
on this pageshow

questions

4

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

open as a page

Write the one-line definitions of TypeScript's built-in `Exclude<T, U>` and `Extract<T, U>` yourself, and explain why they filter a union member by member instead of testing the union as a whole.

level: middleimportance: must knowfreq 62%

basics

~20 s

Exclude<T, U> is T extends U ? never : T and Extract<T, U> is T extends U ? T : never. Because T is a bare type parameter, the conditional is applied to each union member separately and the results are unioned.

open as a page

Given `type Action = { type: 'add'; payload: number } | { type: 'remove'; id: string } | { type: 'reset' }`, how do you write a TypeScript type for just the `'add'` variant — and for its payload — without repeating the shape?

level: middleimportance: should knowfreq 52%

basics

~20 s

Use Extract with a partial shape as the filter: Extract<Action, { type: 'add' }> is the add variant, and Extract<Action, { type: 'add' }>['payload'] is its payload type. Nothing is duplicated, so the types follow the union.

open as a page

In TypeScript, `type Status = 'draft' | 'live' | 'archived'` and someone writes `Exclude<Status, 'archivd'>` with a typo. Why does that compile without any error, what does it evaluate to, and how would you make such a filter fail loudly?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Exclude puts no constraint on its second type argument, so any type is a legal filter. The misspelled literal matches no member, nothing is removed, and the result is the full Status union. Constrain the filter yourself with a helper such as type ExcludeStrict<T, U extends T> = Exclude<T, U>.

open as a page