Skip to main content
Back to Blog
Automation TutorialsTypeScript Quality Engineering · Part 4

Test TypeScript Discriminated Unions as State Machines

A discriminated union rules out impossible shapes, but not illegal moves between legal ones or unknown statuses from the server. Using a payment flow, this guide covers deriving the union from a Zod discriminated-union schema, the exact issue Zod reports for an unknown discriminant, why a default branch absorbs a new variant and what assertNever and switch-exhaustiveness-check do instead, a transition table typed with satisfies, generated tests for all sixteen pairs, and why rejected events should return the same object.

Shashank Rawlani

Engineering Leader | Builder | Problem Solver | AI Engineer

Published Oct 7, 2026Updated Oct 14, 202618 min read
View AI-ready Markdown
Dark technical illustration over a faint dashed vertical grid. On the left, four green pills enter from the edge and their curves converge on a tall dashed green gate holding a ringed green dot; below them an orange pill runs into a crossed-out orange ring at the foot of the gate. From the gate an arrow leads into a dashed green frame around a four-by-four grid of rounded cells: four cells glow green with a dot inside, while the other twelve are dim with a short grey pill. From the grid a line reaches a small ringed dot, then an arrow to a larger one that forks up to one ringed dot and down to another. A solid green curve loops from the lower dot back to the fork, and a dashed orange curve arcs from the upper dot back toward the fork, crossed out midway by an orange ring. A green line leaves the upper dot and ends in an arrowhead at the right edge.
Quick answer: Treat the union as a state machine and test the machine, not the pictures of it. Decode server data into the union with a discriminated-union schema instead of casting it. End every switch over the discriminant with an assertNever call instead of a default: that returns something plausible. Write the allowed transitions down as a table typed with satisfies Record<Status, Record<EventType, Status | null>>, generate one test per (state, event) pair from it, and make rejected events return the same object so a test can tell "ignored" from "replaced with an identical copy". Run tsc --noEmit in CI, because the compiler is what forces a new variant into the table.
Vitest does not type-check your tests unless you ask it to. A plain vitest run executes your tests without checking their types, so every compile-time guarantee in this article is only enforced if tsc --noEmit (or vitest --typecheck) runs in the same pipeline. Versions checked on 2026-09-30: TypeScript 7.0.2, Zod 4.6.5, Vitest 5.0.2, typescript-eslint 8.71.0 and React 19.3.0. The examples below compile under TypeScript 7.0.2 and pass under Vitest 5.0.2 with Zod 4.6.5. The lint rule was exercised on TypeScript 6.0.3, because typescript-eslint 8.71.0 declares support for TypeScript >=4.8.4 <6.1.0 and not yet for 7.0.

A checkout screen has four component tests, one per payment state, each rendering a fixture and snapshotting the result. All four pass. In production a customer double-clicks Pay, the second click arrives while the first request is in flight, and the reducer quietly starts a second attempt. Nothing any of the four snapshots showed was wrong. The rendered output of "processing, attempt 1" and "a fresh processing, attempt 1" is identical.

The union type did its job: there was never an impossible state. A discriminated union rules out impossible shapes at compile time. It says nothing about which event may move you from one legal shape to another, and nothing about what arrives over the network, and those two gaps are where payment bugs live.

The worked example throughout is a payment flow: idle to processing, then succeeded or failed, with a retry from failed. Four states and four events give sixteen pairs, and every one of them gets a test.

Derive the union from the decoder, not the other way round

Start with the states and the events. The states are declared once, as a Zod schema, and the TypeScript type is inferred from it. The events never cross a network boundary in this example, so they stay a plain type.

import * as z from "zod";

export const PaymentStateSchema = z.discriminatedUnion("status", [
  z.object({ status: z.literal("idle") }),
  z.object({ status: z.literal("processing"), attempt: z.number().int().positive() }),
  z.object({ status: z.literal("succeeded"), receiptId: z.string().min(1) }),
  z.object({ status: z.literal("failed"), attempt: z.number().int().positive(), reason: z.string() }),
]);

export type PaymentState = z.infer<typeof PaymentStateSchema>;

export type PaymentEvent =
  | { type: "SUBMIT" }
  | { type: "RESOLVE"; receiptId: string }
  | { type: "REJECT"; reason: string }
  | { type: "RETRY" };

export type Status = PaymentState["status"];
export type EventType = PaymentEvent["type"];

The direction matters more than it looks. The common alternative is a hand-written union plus a schema checked against it with satisfies z.ZodType<PaymentState>. That check is one-sided. Add a refunded member to the hand-written type, leave the schema alone, and the schema still satisfies the type: a decoder that can only produce four of the five variants is assignable to a type that allows five. The compiler reports nothing, and the client can never decode a refund. Inferring the type from the schema leaves one list of variants, so adding one in the schema is adding it everywhere.

The TypeScript handbook describes when narrowing kicks in: "When every type in a union contains a common property with literal types, TypeScript considers that to be a discriminated union, and can narrow out the members of the union." Here that property is status. Everything below depends on it being a literal in every member.

A cast lets requires_action through

The first place the union is broken is the fetch. The DOM typings that ship with TypeScript declare json(): Promise<any> on Response, and MDN's description of the return value is that the parsed object "could be anything that can be represented by JSON". So the familiar line below does not even need its cast to compile. The as only documents a hope.

// Wrong: the server's word is taken as the type
const res = await fetch(`/api/payments/${id}`);
const state = (await res.json()) as PaymentState;
dispatchView(toView(state));

When the payments service adds a requires_action status for a card that needs authentication, this code hands an object with an unknown discriminant to every switch in the client. What happens next depends on how those switches end, which is the next section. The fix is to make the boundary refuse it:

export type LoadResult =
  | { ok: true; state: PaymentState }
  | { ok: false; issues: z.core.$ZodIssue[] };

export async function loadPayment(id: string, fetchImpl: typeof fetch = fetch): Promise<LoadResult> {
  const res = await fetchImpl(`/api/payments/${id}`);
  const parsed = PaymentStateSchema.safeParse(await res.json());
  return parsed.success
    ? { ok: true, state: parsed.data }
    : { ok: false, issues: parsed.error.issues };
}

A decode failure is deliberately not mapped to one of the four states. Coercing an unknown status to failed would show a Try again button to someone whose payment may already be authorised, which is the double charge you were trying to prevent. The caller gets an ok: false it has to handle, and the machine only ever sees values it has a row for.

Zod's report here is more specific than "none of the options matched". With Zod 4.6.5, an input whose discriminator matches no option produces exactly one issue: code invalid_union, path ["status"], an empty errors array, a note of No matching discriminator, and the list of accepted values. The parser looks the option up by the discriminator value and stops there, so the issue is stable enough to assert on:

test("an unknown status is refused at the boundary, not coerced", async () => {
  const fakeFetch = async () => Response.json({ status: "requires_action", attempt: 1 });
  const result = await loadPayment("pay_1", fakeFetch as typeof fetch);

  expect(result.ok).toBe(false);
  if (!result.ok) {
    expect(result.issues).toMatchObject([
      { code: "invalid_union", path: ["status"], note: "No matching discriminator" },
    ]);
  }
});

A known status with a missing field fails differently: { status: "succeeded" } yields an invalid_type issue on ["receiptId"]. The first means the server knows a state the client does not; the second means a known state arrived malformed. Your error reporting should keep them apart. Validation of payloads in general, beyond the discriminant, is covered in testing TypeScript's runtime boundaries.

The default: branch that absorbs refunded

Every switch over the discriminant is a place a new variant can go missing. This renderer turns a state into a small view model. It reads well and it passes every test written today:

// Wrong: the last case is written as "everything else"
export function toView(state: PaymentState): PaymentView {
  switch (state.status) {
    case "idle":
      return { label: "Pay", action: "SUBMIT", tone: "neutral" };
    case "processing":
      return { label: "Paying…", action: null, tone: "busy" };
    case "succeeded":
      return { label: `Paid · ${state.receiptId}`, action: null, tone: "success" };
    default:
      return { label: "Try again", action: "RETRY", tone: "error" };
  }
}

Now add refunded to the schema. This function still compiles, because the default branch accepts failed | refunded and returns a constant that fits either. A refunded customer is shown a red Try again button wired to RETRY. The handbook's exhaustiveness pattern turns that silent absorption into a compile error, because "no type is assignable to never (except never itself)":

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

export function toView(state: PaymentState): PaymentView {
  switch (state.status) {
    case "idle":
      return { label: "Pay", action: "SUBMIT", tone: "neutral" };
    case "processing":
      return { label: "Paying…", action: null, tone: "busy" };
    case "succeeded":
      return { label: `Paid · ${state.receiptId}`, action: null, tone: "success" };
    case "failed":
      return { label: "Try again", action: "RETRY", tone: "error" };
    default:
      return assertNever(state);
  }
}

With the refund variant added to the schema, TypeScript 7.0.2 stops at both switches:

src/payment.ts(39,26): error TS2345: Argument of type '{ status: "refunded"; receiptId: string; }' is not assignable to parameter of type 'never'.
src/payment.ts(60,26): error TS2345: Argument of type '{ status: "refunded"; receiptId: string; }' is not assignable to parameter of type 'never'.

The throw inside assertNever is not dead code. If a cast anywhere in the codebase lets an undeclared status in, the parameter's never type is a fiction at runtime and the throw is what stops the value being rendered as something it is not.

typescript-eslint's switch-exhaustiveness-check rule catches the same mistake without the helper. It needs type information, and its defaults matter here: considerDefaultExhaustiveForUnions is false, so a default: does not count as handling a union member, and the wrong renderer above is reported as Switch is not exhaustive. Cases not matched: "failed" | "refunded". Flip that option to true and the same switch passes the lint. allowDefaultCaseForExhaustiveSwitch defaults to true, which is what lets an exhaustive switch keep its assertNever default. The rule's own documentation gives the reason to keep one: a server "running a newer software version" can "send a value not recognized by the client's older typings". That is the requires_action case again, and it is why decoding at the boundary and a throwing default are complements, not alternatives.

One practical limit as of today: typescript-eslint 8.71.0 declares support for TypeScript up to but not including 6.1. A project already on 7.0 is running the rule on an unsupported compiler, and the parser logs a warning about it. assertNever is enforced by tsc itself, so it holds on any version.

The transition table, typed with satisfies

The reducer is the code under test. The table is the specification it is tested against, written independently so the test is not the reducer checking itself. Each cell names the status the machine should reach, or null for an event that state must reject:

import type { EventType, PaymentEvent, PaymentState, Status } from "./payment";

export const transitions = {
  idle:       { SUBMIT: "processing", RESOLVE: null,        REJECT: null,     RETRY: null },
  processing: { SUBMIT: null,         RESOLVE: "succeeded", REJECT: "failed", RETRY: null },
  succeeded:  { SUBMIT: null,         RESOLVE: null,        REJECT: null,     RETRY: null },
  failed:     { SUBMIT: null,         RESOLVE: null,        REJECT: null,     RETRY: "processing" },
} as const satisfies Record<Status, Record<EventType, Status | null>>;

export const sampleState = {
  idle: { status: "idle" },
  processing: { status: "processing", attempt: 1 },
  succeeded: { status: "succeeded", receiptId: "rcpt_1" },
  failed: { status: "failed", attempt: 1, reason: "card_declined" },
} satisfies { [S in Status]: Extract<PaymentState, { status: S }> };

export const sampleEvent = {
  SUBMIT: { type: "SUBMIT" },
  RESOLVE: { type: "RESOLVE", receiptId: "rcpt_1" },
  REJECT: { type: "REJECT", reason: "card_declined" },
  RETRY: { type: "RETRY" },
} satisfies { [E in EventType]: Extract<PaymentEvent, { type: E }> };

The TypeScript 4.9 release notes introduced satisfies as a way to "validate that the type of an expression matches some type, without changing the resulting type of that expression", and used a Record over a union of keys to show both halves of what you want here: a missing key is an error, and so is one "never listed". A Record<Status, …> annotation would check completeness too, but it would widen every cell to Status | null; with as const satisfies the cell for idle and SUBMIT keeps the literal type "processing", so you can still derive narrower types from the table later.

The two sample maps use a mapped type so each fixture must be the matching variant. Add refunded to the schema and the compiler reports Property 'refunded' is missing on the table and on sampleState, until someone decides what a refunded payment does with each of the four events. The type system can enumerate the pairs; it cannot say whether RETRY after a refund should be allowed.

One generated test per pair

Flatten the table into cases and hand them to test.each. Vitest lets you "access object properties and array elements with $ prefix" in the title, so each generated test names its own pair:

import { expect, test } from "vitest";
import { PaymentStateSchema, paymentReducer } from "./payment";
import type { EventType, Status } from "./payment";
import { sampleEvent, sampleState, transitions } from "./transitions";

const cases = (Object.keys(transitions) as Status[]).flatMap((from) =>
  (Object.keys(transitions[from]) as EventType[]).map((event) => ({
    from,
    event,
    to: transitions[from][event] as Status | null,
  })),
);
const accepted = cases.filter((c) => c.to !== null);
const rejected = cases.filter((c) => c.to === null);

test("the table has a row for every variant the schema decodes", () => {
  const decodable = PaymentStateSchema.options.flatMap((o) => [...o.shape.status.values]);
  expect(Object.keys(transitions).sort()).toEqual(decodable.sort());
  expect(cases).toHaveLength(decodable.length * Object.keys(sampleEvent).length);
});

test.each(accepted)("$from + $event -> $to", ({ from, event, to }) => {
  expect(paymentReducer(sampleState[from], sampleEvent[event]).status).toBe(to);
});

test.each(rejected)("$from ignores $event", ({ from, event }) => {
  const before = sampleState[from];
  expect(paymentReducer(before, sampleEvent[event])).toBe(before);
});

That yields four accepted tests with names like processing + REJECT -> failed and twelve rejection tests like succeeded ignores SUBMIT. The first test is there for the gap the warning callout describes. Add refunded to the schema and leave the table alone, and tsc reports errors in the reducer, the renderer, the table and the fixtures. vitest run, which does not type-check, would pass all sixteen pair tests, because Object.keys(transitions) still has four rows at runtime. The parity test reads the variants back out of the schema through .options and each literal's .values set, so the runtime suite fails too:

FAIL  src/payment.test.ts > the table has a row for every variant the schema decodes
AssertionError: expected [ Array(4) ] to deeply equal [ Array(5) ]
-   "refunded",
Try this in Test Failure Triage

A target status does not capture payload. The table says RETRY from failed reaches processing; it does not say the attempt counter goes up. Keep one ordinary example-based test for each payload rule, and let the table carry only the shape of the machine:

test("a retry counts as a new attempt", () => {
  expect(paymentReducer(sampleState.failed, sampleEvent.RETRY)).toEqual({ status: "processing", attempt: 2 });
});

succeeded + SUBMIT returns the same object

A rejected event has two defensible outcomes. The reducer can return the state it was given, unchanged and by reference. Or it can return an explicit rejection, something like { state, rejected: "SUBMIT" }, that the caller can log. This article takes the first position, for a reason that comes from React: useReducer compares the result "as determined by an Object.is comparison" and will "skip re-rendering the component and its children" when it is identical. Returning the same object makes a rejected event free. A rejection wrapper is a new object every time, so every ignored double-click re-renders the form.

The same-reference rule also makes the test sharper than a status check. Here is the double-submit bug from the opening, written into the processing branch:

case "processing":
  if (event.type === "RESOLVE") return { status: "succeeded", receiptId: event.receiptId };
  if (event.type === "REJECT") return { status: "failed", attempt: state.attempt, reason: event.reason };
  if (event.type === "SUBMIT") return { status: "processing", attempt: 1 }; // restarts the attempt
  return state;

A status assertion passes: it is still processing. A snapshot of the rendered view passes: the view is identical. The generated identity test fails, and its message shows exactly why a deep comparison would not have:

FAIL  src/payment.test.ts > processing ignores SUBMIT
AssertionError: expected { status: 'processing', attempt: 1 } to be { status: 'processing', attempt: 1 } // Object.is equality

The cost of this position is that rejection is silent. A component that dispatches RESOLVE while idle, which is a bug in the component, produces no signal at all. If you need to see rejected events, count them in the code that calls dispatch by checking the table first; do not log from the reducer. React requires reducers to be pure, and in Strict Mode it calls them twice "to help you find accidental impurities", so a log line inside one fires twice in development.

Render the states the reducer reaches, not only the fixtures

One snapshot per state proves that four hand-built objects render. It says nothing about whether the renderer and the machine agree. The useful question is narrower: does any view offer the user an action the current state would reject? Build the list of states from the reducer's own output and check every offered action against the table:

const reachable = [
  sampleState.idle,
  ...accepted.map(({ from, event }) => paymentReducer(sampleState[from], sampleEvent[event])),
];

test.each(reachable)("the $status view offers only an accepted event", (state) => {
  const { action } = toView(state);
  if (action !== null) {
    expect(transitions[state.status][action]).not.toBeNull();
  }
});

This is the test that makes the rejection policy safe. If no view ever offers a rejected event, the silent drop only swallows genuine races such as a second click, which is the case you wanted to swallow. It also catches the renderer and table drifting apart: point the failed view's button at SUBMIT instead of RETRY and the failed case goes red.

React stays optional: toView is a pure function, so the suite runs in Node with no DOM. In a component the machine plugs in as useReducer(paymentReducer, { status: "idle" }), and the component test only has to prove the button dispatches view.action. Which Vitest environment that component test belongs in is a separate decision, covered in choosing between Vitest browser mode and Playwright.

Guards the table cannot hold

The table models transitions that depend only on the discriminant. Real payment flows also have guards that depend on payload: allow RETRY only while attempt < 3, or refuse RESOLVE whose receipt belongs to another payment. A Status | null cell cannot express either. You can split the state (failed and failedFinal), which keeps the table honest and costs a variant; or keep the guard in the reducer and give it its own example tests at the boundary values. Split when the UI renders the two cases differently; otherwise the extra variant is noise.

An assistant can read the two union types and draft all sixteen rows in seconds, and that draft is a reasonable starting point. It will fill cells by plausibility. Whether a succeeded payment accepts a second SUBMIT for a new order, or whether RETRY reuses the idempotency key, is a product rule, and the person who owns that rule has to sign off each null.

For a two-state toggle none of this earns its keep; a boolean and two tests are fine. The table starts to pay at the point where a reviewer can no longer hold every (state, event) pair in their head. Past that point, a written table is cheaper than everyone re-deriving it in review.

Questions about testing unions as machines

Is the transition table the reducer written twice?

It duplicates the decisions, which is the point: two independent statements of the same rule that must agree. It does not duplicate the payload construction, which is where reducers get long. If you generate the reducer from the table instead, the table-driven tests become tautological and you are back to needing example tests for the table itself.

Why z.discriminatedUnion rather than z.union?

Zod's documentation notes that a regular union checks the input "against each option in order" and returns the first that passes, while the discriminated form uses the key to go straight to one option. For this article the error shape matters more than the speed: an unknown status becomes one issue on ["status"] that you can assert on, instead of a failure for every option.

Can Vitest's --typecheck replace a separate tsc step?

Vitest's type-checking mode runs tsc under the hood and, per its docs, "will also print out type errors in your source code". It is aimed at type tests in *.test-d.ts files, a topic of its own later in this series. A plain tsc --noEmit in CI is the simpler guarantee for the errors this article relies on.

Put one reducer under a table today

Pick the reducer in your codebase whose bugs cost the most, usually anything touching payment, submission or auth. Replace any trailing default: in its switches with assertNever, find the as on the fetch that feeds it and put a schema there, then write its transition table and the three generated tests above. When you capture a real response to build the schema, compare it against your fixture in the JSON Diff tool so the variant list comes from the server rather than memory.

The evidence to keep is two lines of output: tsc --noEmit exiting 0, and a Vitest run in which the number of pair tests equals states times events. Then add a throwaway variant to the schema and confirm both fail before you delete it. If either stays green, the guarantee you think you have is not wired up.

Primary references

  • TypeScript Handbook — Narrowing. The definition of a discriminated union ("When every type in a union contains a common property with literal types, TypeScript considers that to be a discriminated union"), that narrowing on the discriminant works in switch statements, and the exhaustiveness pattern: "no type is assignable to never (except never itself)", with a new member producing a "not assignable to type 'never'" error in the default branch.
  • TypeScript 4.9 release notes — The satisfies Operator. That satisfies validates "that the type of an expression matches some type, without changing the resulting type of that expression", and the Record-over-a-key-union example that reports both a missing key and a key "never listed".
  • TypeScript 7.0.2 release (microsoft/TypeScript). The current TypeScript release at the time of checking, the compiler the examples and error output were produced with.
  • TypeScript 7.0.2 — lib.dom declarations. That Response.json() is declared as returning Promise<any>, which is why a cast on it is unchecked.
  • MDN — Response: json() method. That the promise resolves to an object that "could be anything that can be represented by JSON".
  • typescript-eslint — switch-exhaustiveness-check. That the rule requires type information; the option names and defaults (allowDefaultCaseForExhaustiveSwitch: true, considerDefaultExhaustiveForUnions: false, requireDefaultForNonUnion: false, defaultCaseCommentPattern); and the caveat that a server "running a newer software version" can "send a value not recognized by the client's older typings". Documentation for version 8.71.0.
  • typescript-eslint — Dependency Versions. That the supported TypeScript range is >=4.8.4 <6.1.0 and that the parser logs a warning on an unsupported version.
  • Zod 4 — Defining schemas. The z.discriminatedUnion() API and its contrast with a regular union that checks "each option in order"; .options on union schemas and .values on literal schemas, used by the parity test.
  • Zod 4.6.5 — core schemas source. The issue a discriminated union emits for an unmatched discriminator: code invalid_union, empty errors, note No matching discriminator, path set to the discriminator key, unless unionFallback is enabled.
  • Vitest — Test API. That test.each accepts an array of objects and that the title can "access object properties and array elements with $ prefix". Documentation for Vitest 5.0.2.
  • Vitest — Testing Types. That type checking is enabled with the --typecheck flag, that Vitest "calls tsc or vue-tsc" under the hood, and that *.test-d.ts files are treated as type tests by default.
  • React — useReducer. That a reducer "must be pure", that Strict Mode calls it twice "to help you find accidental impurities", and that an identical next state "as determined by an Object.is comparison" lets React "skip re-rendering the component and its children".

TypeScript Quality Engineering

Get part 5: Test React Server and Client Boundaries in Next.js

It is live now: read part 5. The form below subscribes you to The Automation Brief, the site newsletter.

No spam, no sending cadence promised. Unsubscribe any time.