- Book: The TypeScript Type System — From Generics to DSL-Level Types
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You add a new payment method. PixPayment joins CardPayment
and BankTransfer in the type, the API starts sending it, and
the order page renders it fine. Two weeks later finance asks
why Pix orders show a blank fee line on the invoice PDF. The
fee calculator has a switch over payment kinds. Nobody added
a case "pix". It fell through to the default branch, which
returned 0, and the compiler said nothing, because the
function still type-checks. Every branch you wrote is valid.
The branch you forgot is the bug.
That whole class of bug is avoidable. The tool is a
discriminated union plus a never check, and it turns the
forgotten branch into a red squiggle in your editor before the
code ever runs.
A union the compiler can narrow
A discriminated union (also called a tagged union) is a union
of object types that all share one literal-typed field. That
field is the discriminant. The compiler reads it to figure out
which member you are holding.
type CardPayment = {
kind: "card";
last4: string;
brand: string;
};
type BankTransfer = {
kind: "bank";
iban: string;
};
type PixPayment = {
kind: "pix";
pixKey: string;
};
type Payment = CardPayment | BankTransfer | PixPayment;
The kind field is a string literal, not a string. That is
the part that does the work. When you check p.kind === "card"
inside an if, TypeScript narrows p to CardPayment in that
block and gives you last4 and brand without a cast. Check
for "bank" and only iban is in scope. The shape follows the
tag.
function describe(p: Payment): string {
if (p.kind === "card") {
return `${p.brand} ending ${p.last4}`;
}
if (p.kind === "bank") {
return `Transfer to ${p.iban}`;
}
return `Pix to ${p.pixKey}`;
}
This already reads better than an any-typed blob with manual
property checks. But the last return is a trap. It assumes
the only thing left is Pix. Add a fourth payment method and
that assumption is silently wrong.
Switch on the discriminant
A switch over the discriminant is the idiomatic shape. It
narrows per case the same way the if chain did, and it
groups the logic where you can see all the branches at once.
function fee(p: Payment): number {
switch (p.kind) {
case "card":
return 30;
case "bank":
return 0;
case "pix":
return 0;
}
}
Under strict (or noImplicitReturns), this compiles only
because every member of Payment is handled and the function
returns on every path. Remove the pix case and the function
now returns number | undefined, which does not satisfy the
: number signature. That is one safety net. It depends on the
return type, though. A switch that does side effects and
returns nothing gets no protection from it.
The net you actually want does not depend on the return type at
all. It depends on never.
The never trick
never is the type with no values. A variable of type never
can never be assigned anything, because nothing inhabits the
type. That sounds useless until you pair it with exhaustive
narrowing.
When you handle "card", "bank", and "pix" in a switch,
the compiler narrows what is left in the default branch. If
you handled every member, what is left is never — there are
no payment kinds the compiler still considers possible. So if
you put a variable of type never in the default and assign
p to it, the assignment type-checks only when the union is
fully covered.
function assertNever(value: never): never {
throw new Error(
`Unhandled variant: ${JSON.stringify(value)}`,
);
}
Wire it into the default branch.
function fee(p: Payment): number {
switch (p.kind) {
case "card":
return 30;
case "bank":
return 0;
case "pix":
return 0;
default:
return assertNever(p);
}
}
While every case is handled, p in the default has type
never, and assertNever(p) is happy. The moment you add a
fourth variant to the Payment type and forget to add its
case, p in the default narrows to that new variant instead of
never. Passing it to assertNever fails:
Argument of type 'RefundPayment' is not assignable to
parameter of type 'never'.
The error points at the exact switch you forgot to update.
Not a unit test that happened to cover it, not a customer
ticket. A compile error, in the file that is wrong, the moment
you change the type.
The throw inside assertNever is the runtime backstop for
the case where bad data reaches the function at runtime — an
API sending a kind your types never declared. The return type
never lets you call it in a branch that is supposed to return
a value, because a function that always throws never returns
one.
A reducer that cannot drift
State machines are where this pays off most, because a reducer
is one giant switch over action types and it grows every time
the feature does. Here is a small checkout machine.
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "paid"; receiptId: string }
| { status: "failed"; reason: string };
type Action =
| { type: "submit" }
| { type: "confirm"; receiptId: string }
| { type: "reject"; reason: string }
| { type: "reset" };
The reducer switches on action.type, and the default branch
runs assertNever so a new action without a handler will not
compile.
function reduce(state: State, action: Action): State {
switch (action.type) {
case "submit":
return { status: "loading" };
case "confirm":
return {
status: "paid",
receiptId: action.receiptId,
};
case "reject":
return {
status: "failed",
reason: action.reason,
};
case "reset":
return { status: "idle" };
default:
return assertNever(action);
}
}
Notice the narrowing inside the cases. In case "confirm",
action.receiptId is in scope and typed string; in
case "reject", action.reason is in scope and receiptId is
not. You cannot read a field that the action does not carry.
The discriminant gates the payload.
Now add a "refund" action with its own field. You update the
Action union, the reducer stops compiling at the
assertNever line, and your editor sends you straight to the
branch that needs the new case. The type and the logic cannot
drift apart, because drifting is a build failure.
Where it bites, and how to keep it
A few sharp edges are worth knowing before you scatter this
across a codebase.
The discriminant has to be a literal type, which means the
objects need to be built such that TypeScript keeps kind as
"card" and not string. Inline object literals get this
right. Objects assembled dynamically sometimes widen the field
to string, and then narrowing stops working entirely. If
narrowing goes dead, check the inferred type of the
discriminant first.
Reaching for assertNever after the cases is the habit to
build. A bare switch with no default still misses the new
variant unless the return type happens to catch it. The
never assignment is the part that does not care about return
types, side effects, or whether anyone wrote a test.
Keep assertNever in one shared module and import it
everywhere. One definition, used in every reducer and every
discriminated switch, is what makes the pattern free to apply.
The cost is one line per switch. The payback is that adding a
variant becomes a guided edit: the compiler hands you the list
of every place that needs to change.
The fee bug from the opening could not have shipped with this
in place. The day someone added Pix to the union, the fee
function would have refused to compile until they handled it.
The bug never reaches the invoice PDF, because it never reaches
main.
If you want the full account of how narrowing, literal types,
and never compose into checks the compiler enforces for you,
that is the territory The TypeScript Type System covers in
depth. Discriminated unions are one entry point into the larger
toolbox of conditional types, mapped types, and infer that
let the type system carry guarantees your tests would otherwise
have to.
The TypeScript Library — the 5-book collection. Books 1 and 2 are the core path; 3 and 4 substitute for 1 and 2 if you come from the JVM or PHP; book 5 is for anyone shipping TS at work.
- TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, and the browser.
- The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types.
- Kotlin and Java to TypeScript — variance, null safety, sealed classes to unions, coroutines to async/await.
- PHP to TypeScript — the sync-to-async shift, generics, discriminated unions for PHP 8+ developers.
- TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.
All five books ship in ebook, paperback, and hardcover.

Top comments (0)