Type expression

Discriminated unions

  • Combining types
  • Tested with TypeScript 5.9

A discriminated union is a union of object types that all have one property with a different literal value in each: kind: "circle" or kind: "square". Testing that property, in a switch or an if, tells TypeScript which member you have, and inside that branch only its properties are available.

It is the type-safe way to model data that comes in variants: shapes, events, API results, the states of a request. A property that belongs to another variant is TS2339 in the wrong branch, as the second example shows.

At a glance

Use JSDoc in a .js file TypeScript in a .ts file
A tagged union /** @typedef {{ kind: "circle", radius: number } | { kind: "square", side: number }} Shape */ type Shape = { kind: "circle"; radius: number } | { kind: "square"; side: number };
A result type /** * @template T * @typedef {{ ok: true, data: T } | { ok: false, error: string }} Result */ type Result<T> = { ok: true; data: T } | { ok: false; error: string };

Examples

shapes.js shapes.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: "circle", radius: number }
 *   | { kind: "square", side: number }
 *   | { kind: "rect", width: number, height: number }} Shape
 */

/**
 * @param {Shape} shape
 * @returns {number}
 */
export function area(shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    case "rect":
      return shape.width * shape.height;
  }
}

area({ kind: "circle", radius: 2 });
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rect"; width: number; height: number };

export function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    case "rect":
      return shape.width * shape.height;
  }
}

area({ kind: "circle", radius: 2 });
A union told apart by its kind property.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": []
  },
  "include": ["*.js"]
}
shapes.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: shapes.js(7,18): error TS2339: Property 'radius' does not exist on type '{ kind: "square"; side: number; }'. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. shapes.js(7,18): error TS2339: Property 'radius' does not exist on type '{ kind: "square"; side: number; }'. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. shapes.js(7,18): error TS2339: Property 'radius' does not exist on type '{ kind: "square"; side: number; }'. checked 21 October 2026
/** @typedef {{ kind: "circle", radius: number } | { kind: "square", side: number }} Shape */

/** @param {Shape} shape */
export function width(shape) {
  if (shape.kind === "square") {
    return shape.radius * 2;error TS2339: Property 'radius' does not exist on type '{ kind: "square"; side: number; }'.
  }
  return shape.radius * 2;
}
Each branch sees only its own members.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": []
  },
  "include": ["*.js"]
}
load.js 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
/**
 * @template T
 * @typedef {{ ok: true, data: T } | { ok: false, error: string }} Result
 */

/**
 * @param {string} url
 * @returns {Promise<Result<unknown>>}
 */
export async function getJson(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) return { ok: false, error: `HTTP ${response.status}` };
    return { ok: true, data: await response.json() };
  } catch (error) {
    return { ok: false, error: error instanceof Error ? error.message : String(error) };
  }
}

const result = await getJson("/api/cart");
if (result.ok) console.log(result.data);
else console.warn(result.error);
A result type for code that can fail.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": []
  },
  "include": ["*.js"]
}

Writing them in JSDoc

Write the union in a @typedef. Long unions can span several lines inside the comment, as in the first example; the leading | on each continuation line is allowed. Each member is an object type with the discriminant as a literal type.

For a generic result type, combine with @template: { ok: true, data: T } | { ok: false, error: string } makes callers check result.ok before they can read data, as the third example does.

Handling every case

The area function in the first example has no default and no final return, yet TypeScript accepts its @returns {number}: the switch covers every kind, so the end of the function is unreachable. Adding a fourth shape without a case makes that an error. A default that passes the value to a never parameter makes the check explicit.

Gotchas

  • The discriminant must be a literal type in each member. If one member has kind: string, nothing narrows.
  • Every member needs the discriminant property: if one member lacks kind, reading shape.kind before narrowing is already an error.

Questions

How do I write a tagged union in JSDoc?

As a @typedef of object types that each have the same property with a different literal value, for example {{ type: "click", x: number } | { type: "key", key: string }}.

Sources

Last reviewed 20 October 2026.