# 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.

- Author: [Shashank Rawlani](https://shashank.rawlani.com)

- Published: 2026-10-07T13:30:00.000Z

- Updated: 2026-10-14T11:09:52.936Z

- Category: Automation Tutorials

- Canonical source: https://automationtester.in/blog/automation-tutorials/test-typescript-discriminated-unions-state-machines

- Tags: typescript, vitest, zod, state-machines, unit-testing, react

**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](/blog/typescript-runtime-boundary-validation-testing).

## 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](https://automationtester.in/tools/test-failure-triage#p=eyJ2IjoxLCJpbnB1dCI6IkZBSUwgIHNyYy9wYXltZW50LnRlc3QudHMgPiB0aGUgdGFibGUgaGFzIGEgcm93IGZvciBldmVyeSB2YXJpYW50IHRoZSBzY2hlbWEgZGVjb2Rlc1xuQXNzZXJ0aW9uRXJyb3I6IGV4cGVjdGVkIFsgQXJyYXkoNCkgXSB0byBkZWVwbHkgZXF1YWwgWyBBcnJheSg1KSBdXG4tICAgXCJyZWZ1bmRlZFwiLCJ9)

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](/blog/choose-vitest-browser-mode-playwright-by-risk).

## 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](/tools/json-diff) 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](https://www.typescriptlang.org/docs/handbook/2/narrowing.html). 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](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html). 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)](https://github.com/microsoft/TypeScript/releases/tag/v7.0.2). 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](https://github.com/microsoft/TypeScript/blob/v7.0.2/src/lib/dom.generated.d.ts). That `Response.json()` is declared as returning `Promise<any>`, which is why a cast on it is unchecked.

- [MDN — Response: json() method](https://developer.mozilla.org/en-US/docs/Web/API/Response/json). That the promise resolves to an object that "could be anything that can be represented by JSON".

- [typescript-eslint — switch-exhaustiveness-check](https://typescript-eslint.io/rules/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](https://typescript-eslint.io/users/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](https://zod.dev/api). 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](https://github.com/colinhacks/zod/blob/v4.6.5/packages/zod/src/v4/core/schemas.ts). 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](https://vitest.dev/api/test). 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](https://vitest.dev/guide/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](https://react.dev/reference/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".

## Continue reading on AutomationTester.in

- [Mock or Integrate? Playwright Network Testing Boundaries](https://automationtester.in/blog/automation-tutorials/playwright-network-mocking-integration-boundaries)
- [Build Deterministic Python API Test Data Factories](https://automationtester.in/blog/automation-tutorials/python-api-test-data-factories-deterministic)
- [Make JUnit Failures Useful in CI](https://automationtester.in/blog/automation-tutorials/junit-failure-reports-useful-ci)
- [Design Playwright Fixtures for Parallel Test Isolation](https://automationtester.in/blog/automation-tutorials/playwright-fixtures-parallel-test-isolation)
- [Debug JUnit Parallel-Execution Flakes](https://automationtester.in/blog/automation-tutorials/debug-junit-parallel-execution-flakes)

Source: [Test TypeScript Discriminated Unions as State Machines](https://automationtester.in/blog/automation-tutorials/test-typescript-discriminated-unions-state-machines) by Shashank Rawlani.
