{"title":"Test TypeScript Discriminated Unions as State Machines","excerpt":"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.","canonicalUrl":"https://automationtester.in/blog/automation-tutorials/test-typescript-discriminated-unions-state-machines","category":{"name":"Automation Tutorials","slug":"automation-tutorials"},"tags":["typescript","vitest","zod","state-machines","unit-testing","react"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-10-07T13:30:00.000Z","dateModified":"2026-10-14T11:09:52.936Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/test-typescript-discriminated-unions-state-machines.webp","alt":"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."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> 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 <code class=\"language-text\">assertNever</code> call instead of a <code class=\"language-text\">default:</code> that returns something plausible. Write the allowed transitions down as a table typed with <code class=\"language-text\">satisfies Record&lt;Status, Record&lt;EventType, Status | null&gt;&gt;</code>, 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 <code class=\"language-text\">tsc --noEmit</code> in CI, because the compiler is what forces a new variant into the table.</div>\n\n<div class=\"callout callout-warning\"><strong>Vitest does not type-check your tests unless you ask it to.</strong> A plain <code class=\"language-text\">vitest run</code> executes your tests without checking their types, so every compile-time guarantee in this article is only enforced if <code class=\"language-text\">tsc --noEmit</code> (or <code class=\"language-text\">vitest --typecheck</code>) 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 <code class=\"language-text\">&gt;=4.8.4 &lt;6.1.0</code> and not yet for 7.0.</div>\n\n<p>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 <em>Pay</em>, 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.</p>\n\n<p>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.</p>\n\n<p>The worked example throughout is a payment flow: <em>idle</em> to <em>processing</em>, then <em>succeeded</em> or <em>failed</em>, with a retry from failed. Four states and four events give sixteen pairs, and every one of them gets a test.</p>\n\n<h2 id=\"derive-the-union-from-the-decoder\">Derive the union from the decoder, not the other way round</h2>\n\n<p>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.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">import * as z from \"zod\";\n\nexport const PaymentStateSchema = z.discriminatedUnion(\"status\", [\n  z.object({ status: z.literal(\"idle\") }),\n  z.object({ status: z.literal(\"processing\"), attempt: z.number().int().positive() }),\n  z.object({ status: z.literal(\"succeeded\"), receiptId: z.string().min(1) }),\n  z.object({ status: z.literal(\"failed\"), attempt: z.number().int().positive(), reason: z.string() }),\n]);\n\nexport type PaymentState = z.infer&lt;typeof PaymentStateSchema&gt;;\n\nexport type PaymentEvent =\n  | { type: \"SUBMIT\" }\n  | { type: \"RESOLVE\"; receiptId: string }\n  | { type: \"REJECT\"; reason: string }\n  | { type: \"RETRY\" };\n\nexport type Status = PaymentState[\"status\"];\nexport type EventType = PaymentEvent[\"type\"];</code></pre>\n\n<p>The direction matters more than it looks. The common alternative is a hand-written union plus a schema checked against it with <code class=\"language-text\">satisfies z.ZodType&lt;PaymentState&gt;</code>. That check is one-sided. Add a <code class=\"language-text\">refunded</code> 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.</p>\n\n<p>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 <code class=\"language-text\">status</code>. Everything below depends on it being a literal in every member.</p>\n\n<h2 id=\"a-cast-lets-requires-action-through\">A cast lets <code class=\"language-text\">requires_action</code> through</h2>\n\n<p>The first place the union is broken is the fetch. The DOM typings that ship with TypeScript declare <code class=\"language-text\">json(): Promise&lt;any&gt;</code> on <code class=\"language-text\">Response</code>, 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 <code class=\"language-text\">as</code> only documents a hope.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// Wrong: the server's word is taken as the type\nconst res = await fetch(`/api/payments/${id}`);\nconst state = (await res.json()) as PaymentState;\ndispatchView(toView(state));</code></pre>\n\n<p>When the payments service adds a <code class=\"language-text\">requires_action</code> 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:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">export type LoadResult =\n  | { ok: true; state: PaymentState }\n  | { ok: false; issues: z.core.$ZodIssue[] };\n\nexport async function loadPayment(id: string, fetchImpl: typeof fetch = fetch): Promise&lt;LoadResult&gt; {\n  const res = await fetchImpl(`/api/payments/${id}`);\n  const parsed = PaymentStateSchema.safeParse(await res.json());\n  return parsed.success\n    ? { ok: true, state: parsed.data }\n    : { ok: false, issues: parsed.error.issues };\n}</code></pre>\n\n<p>A decode failure is deliberately not mapped to one of the four states. Coercing an unknown status to <code class=\"language-text\">failed</code> would show a <em>Try again</em> button to someone whose payment may already be authorised, which is the double charge you were trying to prevent. The caller gets an <code class=\"language-text\">ok: false</code> it has to handle, and the machine only ever sees values it has a row for.</p>\n\n<p>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 <code class=\"language-text\">invalid_union</code>, path <code class=\"language-text\">[\"status\"]</code>, an empty <code class=\"language-text\">errors</code> array, a <code class=\"language-text\">note</code> of <code class=\"language-text\">No matching discriminator</code>, 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:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">test(\"an unknown status is refused at the boundary, not coerced\", async () =&gt; {\n  const fakeFetch = async () =&gt; Response.json({ status: \"requires_action\", attempt: 1 });\n  const result = await loadPayment(\"pay_1\", fakeFetch as typeof fetch);\n\n  expect(result.ok).toBe(false);\n  if (!result.ok) {\n    expect(result.issues).toMatchObject([\n      { code: \"invalid_union\", path: [\"status\"], note: \"No matching discriminator\" },\n    ]);\n  }\n});</code></pre>\n\n<p>A known status with a missing field fails differently: <code class=\"language-text\">{ status: \"succeeded\" }</code> yields an <code class=\"language-text\">invalid_type</code> issue on <code class=\"language-text\">[\"receiptId\"]</code>. 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 <a href=\"/blog/typescript-runtime-boundary-validation-testing\">testing TypeScript's runtime boundaries</a>.</p>\n\n<h2 id=\"the-default-branch-that-absorbs-refunded\">The <code class=\"language-text\">default:</code> branch that absorbs <code class=\"language-text\">refunded</code></h2>\n\n<p>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:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// Wrong: the last case is written as \"everything else\"\nexport function toView(state: PaymentState): PaymentView {\n  switch (state.status) {\n    case \"idle\":\n      return { label: \"Pay\", action: \"SUBMIT\", tone: \"neutral\" };\n    case \"processing\":\n      return { label: \"Paying…\", action: null, tone: \"busy\" };\n    case \"succeeded\":\n      return { label: `Paid · ${state.receiptId}`, action: null, tone: \"success\" };\n    default:\n      return { label: \"Try again\", action: \"RETRY\", tone: \"error\" };\n  }\n}</code></pre>\n\n<p>Now add <code class=\"language-text\">refunded</code> to the schema. This function still compiles, because the <code class=\"language-text\">default</code> branch accepts <code class=\"language-text\">failed | refunded</code> and returns a constant that fits either. A refunded customer is shown a red <em>Try again</em> button wired to <code class=\"language-text\">RETRY</code>. The handbook's exhaustiveness pattern turns that silent absorption into a compile error, because \"no type is assignable to never (except never itself)\":</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">export function assertNever(value: never): never {\n  throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);\n}\n\nexport function toView(state: PaymentState): PaymentView {\n  switch (state.status) {\n    case \"idle\":\n      return { label: \"Pay\", action: \"SUBMIT\", tone: \"neutral\" };\n    case \"processing\":\n      return { label: \"Paying…\", action: null, tone: \"busy\" };\n    case \"succeeded\":\n      return { label: `Paid · ${state.receiptId}`, action: null, tone: \"success\" };\n    case \"failed\":\n      return { label: \"Try again\", action: \"RETRY\", tone: \"error\" };\n    default:\n      return assertNever(state);\n  }\n}</code></pre>\n\n<p>With the refund variant added to the schema, TypeScript 7.0.2 stops at both switches:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">src/payment.ts(39,26): error TS2345: Argument of type '{ status: \"refunded\"; receiptId: string; }' is not assignable to parameter of type 'never'.\nsrc/payment.ts(60,26): error TS2345: Argument of type '{ status: \"refunded\"; receiptId: string; }' is not assignable to parameter of type 'never'.</code></pre>\n\n<p>The <code class=\"language-text\">throw</code> inside <code class=\"language-text\">assertNever</code> is not dead code. If a cast anywhere in the codebase lets an undeclared status in, the parameter's <code class=\"language-text\">never</code> type is a fiction at runtime and the throw is what stops the value being rendered as something it is not.</p>\n\n<p>typescript-eslint's <code class=\"language-text\">switch-exhaustiveness-check</code> rule catches the same mistake without the helper. It needs type information, and its defaults matter here: <code class=\"language-text\">considerDefaultExhaustiveForUnions</code> is <code class=\"language-text\">false</code>, so a <code class=\"language-text\">default:</code> does not count as handling a union member, and the wrong renderer above is reported as <code class=\"language-text\">Switch is not exhaustive. Cases not matched: \"failed\" | \"refunded\"</code>. Flip that option to <code class=\"language-text\">true</code> and the same switch passes the lint. <code class=\"language-text\">allowDefaultCaseForExhaustiveSwitch</code> defaults to <code class=\"language-text\">true</code>, which is what lets an exhaustive switch keep its <code class=\"language-text\">assertNever</code> 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 <code class=\"language-text\">requires_action</code> case again, and it is why decoding at the boundary and a throwing default are complements, not alternatives.</p>\n\n<p>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. <code class=\"language-text\">assertNever</code> is enforced by <code class=\"language-text\">tsc</code> itself, so it holds on any version.</p>\n\n<h2 id=\"the-transition-table-typed-with-satisfies\">The transition table, typed with <code class=\"language-text\">satisfies</code></h2>\n\n<p>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 <code class=\"language-text\">null</code> for an event that state must reject:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">import type { EventType, PaymentEvent, PaymentState, Status } from \"./payment\";\n\nexport const transitions = {\n  idle:       { SUBMIT: \"processing\", RESOLVE: null,        REJECT: null,     RETRY: null },\n  processing: { SUBMIT: null,         RESOLVE: \"succeeded\", REJECT: \"failed\", RETRY: null },\n  succeeded:  { SUBMIT: null,         RESOLVE: null,        REJECT: null,     RETRY: null },\n  failed:     { SUBMIT: null,         RESOLVE: null,        REJECT: null,     RETRY: \"processing\" },\n} as const satisfies Record&lt;Status, Record&lt;EventType, Status | null&gt;&gt;;\n\nexport const sampleState = {\n  idle: { status: \"idle\" },\n  processing: { status: \"processing\", attempt: 1 },\n  succeeded: { status: \"succeeded\", receiptId: \"rcpt_1\" },\n  failed: { status: \"failed\", attempt: 1, reason: \"card_declined\" },\n} satisfies { [S in Status]: Extract&lt;PaymentState, { status: S }&gt; };\n\nexport const sampleEvent = {\n  SUBMIT: { type: \"SUBMIT\" },\n  RESOLVE: { type: \"RESOLVE\", receiptId: \"rcpt_1\" },\n  REJECT: { type: \"REJECT\", reason: \"card_declined\" },\n  RETRY: { type: \"RETRY\" },\n} satisfies { [E in EventType]: Extract&lt;PaymentEvent, { type: E }&gt; };</code></pre>\n\n<p>The TypeScript 4.9 release notes introduced <code class=\"language-text\">satisfies</code> 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 <code class=\"language-text\">Record</code> 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 <code class=\"language-text\">Record&lt;Status, …&gt;</code> annotation would check completeness too, but it would widen every cell to <code class=\"language-text\">Status | null</code>; with <code class=\"language-text\">as const satisfies</code> the cell for idle and SUBMIT keeps the literal type <code class=\"language-text\">\"processing\"</code>, so you can still derive narrower types from the table later.</p>\n\n<p>The two sample maps use a mapped type so each fixture must be the matching variant. Add <code class=\"language-text\">refunded</code> to the schema and the compiler reports <code class=\"language-text\">Property 'refunded' is missing</code> on the table and on <code class=\"language-text\">sampleState</code>, 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.</p>\n\n<h2 id=\"one-generated-test-per-pair\">One generated test per pair</h2>\n\n<p>Flatten the table into cases and hand them to <code class=\"language-text\">test.each</code>. Vitest lets you \"access object properties and array elements with $ prefix\" in the title, so each generated test names its own pair:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">import { expect, test } from \"vitest\";\nimport { PaymentStateSchema, paymentReducer } from \"./payment\";\nimport type { EventType, Status } from \"./payment\";\nimport { sampleEvent, sampleState, transitions } from \"./transitions\";\n\nconst cases = (Object.keys(transitions) as Status[]).flatMap((from) =&gt;\n  (Object.keys(transitions[from]) as EventType[]).map((event) =&gt; ({\n    from,\n    event,\n    to: transitions[from][event] as Status | null,\n  })),\n);\nconst accepted = cases.filter((c) =&gt; c.to !== null);\nconst rejected = cases.filter((c) =&gt; c.to === null);\n\ntest(\"the table has a row for every variant the schema decodes\", () =&gt; {\n  const decodable = PaymentStateSchema.options.flatMap((o) =&gt; [...o.shape.status.values]);\n  expect(Object.keys(transitions).sort()).toEqual(decodable.sort());\n  expect(cases).toHaveLength(decodable.length * Object.keys(sampleEvent).length);\n});\n\ntest.each(accepted)(\"$from + $event -&gt; $to\", ({ from, event, to }) =&gt; {\n  expect(paymentReducer(sampleState[from], sampleEvent[event]).status).toBe(to);\n});\n\ntest.each(rejected)(\"$from ignores $event\", ({ from, event }) =&gt; {\n  const before = sampleState[from];\n  expect(paymentReducer(before, sampleEvent[event])).toBe(before);\n});</code></pre>\n\n<p>That yields four accepted tests with names like <code class=\"language-text\">processing + REJECT -&gt; failed</code> and twelve rejection tests like <code class=\"language-text\">succeeded ignores SUBMIT</code>. The first test is there for the gap the warning callout describes. Add <code class=\"language-text\">refunded</code> to the schema and leave the table alone, and <code class=\"language-text\">tsc</code> reports errors in the reducer, the renderer, the table and the fixtures. <code class=\"language-text\">vitest run</code>, which does not type-check, would pass all sixteen pair tests, because <code class=\"language-text\">Object.keys(transitions)</code> still has four rows at runtime. The parity test reads the variants back out of the schema through <code class=\"language-text\">.options</code> and each literal's <code class=\"language-text\">.values</code> set, so the runtime suite fails too:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">FAIL  src/payment.test.ts &gt; the table has a row for every variant the schema decodes\nAssertionError: expected [ Array(4) ] to deeply equal [ Array(5) ]\n-   \"refunded\",</code></pre>\n<a href=\"https://automationtester.in/tools/test-failure-triage#p=eyJ2IjoxLCJpbnB1dCI6IkZBSUwgIHNyYy9wYXltZW50LnRlc3QudHMgPiB0aGUgdGFibGUgaGFzIGEgcm93IGZvciBldmVyeSB2YXJpYW50IHRoZSBzY2hlbWEgZGVjb2Rlc1xuQXNzZXJ0aW9uRXJyb3I6IGV4cGVjdGVkIFsgQXJyYXkoNCkgXSB0byBkZWVwbHkgZXF1YWwgWyBBcnJheSg1KSBdXG4tICAgXCJyZWZ1bmRlZFwiLCJ9\">Try this in Test Failure Triage</a>\n\n<p>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:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">test(\"a retry counts as a new attempt\", () =&gt; {\n  expect(paymentReducer(sampleState.failed, sampleEvent.RETRY)).toEqual({ status: \"processing\", attempt: 2 });\n});</code></pre>\n\n<h2 id=\"succeeded-plus-submit-returns-the-same-object\">succeeded + SUBMIT returns the same object</h2>\n\n<p>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 <code class=\"language-text\">{ state, rejected: \"SUBMIT\" }</code>, that the caller can log. This article takes the first position, for a reason that comes from React: <code class=\"language-text\">useReducer</code> 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.</p>\n\n<p>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:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">case \"processing\":\n  if (event.type === \"RESOLVE\") return { status: \"succeeded\", receiptId: event.receiptId };\n  if (event.type === \"REJECT\") return { status: \"failed\", attempt: state.attempt, reason: event.reason };\n  if (event.type === \"SUBMIT\") return { status: \"processing\", attempt: 1 }; // restarts the attempt\n  return state;</code></pre>\n\n<p>A status assertion passes: it is still <code class=\"language-text\">processing</code>. 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:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">FAIL  src/payment.test.ts &gt; processing ignores SUBMIT\nAssertionError: expected { status: 'processing', attempt: 1 } to be { status: 'processing', attempt: 1 } // Object.is equality</code></pre>\n\n<p>The cost of this position is that rejection is silent. A component that dispatches <code class=\"language-text\">RESOLVE</code> 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 <code class=\"language-text\">dispatch</code> 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.</p>\n\n<h2 id=\"render-the-states-the-reducer-reaches\">Render the states the reducer reaches, not only the fixtures</h2>\n\n<p>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:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">const reachable = [\n  sampleState.idle,\n  ...accepted.map(({ from, event }) =&gt; paymentReducer(sampleState[from], sampleEvent[event])),\n];\n\ntest.each(reachable)(\"the $status view offers only an accepted event\", (state) =&gt; {\n  const { action } = toView(state);\n  if (action !== null) {\n    expect(transitions[state.status][action]).not.toBeNull();\n  }\n});</code></pre>\n\n<p>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 <code class=\"language-text\">SUBMIT</code> instead of <code class=\"language-text\">RETRY</code> and the failed case goes red.</p>\n\n<p>React stays optional: <code class=\"language-text\">toView</code> is a pure function, so the suite runs in Node with no DOM. In a component the machine plugs in as <code class=\"language-text\">useReducer(paymentReducer, { status: \"idle\" })</code>, and the component test only has to prove the button dispatches <code class=\"language-text\">view.action</code>. Which Vitest environment that component test belongs in is a separate decision, covered in <a href=\"/blog/choose-vitest-browser-mode-playwright-by-risk\">choosing between Vitest browser mode and Playwright</a>.</p>\n\n<h2 id=\"guards-the-table-cannot-hold\">Guards the table cannot hold</h2>\n\n<p>The table models transitions that depend only on the discriminant. Real payment flows also have guards that depend on payload: allow RETRY only while <code class=\"language-text\">attempt &lt; 3</code>, or refuse RESOLVE whose receipt belongs to another payment. A <code class=\"language-text\">Status | null</code> cell cannot express either. You can split the state (<code class=\"language-text\">failed</code> and <code class=\"language-text\">failedFinal</code>), 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.</p>\n\n<p>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 <code class=\"language-text\">null</code>.</p>\n\n<p>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.</p>\n\n<h2 id=\"state-machine-testing-questions\">Questions about testing unions as machines</h2>\n\n<h3 id=\"does-the-table-duplicate-the-reducer\">Is the transition table the reducer written twice?</h3>\n\n<p>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.</p>\n\n<h3 id=\"why-discriminated-union-over-plain-union\">Why <code class=\"language-text\">z.discriminatedUnion</code> rather than <code class=\"language-text\">z.union</code>?</h3>\n\n<p>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 <code class=\"language-text\">[\"status\"]</code> that you can assert on, instead of a failure for every option.</p>\n\n<h3 id=\"is-vitest-typecheck-enough\">Can Vitest's <code class=\"language-text\">--typecheck</code> replace a separate <code class=\"language-text\">tsc</code> step?</h3>\n\n<p>Vitest's type-checking mode runs <code class=\"language-text\">tsc</code> under the hood and, per its docs, \"will also print out type errors in your source code\". It is aimed at type tests in <code class=\"language-text\">*.test-d.ts</code> files, a topic of its own later in this series. A plain <code class=\"language-text\">tsc --noEmit</code> in CI is the simpler guarantee for the errors this article relies on.</p>\n\n<h2 id=\"put-one-reducer-under-a-table-today\">Put one reducer under a table today</h2>\n\n<p>Pick the reducer in your codebase whose bugs cost the most, usually anything touching payment, submission or auth. Replace any trailing <code class=\"language-text\">default:</code> in its switches with <code class=\"language-text\">assertNever</code>, find the <code class=\"language-text\">as</code> 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 <a href=\"/tools/json-diff\">JSON Diff tool</a> so the variant list comes from the server rather than memory.</p>\n\n<p>The evidence to keep is two lines of output: <code class=\"language-text\">tsc --noEmit</code> 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.</p>\n\n<h2 id=\"discriminated-union-references\">Primary references</h2>\n\n<ul>\n<li><a href=\"https://www.typescriptlang.org/docs/handbook/2/narrowing.html\">TypeScript Handbook — Narrowing</a>. 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 <code class=\"language-text\">switch</code> 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.</li>\n<li><a href=\"https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html\">TypeScript 4.9 release notes — The satisfies Operator</a>. That <code class=\"language-text\">satisfies</code> validates \"that the type of an expression matches some type, without changing the resulting type of that expression\", and the <code class=\"language-text\">Record</code>-over-a-key-union example that reports both a missing key and a key \"never listed\".</li>\n<li><a href=\"https://github.com/microsoft/TypeScript/releases/tag/v7.0.2\">TypeScript 7.0.2 release (microsoft/TypeScript)</a>. The current TypeScript release at the time of checking, the compiler the examples and error output were produced with.</li>\n<li><a href=\"https://github.com/microsoft/TypeScript/blob/v7.0.2/src/lib/dom.generated.d.ts\">TypeScript 7.0.2 — lib.dom declarations</a>. That <code class=\"language-text\">Response.json()</code> is declared as returning <code class=\"language-text\">Promise&lt;any&gt;</code>, which is why a cast on it is unchecked.</li>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/Response/json\">MDN — Response: json() method</a>. That the promise resolves to an object that \"could be anything that can be represented by JSON\".</li>\n<li><a href=\"https://typescript-eslint.io/rules/switch-exhaustiveness-check/\">typescript-eslint — switch-exhaustiveness-check</a>. That the rule requires type information; the option names and defaults (<code class=\"language-text\">allowDefaultCaseForExhaustiveSwitch: true</code>, <code class=\"language-text\">considerDefaultExhaustiveForUnions: false</code>, <code class=\"language-text\">requireDefaultForNonUnion: false</code>, <code class=\"language-text\">defaultCaseCommentPattern</code>); 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.</li>\n<li><a href=\"https://typescript-eslint.io/users/dependency-versions/\">typescript-eslint — Dependency Versions</a>. That the supported TypeScript range is <code class=\"language-text\">&gt;=4.8.4 &lt;6.1.0</code> and that the parser logs a warning on an unsupported version.</li>\n<li><a href=\"https://zod.dev/api\">Zod 4 — Defining schemas</a>. The <code class=\"language-text\">z.discriminatedUnion()</code> API and its contrast with a regular union that checks \"each option in order\"; <code class=\"language-text\">.options</code> on union schemas and <code class=\"language-text\">.values</code> on literal schemas, used by the parity test.</li>\n<li><a href=\"https://github.com/colinhacks/zod/blob/v4.6.5/packages/zod/src/v4/core/schemas.ts\">Zod 4.6.5 — core schemas source</a>. The issue a discriminated union emits for an unmatched discriminator: code <code class=\"language-text\">invalid_union</code>, empty <code class=\"language-text\">errors</code>, note <code class=\"language-text\">No matching discriminator</code>, path set to the discriminator key, unless <code class=\"language-text\">unionFallback</code> is enabled.</li>\n<li><a href=\"https://vitest.dev/api/test\">Vitest — Test API</a>. That <code class=\"language-text\">test.each</code> accepts an array of objects and that the title can \"access object properties and array elements with $ prefix\". Documentation for Vitest 5.0.2.</li>\n<li><a href=\"https://vitest.dev/guide/testing-types\">Vitest — Testing Types</a>. That type checking is enabled with the <code class=\"language-text\">--typecheck</code> flag, that Vitest \"calls tsc or vue-tsc\" under the hood, and that <code class=\"language-text\">*.test-d.ts</code> files are treated as type tests by default.</li>\n<li><a href=\"https://react.dev/reference/react/useReducer\">React — useReducer</a>. 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\".</li>\n</ul>\n"}