🔧 Programmierung 🕛 vor 3 Monaten 8 Min Lesezeit
0

Discriminated Unions + never: Exhaustive Checks at Compile Time

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht


  • Book:


  • My project: — an IDE for developers who ship with Claude Code and other AI coding tools


  • Me:






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.




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




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




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




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






Wire it into the default branch.




CODE
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:




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




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




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





  1. — generics, mapped/conditional types, infer, template literals, branded types.


  2. — the sync-to-async shift, generics, discriminated unions for PHP 8+ developers.


  3. Vollständiger Original-Artikel
    Den kompletten Beitrag mit allen Details direkt auf dev.to lesen.
    ↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
6 Quellen
CVE-2022-44255 | TOTOLINK LR350 9.3.5u.6369_B20220309 buffer overflow (EUVD-2022-47204)
2 Quellen
CVE-2026-68426 | Linux Kernel up to 6.18.41/7.1.5/7.2-rc3 xfrm validate_xmit_skb_list use after free (Nessus ID 346426)
1 Quelle
Windows 11 Probleme mit gültiger Domänenanmeldung nach September-Update [Workaround]
Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten Discriminated Unions + never: Exhaustive Checks at Compile Time

Thematisch verwandte Begriffe: Discriminated, Unions, never, Exhaustive · 6 Treffer

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...