Skip to content
Chapter 5Lesson 3

Exhaustiveness, enforced

TypeScript techniques that turn an unhandled union variant and an unchecked external value into compile errors instead of production bugs.

After the previous two lessons, you write discriminated unions whose shape is watertight: the variants exist, and the transitions between them are typed.

The remaining gap is not in the union itself. It is in the consumers, the code that reads a discriminated union: a switch, a router, an event handler. TypeScript keeps no compile-time link between which variants exist and which variants a consumer handles. When you add a fifth variant to the union, the existing switch over the four old variants keeps compiling. The new variant slides through the default branch, whether that branch is a return null, a console.warn, or a logged-and-ignored payload, and nobody notices until production telemetry flags the gap.

Picture a webhook handler. It receives event payloads typed as a four-variant union, 'user.created', 'invoice.paid', 'subscription.canceled', and 'session.revoked', and dispatches on event.type. Six months in, a teammate ships a new event type, 'invoice.refunded', and updates the producer side. The consumer’s switch keeps compiling, because TypeScript doesn’t track whether every variant has a case. Refunds go unhandled for two weeks. Customer service finds the bug, and the team finds the silent default branch.

type AppEvent =
| { type: 'user.created'; userId: string }
| { type: 'invoice.paid'; invoiceId: string; amount: number }
| { type: 'subscription.canceled'; subscriptionId: string }
| { type: 'session.revoked'; sessionId: string };
const handle = (event: AppEvent): void => {
switch (event.type) {
case 'user.created': return notifyUser(event.userId);
case 'invoice.paid': return recordPayment(event.invoiceId, event.amount);
case 'subscription.canceled': return cleanupSubscription(event.subscriptionId);
case 'session.revoked': return revokeSession(event.sessionId);
default: return; // adding a fifth variant lands here, silently
}
};

The fix is structural. You want the compiler to refuse to build when a handler is missing, rather than merely warn or suggest. This lesson shows how: end every switch on a discriminant with something that turns a missing variant into a compile error. After that, it covers two narrowing helpers, type predicates and assertion functions, that the rest of the course relies on for narrowing unknown from the wire and filtering arrays cleanly.

The whole enforcement mechanic rests on one type. never is TypeScript’s bottom type, the type with no inhabitants. No value is assignable to never: a variable typed never cannot hold a string, a number, an object, or anything else. That emptiness is exactly what makes it useful here.

The mechanic works like this. Inside a switch on the discriminant of a discriminated union, the compiler narrows the value one variant at a time. After the first case returns, the remaining type loses that variant. After the second case returns, it loses another. Once every variant has been handled, the value at the default branch has nothing left in its type, so the compiler narrows it to never.

const handle = (event: AppEvent): void => {
switch (event.type) {
case 'user.created': return notifyUser(event.userId);
case 'invoice.paid': return recordPayment(event.invoiceId, event.amount);
case 'subscription.canceled': return cleanupSubscription(event.subscriptionId);
case 'session.revoked': return revokeSession(event.sessionId);
default:
const remaining = event;
// ^? const remaining: never
}
};

Now remove one of the cases. The compiler can no longer eliminate that variant, so the value at the default is the missed variant itself, not never.

const handle = (event: AppEvent): void => {
switch (event.type) {
case 'user.created': return notifyUser(event.userId);
case 'invoice.paid': return recordPayment(event.invoiceId, event.amount);
case 'subscription.canceled': return cleanupSubscription(event.subscriptionId);
// case 'session.revoked': handled? no.
default:
const remaining = event;
// ^?
// const remaining: { type: 'session.revoked'; sessionId: string }
}
};

That is the whole observation: any time the type at the bottom of a switch is something other than never, a variant was missed. The enforcement idea follows from it. Pass that bottom value to a function whose parameter is typed never. When every variant has been handled, the bottom is never and the call compiles. When a variant is missed, the bottom is the missed variant, and the call fails, because that variant is not assignable to never. The compile error then names the missing variant by its full type.

The rest of the lesson gives that one mechanic two surface shapes.

The first shape is a helper function, three lines long:

export function assertNever(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}

Note the function keyword, a deliberate break from the chapter’s arrow-function default. The code conventions carve out type-guard signatures from that default, and assertNever qualifies for the same reason: its never return type is the whole point of the signature. Wherever the signature itself is the contract, reach for the function form. The file lives at lib/assert-never.ts, one export per file, with a kebab-case filename matching the export.

Here is the shape at the call site:

import { assertNever } from '@/lib/assert-never';
type AppEvent =
| { type: 'user.created'; userId: string }
| { type: 'invoice.paid'; invoiceId: string; amount: number }
| { type: 'subscription.canceled'; subscriptionId: string }
| { type: 'session.revoked'; sessionId: string };
const handle = (event: AppEvent): void => {
switch (event.type) {
case 'user.created': return notifyUser(event.userId);
case 'invoice.paid': return recordPayment(event.invoiceId, event.amount);
case 'subscription.canceled': return cleanupSubscription(event.subscriptionId);
case 'session.revoked': return revokeSession(event.sessionId);
default: return assertNever(event);
}
};

The four handled cases. Each variant gets exactly one case. The handler bodies are elided; what matters is that each case terminates with return, so the compiler can eliminate that variant from the remaining type as the switch walks down.

import { assertNever } from '@/lib/assert-never';
type AppEvent =
| { type: 'user.created'; userId: string }
| { type: 'invoice.paid'; invoiceId: string; amount: number }
| { type: 'subscription.canceled'; subscriptionId: string }
| { type: 'session.revoked'; sessionId: string };
const handle = (event: AppEvent): void => {
switch (event.type) {
case 'user.created': return notifyUser(event.userId);
case 'invoice.paid': return recordPayment(event.invoiceId, event.amount);
case 'subscription.canceled': return cleanupSubscription(event.subscriptionId);
case 'session.revoked': return revokeSession(event.sessionId);
default: return assertNever(event);
}
};

The exhaustiveness check lives on this one line. assertNever’s parameter is typed never, so the call only type-checks when the bottom of the switch has been narrowed to never, which happens exactly when every variant above has been handled.

import { assertNever } from '@/lib/assert-never';
type AppEvent =
| { type: 'user.created'; userId: string }
| { type: 'invoice.paid'; invoiceId: string; amount: number }
| { type: 'subscription.canceled'; subscriptionId: string }
| { type: 'session.revoked'; sessionId: string };
const handle = (event: AppEvent): void => {
switch (event.type) {
case 'user.created': return notifyUser(event.userId);
case 'invoice.paid': return recordPayment(event.invoiceId, event.amount);
case 'subscription.canceled': return cleanupSubscription(event.subscriptionId);
case 'session.revoked': return revokeSession(event.sessionId);
default: return assertNever(event);
}
};

If a case is removed, or a new variant lands without a corresponding case, event at the default is no longer never. It is the missed variant, and the TypeScript error reads Argument of type '{ type: "session.revoked"; ... }' is not assignable to parameter of type 'never'. The diagnostic names the missed variant by its full type, so the on-call engineer can walk straight to the missing case.

1 / 1

Two things happen on that one default line, and both matter. The first is the compile-time check just described. The second is a runtime safety net: assertNever throws. If the consumer is somehow reached at runtime with an unhandled variant, say a webhook from a future API version, a manually crafted JSON body, or a database row left by a deprecated codepath, the function throws with the offending value in the error message. The compile-time check and the runtime throw are two channels for the same value, and both are part of the contract.

The runtime throw is loud on purpose. If the consumer is reached with an unhandled variant, the team needs the error in their logs immediately, not a swallowed console.warn. Silence at this layer is how a missing-refund bug survives for two weeks. The Error carries the offending value via JSON.stringify, so whoever is on call doesn’t have to chase a missing field across three log lines.

satisfies never, when the runtime throw isn’t needed

Section titled “satisfies never, when the runtime throw isn’t needed”

The same mechanic has a second surface. Since TypeScript 4.9, the satisfies operator, which the previous chapter introduced, lets you write the check inline without a helper:

const handle = (event: AppEvent): void => {
switch (event.type) {
case 'user.created': return notifyUser(event.userId);
case 'invoice.paid': return recordPayment(event.invoiceId, event.amount);
case 'subscription.canceled': return cleanupSubscription(event.subscriptionId);
case 'session.revoked': return revokeSession(event.sessionId);
default: event satisfies never;
}
};

Recall what satisfies does: it checks that the value on the left is assignable to the type on the right, without widening the value’s inferred type. So event satisfies never compiles only when event’s narrowed type at that point is never. That is the same enforcement as assertNever, but the line erases at compile time, so it carries no runtime cost: no function call, no throw, nothing in the emitted JavaScript.

Which one you reach for turns on a single question: what should happen if the bottom is somehow reached at runtime?

The course default is assertNever. Production handlers want the loud throw, because silence at the bottom of a real consumer is how runtime drift goes unnoticed. Reach for satisfies never only when the bottom genuinely can’t be hit, and use assertNever everywhere else.

noFallthroughCasesInSwitch, the strict-flag companion

Section titled “noFallthroughCasesInSwitch, the strict-flag companion”

One more enforcement composes with assertNever, and it needs nothing from you to turn on: the course’s tsconfig.json ships with noFallthroughCasesInSwitch enabled. The flag turns a case body that lacks an explicit break, return, throw, or continue into a compile error, so a case that forgets to terminate can no longer fall silently through into the next case’s body.

The two enforcements are independent, and they compose. noFallthroughCasesInSwitch makes every case body unambiguous, and assertNever makes every switch exhaustive. Together they leave a switch watertight at the case level and at the variant-set level. You will see the fallthrough error fire occasionally during refactors, and now you know what it is telling you.

The switch + assertNever shape is the right reach when each case has nontrivial logic: a few lines, a branch, a call to a helper. But a lot of dispatch is simpler than that. When each variant maps to a single handler with no branching and no inline logic, a Record indexed by the variant literal reads cleaner than a switch, and it gives you the same exhaustiveness guarantee for free.

type EventType = AppEvent['type'];
const HANDLERS: Record<EventType, (event: AppEvent) => Promise<void>> = {
'user.created': handleUserCreated,
'invoice.paid': handleInvoicePaid,
'subscription.canceled': handleSubscriptionCanceled,
'session.revoked': handleSessionRevoked,
};
await HANDLERS[event.type](event);

The Record<EventType, Handler> form requires every key in the union to have a handler, the same rule a literal-keyed Record carries from the previous chapter. Add 'invoice.refunded' to AppEvent and the HANDLERS constant fails to type-check at the literal site, on the object that is missing the key. This is the same enforcement as assertNever, expressed as a completeness constraint on the record’s keys rather than a check at the bottom of a switch.

The AppEvent['type'] form is indexed access, reading the type of the type field straight off AppEvent. A later lesson in this chapter, “Derive types from values,” covers the derivation operators properly; here it is used directly because it is the most readable way to name the discriminant union.

Use this to decide between the two shapes:

  • switch + assertNever when each case has inline logic, whether a few lines, a conditional, or anything that wants its own block. The case body sits next to the discriminant check, which reads well.
  • Record<Variant, Handler> when each case is a one-to-one handler reference. The lookup table is shorter, carries less ceremony, and the keys document the variant set at a glance.

The chapter so far has assumed values that already carry a type: a discriminated union you wrote, a state machine you control. The other half of working with types is values that don’t carry a type yet, such as unknown from await req.json(), mixed arrays from a query that returns multiple shapes, or any value that crossed a boundary without a parser. Two functions narrow these values into the typed code that follows. This is the first one.

A type predicate is a function whose return type is value is T. The signature tells the compiler that when the function returns true, it should treat the argument as T inside the conditional block where the call appears. The narrow lives inside the if-block; once execution leaves the block, the type widens back. That block-scoped rule is what distinguishes this helper from the next one.

Here is the canonical shape:

function isUser(value: unknown): value is User {
return typeof value === 'object'
&& value !== null
&& 'id' in value
&& 'email' in value;
}

Note the function keyword again, the same carve-out as assertNever. Type predicates are the canonical case the rule was written for: the value is User return type is the function’s whole contract, so the signature wants the form that reads most legibly.

Two uses dominate the rest of the course: narrowing a mixed array down to one variant with .filter, and narrowing an unknown value from the wire before passing it into typed code. The block below walks through both.

type User = { id: string; email: string };
type Guest = { sessionId: string };
function isUser(value: unknown): value is User {
return typeof value === 'object'
&& value !== null
&& 'id' in value
&& 'email' in value;
}
declare const allMembers: (User | Guest)[];
const users = allMembers.filter(isUser);
// ^? const users: User[]
declare const maybeUsers: (User | undefined)[];
const present = maybeUsers.filter((u) => u !== undefined);
// ^? const present: User[]
declare const payload: unknown;
if (isUser(payload)) {
await sendWelcome(payload);
// ^? (parameter) payload: User
}

The signature is the type predicate’s whole API surface. The value is User return type is what tells the compiler the function refines its argument when it returns true.

type User = { id: string; email: string };
type Guest = { sessionId: string };
function isUser(value: unknown): value is User {
return typeof value === 'object'
&& value !== null
&& 'id' in value
&& 'email' in value;
}
declare const allMembers: (User | Guest)[];
const users = allMembers.filter(isUser);
// ^? const users: User[]
declare const maybeUsers: (User | undefined)[];
const present = maybeUsers.filter((u) => u !== undefined);
// ^? const present: User[]
declare const payload: unknown;
if (isUser(payload)) {
await sendWelcome(payload);
// ^? (parameter) payload: User
}

The named-predicate filter. The array narrows from (User | Guest)[] to User[] because .filter’s typing recognizes the callback is a type predicate and uses its refined type for the result.

type User = { id: string; email: string };
type Guest = { sessionId: string };
function isUser(value: unknown): value is User {
return typeof value === 'object'
&& value !== null
&& 'id' in value
&& 'email' in value;
}
declare const allMembers: (User | Guest)[];
const users = allMembers.filter(isUser);
// ^? const users: User[]
declare const maybeUsers: (User | undefined)[];
const present = maybeUsers.filter((u) => u !== undefined);
// ^? const present: User[]
declare const payload: unknown;
if (isUser(payload)) {
await sendWelcome(payload);
// ^? (parameter) payload: User
}

The TypeScript 5.5 inferred form. Since TypeScript 5.5, released in June 2024, the compiler infers u is User automatically when an inline arrow callback’s body is a simple refinement, so one-shot inline filters need no helper function.

type User = { id: string; email: string };
type Guest = { sessionId: string };
function isUser(value: unknown): value is User {
return typeof value === 'object'
&& value !== null
&& 'id' in value
&& 'email' in value;
}
declare const allMembers: (User | Guest)[];
const users = allMembers.filter(isUser);
// ^? const users: User[]
declare const maybeUsers: (User | undefined)[];
const present = maybeUsers.filter((u) => u !== undefined);
// ^? const present: User[]
declare const payload: unknown;
if (isUser(payload)) {
await sendWelcome(payload);
// ^? (parameter) payload: User
}

Narrowing unknown from the wire. Inside the if-block, payload is User and typed code can read its fields; outside the block, it is still unknown. That scope rule is the block-scoped narrow named above.

1 / 1

The TypeScript 5.5 inferred form is worth a closer look. Before 5.5, narrowing an array with an inline arrow required either a named type predicate or a type assertion at the call site. Since 5.5, when an inline arrow’s body is a simple refinement of the parameter, such as u !== undefined, typeof u === 'string', or an in-check, the compiler infers the predicate without you writing it. For one-shot inline filters whose body is a simple refinement, the inferred form is the right reach: no helper, no annotation, and the array still narrows correctly.

For named, reusable, multi-condition predicates, the kind you would put in lib/ and import from several call sites, the explicit value is T form is still the default. The signature documents the predicate’s intent, and the body can grow without losing the narrow.

One word on the body of isUser above. The chain typeof value === 'object' && value !== null && 'id' in value && 'email' in value is illustrative: it is the simplest body that shows the predicate’s signature in action. It is also fragile. It doesn’t check that id and email are strings, it drifts the moment User grows a field, and it can’t express refinement constraints, such as email looking like an email or id looking like a UUID. In production, the body is a Zod parse, not a hand-written in chain. A schema-authoring chapter later in the course owns the schemas themselves; here, the lesson is the signature, not the body.

The second narrowing helper is the same idea, a function that narrows a value, with a different scope.

An assertion function has the return type asserts value is T. The signature tells the compiler that if this function returns without throwing, the argument is T for the rest of the scope, meaning not just inside an if-block but the whole rest of the function. The narrow is scope-wide.

Here is the canonical shape:

function assertIsUser(value: unknown): asserts value is User {
if (!isUser(value)) {
throw new Error(`Expected User, got: ${JSON.stringify(value)}`);
}
}

The structural difference between the two helpers is this: predicates return a boolean and let the caller branch on it, while assertions throw and let the caller continue with the narrowed type. The same function-keyword carve-out applies for the same reason.

Read them side by side. The difference between block-scoped and scope-wide shows up in what each call site looks like.

const handlePayload = async (payload: unknown): Promise<void> => {
if (isUser(payload)) {
await sendWelcome(payload);
}
await logUnknown(payload);
};
payload is narrowed to User inside the if-block. Outside the block, it widens back to unknown.

The assertion form earns its weight in two places.

The first is parse-or-throw at a service boundary. A function reads unknown from a third-party SDK, asserts the shape, and continues with the narrowed value. There is no nested if-block and no rightward drift, so the rest of the function reads as if the value was already typed:

const raw: unknown = await stripe.invoices.retrieve(id);
assertIsInvoice(raw);
return raw;

The second is test fixtures and harnesses. A custom helper like expectUser(value) asserts inside a test and lets the rest of the test access the narrowed value without wrapping every assertion in an if. The scope-wide narrow is what keeps test bodies readable.

The choice between the two helpers comes down to one question: do you want the caller to handle the false case, or do you want the call site to fail fast? Predicates hand control to the caller, and assertions take it away. Production parse boundaries usually want the assertion form, because the upstream wire shape is a contract: a violation is a bug, not a branch you handle.

One reminder before the exercises, because the lesson’s examples could leave the wrong impression.

The bodies shown above, like the 'id' in value && 'email' in value chain inside isUser, are illustrative. Production code does not write runtime validators by hand. Hand-written chains drift from the type the moment it grows a field. They miss edge cases such as nested optionals, discriminated unions, and refinement constraints. And they duplicate type information that the schema already encodes.

The reliable approach is to treat the schema as the source of truth. Author the schema in Zod once, derive the TypeScript type from z.infer<typeof schema>, and build the type predicate or assertion function on top of schema.safeParse(value).success. A later unit covers schema authoring and the runtime-to-type round-trip. What this lesson covers is the signature the schema gets wired into, not the parser’s body.

Exercise: make the missing variant fail to compile

Section titled “Exercise: make the missing variant fail to compile”

The AppEvent union below has five variants, but the switch only handles four. The return assertNever(event) line at the bottom refuses to compile because event at the default is the unhandled variant, not never. Add the missing case to fix the call.

The `AppEvent` union has five variants; the `switch` only handles four. The `return assertNever(event)` line refuses to compile because `event` at the `default` is the unhandled variant, not `never`. Add the missing `case` so the call type-checks again.

  • Fix all errors
Booting type-checker…
Reveal the reference solution
case 'invoice.refunded': return refund(event.invoiceId);

Drop the new case above the default. With every variant handled, event at the default narrows to never, the call to assertNever(event) compiles, and the runtime throw stays in place as the safety net for any payload that ever reaches the bottom anyway.

The error message TypeScript shows on the starter is the one that matters: Argument of type '{ type: "invoice.refunded"; invoiceId: string; }' is not assignable to parameter of type 'never'. The compiler is telling you exactly which variant slipped through. Add the case that matches, and the bottom of the switch narrows back to never.

Five scenarios, five tools. Pair each scenario to the narrowing tool that fits; the verbs in each description are the cue.

Pair each scenario to the narrowing tool that fits. Two of the right-side options handle exhaustiveness on discriminated unions; the other three handle narrowing a wider type to a narrower one. Click an item on the left, then its match on the right. Press Check when done.

Filter an array of (User | Guest)[] down to just the User[] values, using a named, reusable predicate.
Named type predicate — function isUser(value: unknown): value is User.
Narrow a (User | undefined)[] down to User[] in a single inline .filter call, with no helper to extract.
Inline arrow callback — arr.filter((u) => u !== undefined), predicate inferred since TypeScript 5.5.
Validate an unknown payload from a third-party SDK at a service boundary, then continue the function with the narrowed value — no nested if-block.
Assertion function — function assertIsInvoice(value: unknown): asserts value is Invoice.
Make a switch on a discriminated union refuse to compile when a future variant is added to the union.
assertNever(value) at the default branch.
The same exhaustiveness check, but the bottom of the function is structurally unreachable and you don’t want a runtime throw.
value satisfies never at the bottom of the switch.