skip to content

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%

answer

  1. second parameter carries no constraint
  2. non-matching filter is a silent no-op
  3. Extract fails the other way, to never
  4. wrap it with U extends T
  5. pin derived unions with type assertions

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

solid answer

~50 s

The standard library declares `type Exclude<T, U> = T extends U ? never : T` with `U` completely unconstrained, so `'archivd'` is a perfectly legal filter — it simply matches no member of `Status`, nothing is discarded, and the result is `'draft' | 'live' | 'archived'`. The failure is silent and downstream: some function you meant to narrow still accepts `'archived'`, and no error ever points at the typo. The fix is to constrain the filter yourself: `type ExcludeStrict<T, U extends T> = Exclude<T, U>` makes `ExcludeStrict<Status, 'archivd'>` an error at the typo. The mirror hazard is `Extract`, where an unmatched filter collapses the type to `never` and the error surfaces far away at the first assignment. For unions that drive real logic I add a type-level assertion in a test file so the derived type is pinned, not merely computed.

code

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

type Oops = Exclude<Status, 'archivd'>; // 'draft' | 'live' | 'archived' — silently unchanged
type Gone = Extract<Status, 'archivd'>; // never — fails later, at the use site

// Fix 1: constrain the filter for closed literal unions.
type ExcludeStrict<T, U extends T> = Exclude<T, U>;
type Editable = ExcludeStrict<Status, 'archived'>; // 'draft' | 'live'
// type Caught = ExcludeStrict<Status, 'archivd'>;
// Error: Type '"archivd"' does not satisfy the constraint 'Status'.

// Fix 2: pin the derived type with a compile-time assertion.
type Equals<A, B> =
  (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Assert<T extends true> = T;
type _editableIsPinned = Assert<Equals<Editable, 'draft' | 'live'>>;

go deeper

for a junior

Know that the second argument to Exclude is not checked against the union, so a misspelled literal removes nothing and no error appears. Read the resulting type in your editor rather than assuming it narrowed.

for a middle

Explain the mechanism: the alias leaves U unconstrained and a non-matching filter simply makes every member take the else branch. Contrast that with Extract, where an unmatched filter yields never.

for a senior

Show the containment strategy — a constrained ExcludeStrict wrapper for closed unions, type-level assertions pinning the derived types, and diagnosing a stray never back to its filter rather than casting past it.

for a principal

Own the policy: which derived types are load-bearing enough to need type tests, and where the team accepts the standard library's deliberate laxity because generic code depends on it. Decide it once and write it down, or the pattern reappears unreviewed.

## Why it compiles `Exclude` is an ordinary type alias, not compiler magic: ```ts type Exclude<T, U> = T extends U ? never : T; ``` Neither parameter carries a constraint. `U` is only ever used on the right of `extends`, where its job is to answer an assignability question, and *any* type can answer that question. There is nothing for the compiler to object to when you pass `'archivd'`, `number`, or `{ nope: true }`. `'draft' extends 'archivd'` is simply false — as it is for every member — so every member takes the `else` branch and survives. The result is `'draft' | 'live' | 'archived'`: the untouched union. ## Why that is worse than an error The type still *looks* narrowed everywhere it is used. A function declared `function publish(s: Exclude<Status, 'archivd'>)` accepts `'archived'` happily, and the switch inside it silently loses its exhaustiveness benefit. Nothing fails until run time, when an archived record takes a code path built on the assumption that it could not. The defect has all the properties of the worst class of type bug: no error, no diff-visible symptom, and a type name that reads correct. `Extract` fails in the opposite direction and is usually easier to catch. `Extract<Status, 'archivd'>` is `never`, and `never` accepts no values at all, so the first assignment or call using it errors — but the error points at the *use site*, complaining that `'archived'` is not assignable to `never`, with no hint that a typo three files away is the cause. ## Fix 1: constrain the filter A one-line wrapper reinstates the check the built-in deliberately omits: ```ts type ExcludeStrict<T, U extends T> = Exclude<T, U>; type Editable = ExcludeStrict<Status, 'archived'>; // 'draft' | 'live' // type Oops = ExcludeStrict<Status, 'archivd'>; // Error: Type '"archivd"' does not satisfy the constraint 'Status'. ``` The error now lands exactly on the typo. The same trick works for tag-driven helpers over object unions: constrain the tag parameter to the union of real tags, `K extends Action['type']`, rather than passing a free-form filter object. This is a deliberate trade, not a bug in the standard library. Unconstrained `U` is what lets `Exclude` be used with filters that overlap the union only partly — `Exclude<T, null | undefined>` on a `T` that may contain neither is legitimate and common, and a constraint would reject it. So `ExcludeStrict` is the right tool for a closed, hand-written literal union, and plain `Exclude` for generic plumbing. ## Fix 2: assert the derived type For unions that drive real logic, pin the result with a compile-time assertion rather than trusting the computation: ```ts type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false; type Assert<T extends true> = T; type _check = Assert<Equals<Editable, 'draft' | 'live'>>; ``` If someone edits `Status`, or a filter stops matching, `_check` fails to compile. Put these in a dedicated types test file so they are cheap to read and obviously not production code; dedicated type-testing libraries offer the same thing with nicer output. ## Fix 3: make the union impossible to mistype The deeper fix is often to stop hand-writing the filter literal at all. Derive both the union and the subsets from one declared source, so the only literal in play is written once: ```ts const STATUSES = ['draft', 'live', 'archived'] as const; type Status2 = (typeof STATUSES)[number]; // 'draft' | 'live' | 'archived' ``` Now a filter still has to name a literal, but the editor autocompletes it from the constrained helper, and the union itself cannot drift from the runtime list. ## What to say in the interview The complete answer has three beats: the mechanism (`U` is unconstrained, so a non-matching filter is a no-op rather than an error), the consequence (silent under-filtering with `Exclude`, a distant `never` error with `Extract`), and the remedy (constrain the filter for closed unions, assert derived types in tests, and prefer autocompleted literals over hand-typed ones). Mentioning that the laxity is intentional — it is what makes the utility usable in generic code — shows you understand the design and are not just complaining about it.

  • Why doesn't the standard library just constrain `U extends T` and prevent this?
    Because that would break legitimate uses. `Exclude<T, null | undefined>` in generic code, or filtering by a broad type such as `Function`, would be rejected whenever the filter is not a subtype of the union. The unconstrained form keeps the utility usable everywhere; constraining it is a choice you make locally for closed literal unions.
  • How does the same mistake behave with `Extract` rather than `Exclude`?
    It collapses to `never`, since no member matches and every branch yields `never`. That is louder — `never` accepts no values, so something downstream errors — but the message appears at the use site, complaining that a valid value is not assignable to `never`, with no pointer back to the typo.
  • If a derived union unexpectedly became `never`, how would you track down the cause?
    Hover the alias chain and evaluate the filter one step at a time: check that the filter type actually intersects the union, and test a single member by hand, for example `type T = 'draft' extends 'archivd' ? 1 : 0`. Then add a type-level equality assertion so the same regression fails at compile time next time.
  • Where does a compile-time assertion like that belong in a repo?
    In a type-tests file the build type-checks but does not ship — it emits nothing, so the cost is compile time only. Keep the assertions next to the union they pin, name them for the invariant they protect, and treat a failure as a real break rather than something to delete.

saying these in an interview costs you the question

  • Assuming a non-matching filter raises a compile error
  • Believing the result becomes never when nothing matches
  • Thinking Exclude validates keys against the union
  • Reaching for a cast when the derived type ends up never
  • Calling the missing constraint a compiler bug

context