The short answer
Give every member of the union the same property with a different string literal (kind here), and switch on it:
/** @typedef {{ kind: "card", last4: string }} CardPayment */
/** @typedef {{ kind: "invoice", dueDays: number }} InvoicePayment */
/** @typedef {{ kind: "voucher", code: string }} VoucherPayment */
/** @typedef {CardPayment | InvoicePayment | VoucherPayment} Payment */
/**
* @param {Payment} payment
* @returns {string}
*/
export function describePayment(payment) {
switch (payment.kind) {
case "card":
return `Card ending in ${payment.last4}`;
case "invoice":
return `Invoice, due in ${payment.dueDays} days`;
case "voucher":
return `Voucher ${payment.code}`;
default: {
/** @type {never} */
const unhandled = payment;
return unhandled;
}
}
}type CardPayment = { kind: "card"; last4: string };
type InvoicePayment = { kind: "invoice"; dueDays: number };
type VoucherPayment = { kind: "voucher"; code: string };
type Payment = CardPayment | InvoicePayment | VoucherPayment;
export function describePayment(payment: Payment): string {
switch (payment.kind) {
case "card":
return `Card ending in ${payment.last4}`;
case "invoice":
return `Invoice, due in ${payment.dueDays} days`;
case "voucher":
return `Voucher ${payment.code}`;
default: {
const unhandled: never = payment;
return unhandled;
}
}
}jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} In each case tsc knows which member it has, so payment.last4 is allowed only for cards. The default branch assigns the payment to a variable of type never: if every kind has a case, nothing can reach it.
Why it works
A union of object types whose members share a literal-typed property is a discriminated union. When you compare that property with a literal, TypeScript removes the members that cannot match, inside that branch. After the three cases nothing is left, so payment has the type never in default, and assigning it to a never variable is allowed.
The same thing in TypeScript
The TypeScript equivalent tab writes the members as type aliases and the check as const unhandled: never = payment. In JavaScript each member is a @typedef, and the never annotation is a @type tag on the variable.
What goes wrong
Reading a field before narrowing is an error, because not every member has it (TS2339). And when a member has no case (here the voucher’s case is missing), it reaches default, and the never check reports it by name (TS2322):
/** @typedef {{ kind: "card", last4: string }} CardPayment */
/** @typedef {{ kind: "invoice", dueDays: number }} InvoicePayment */
/** @typedef {{ kind: "voucher", code: string }} VoucherPayment */
/** @typedef {CardPayment | InvoicePayment | VoucherPayment} Payment */
/**
* @param {Payment} payment
* @returns {string}
*/
export function describePayment(payment) {
console.log(payment.last4);error TS2339: Property 'last4' does not exist on type 'Payment'.
Property 'last4' does not exist on type 'InvoicePayment'.
switch (payment.kind) {
case "card":
return `Card ending in ${payment.last4}`;
case "invoice":
return `Invoice, due in ${payment.dueDays} days`;
default: {
/** @type {never} */
const unhandled = payment;error TS2322: Type 'VoucherPayment' is not assignable to type 'never'.
return unhandled;
}
}
}jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} That second error is why the default branch is worth writing: add a fourth kind of payment later, and tsc lists every switch that does not handle it yet.
Gotchas
- Several
@typedeftags can share a comment. One block with the three members and the union reads well too; tsc reads each tag as its own type. - A destructured
kindnarrows only from aconst.const { kind } = paymentfollowed byif (kind === "card")narrowspaymenttoo (TypeScript 4.4 added this); withlet { kind }it does not, andpayment.last4is TS2339 again. @returnsneeds every path to return. Without thedefaultbranch, a function with a@returnstype whoseswitchcovers every case still passes; with anifchain instead of aswitchtsc reports TS2366 at the end.
Questions
Does the discriminant have to be called kind?
No. Any property works if every member of the union has it with a different literal type: type, status and kind are common choices.
Can I narrow with if instead of switch?
Yes: if (payment.kind === "card") narrows inside the block, and an early return narrows the rest of the function. Only the exhaustiveness check needs a place where every case has been handled, such as a default branch or the code after the last if.
Sources
- Narrowing (discriminated unions and exhaustiveness checking) · TypeScript handbook
- TypeScript 4.4 release notes (aliased conditions and discriminants) · TypeScript handbook
- JSDoc Reference (@typedef) · TypeScript handbook
Last reviewed 21 October 2026.