Recipe · Objects and data

How do I narrow a union of object types in JavaScript?

  • Node.js (ES modules)
  • Discriminated unions
  • @typedef
  • never
  • Checked with TypeScript 5.9

The problem A payment can be a card, an invoice or a voucher, each with different fields. How do I describe that in JSDoc so that each branch of my switch knows which fields exist, and so a new kind cannot be forgotten?

The short answer

Give every member of the union the same property with a different string literal (kind here), and switch on it:

payments.js payments.ts TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. No errors. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. No errors. checked 21 October 2026
/** @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;
    }
  }
}
A switch on the kind narrows each case.
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):

payments.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: payments.js(12,23): error TS2339: Property 'last4' does not exist on type 'Payment'. Property 'last4' does not exist on type 'InvoicePayment'. payments.js(21,13): error TS2322: Type 'VoucherPayment' is not assignable to type 'never'. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. payments.js(12,23): error TS2339: Property 'last4' does not exist on type 'Payment'. Property 'last4' does not exist on type 'InvoicePayment'. payments.js(21,13): error TS2322: Type 'VoucherPayment' is not assignable to type 'never'. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. payments.js(12,23): error TS2339: Property 'last4' does not exist on type 'Payment'. Property 'last4' does not exist on type 'InvoicePayment'. payments.js(21,13): error TS2322: Type 'VoucherPayment' is not assignable to type 'never'. checked 21 October 2026
/** @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;
    }
  }
}
A member read before narrowing, and a new kind nobody handles.
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 @typedef tags 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 kind narrows only from a const. const { kind } = payment followed by if (kind === "card") narrows payment too (TypeScript 4.4 added this); with let { kind } it does not, and payment.last4 is TS2339 again.
  • @returns needs every path to return. Without the default branch, a function with a @returns type whose switch covers every case still passes; with an if chain instead of a switch tsc 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

Last reviewed 21 October 2026.