PRACTICAL GUIDE / TypeScript exhaustive union compile tests

Make TypeScript catch the union case you forgot

Learn how to make new union members break compilation, diagnose false-green type checks, and roll reliable exhaustiveness gates into CI safely.

By The Testing AcademyUpdated August 4, 202621 min read
All field guides
In this guide6 sections
  1. Why the compiler misses an open-ended model
  2. Build a guard that can genuinely fail
  3. Diagnose the evidence before editing the switch
  4. Tell an unhandled member from its look-alikes
  5. Roll the gate into an existing suite without hiding failures
  6. Know when exhaustiveness is the wrong contract

What you will learn

  • Why the compiler misses an open-ended model
  • Build a guard that can genuinely fail
  • Diagnose the evidence before editing the switch
  • Tell an unhandled member from its look-alikes

A teammate adds timed_out to a run-status union and updates the API decoder. The dashboard formatter still knows only five statuses, but every runtime test stays green because none constructs the new value. That missing branch becomes a production bug unless compilation is allowed to stop the merge.

Why the compiler misses an open-ended model

An exhaustive check is possible only when the type describes a finite set. A field declared as kind: string does not do that. The compiler sees every string as a potential value, so handling queued, running, passed, failed, and cancelled cannot eliminate all remaining possibilities. A developer often responds by adding a default label. That makes the function total at runtime, but it also converts every future status into the same vague answer.

The same weakness appears in the familiar “one interface, many optional fields” model. Consider an object with kind: "queued" | "passed" | "failed", plus optional durationMs and error. Its kind is finite, so a switch can cover those three strings. The object can still represent nonsense: a passed run without a duration, a queued run with an error, or a failed run carrying both fields. Exhaustive branching does not repair a type that admits invalid combinations.

A useful model has one object type per state. Each member shares one property, usually kind, whose type is a different literal. Fields that belong to a state are required on that member and absent from the others. The TypeScript handbook calls this a discriminated union. Its narrowing documentation confirms that checking the common literal property removes incompatible members as control flow advances.

That removal is the mechanism, not a testing convention. Inside the passed case, the value has the passed-member type, so durationMs is available without a non-null assertion. After every member has been removed, the remaining value has type never. The handbook also states the important inverse: another concrete type cannot be assigned to never. A newly added member therefore leaves a concrete type in the last branch and produces a compiler error.

This distinction matters during review. A switch with five cases is not necessarily exhaustive. It is exhaustive only relative to the type that reaches it. If the parameter was widened to an interface with kind: string, copied from an old declaration, or asserted from unknown, the apparent list of cases may say little about the real contract.

The first worked example is a test-run timeline. Product adds a timed-out state because infrastructure timeouts must be reported separately from test failures. The unit suite has fixtures for all old states and still reaches full branch coverage. Branch coverage reports what ran against today’s fixtures; it does not ask whether the domain type grew. A never guard asks exactly that question during type checking, before a fixture has to know the new status exists.

There is a product decision hiding behind the compiler error. Should a timed-out run show elapsed time, configured limit, retry eligibility, or a link to infrastructure logs? Returning the failed label may be wrong even if it makes a test pass. The red build gives the owner a place to make that policy explicit.

Do not confuse this with runtime validation. TypeScript erases types when JavaScript is produced. A network response can contain {"kind":"paused"} even when no paused member exists in the source. A type assertion can also tell the compiler to trust such data without checking it. The static proof says every value described by the declared union is handled. It says nothing about whether an untrusted value actually conforms to that declaration.

Build a guard that can genuinely fail

Place the proof in the production consumer, not in a test-only copy of its switch. If the union grows, the real formatter should stop compiling. A separate fixture can then test which object shapes the public type accepts and rejects. Those two checks cover different contracts and fail for different changes.

Here is a complete production example. Every status has its own required data, the return type is explicit, and the final call receives the value left after narrowing. No cast appears at the proof point.

TypeScript
export interface Queued {
  kind: "queued";
  runId: string;
}

export interface Running {
  kind: "running";
  runId: string;
  startedAt: string;
}

export interface Passed {
  kind: "passed";
  runId: string;
  durationMs: number;
}

export interface Failed {
  kind: "failed";
  runId: string;
  message: string;
}

export interface Cancelled {
  kind: "cancelled";
  runId: string;
  cancelledBy: string;
}

export type RunEvent = Queued | Running | Passed | Failed | Cancelled;

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

export function labelRun(event: RunEvent): string {
  switch (event.kind) {
    case "queued":
      return `Queued ${event.runId}`;
    case "running":
      return `Running since ${event.startedAt}`;
    case "passed":
      return `Passed in ${event.durationMs} ms`;
    case "failed":
      return `Failed: ${event.message}`;
    case "cancelled":
      return `Cancelled by ${event.cancelledBy}`;
    default:
      return assertNever(event);
  }
}

Add a named TimedOut interface and include it in RunEvent without changing labelRun. At the default call, event is now TimedOut, not never. TypeScript rejects the argument. This is a live oracle: changing the production union without changing its production consumer makes the gate fail. Removing the new member or adding a real case makes it pass again.

A cast such as assertNever(event as never) destroys that relationship. It does not “help narrowing.” It instructs the checker to accept the author’s claim at the exact line where independent verification is needed. Casting the switch value to any earlier has the same practical result. A source rule that rejects those escapes can support the design, but the main evidence remains the compiler reaching the uncast guard.

Some teams omit the default and rely on an explicit return type plus strict null checking. Treat that as a technique your repository adopts rather than as something the documentation endorses. The narrowing page cited above is often credited with it, and it does not describe it: every example there returns an inferred type, and its strictNullChecks remarks concern optional properties, not missing switch branches. The technique itself is real and easy to reproduce. Annotate the function with : number, leave one union member unhandled, and TypeScript 5.9.3 reports error TS2366: Function lacks ending return statement and return type does not include 'undefined'. Turn strictNullChecks off and the same file compiles clean, which is the honest limit of the approach: the signal depends on a compiler setting and on the return type being annotated rather than inferred. It is also less direct in a large diagnostic stream, because it names the function rather than the member nobody handled. The never assignment identifies the unhandled type at the decision point. Either style can work, but a repository should choose one pattern and review it consistently.

The guard has a runtime cost and benefit. Under valid TypeScript calls it is unreachable, so it adds only a small branch to the emitted function. Under invalid JavaScript or unvalidated input it throws, which is often preferable to presenting an invented label. Throwing can also turn one malformed record into a failed page render or worker job. If the boundary must remain available, validate before the formatter and return an explicit validation result there. Do not weaken the exhaustive branch to compensate for missing boundary handling.

The shape tests should import the public type. Copying the union into a fixture is a classic false green because the copy does not grow when production changes. The following file compiles when the contract is intact. Each expected-error directive has a specific production change that would make the file fail: making durationMs optional, permitting an error on queued events, or widening the discriminant would remove the corresponding diagnostic.

TypeScript
import { labelRun, type RunEvent } from "./run-event";

const validEvents = [
  { kind: "queued", runId: "run-101" },
  {
    kind: "running",
    runId: "run-102",
    startedAt: "2026-08-04T09:00:00Z",
  },
  { kind: "passed", runId: "run-103", durationMs: 841 },
  { kind: "failed", runId: "run-104", message: "selector not found" },
  {
    kind: "cancelled",
    runId: "run-105",
    cancelledBy: "release-bot",
  },
] satisfies readonly RunEvent[];

const labels: string[] = validEvents.map(labelRun);
void labels;

// @ts-expect-error passed events require durationMs
const passedWithoutDuration: RunEvent = { kind: "passed", runId: "run-106" };

// @ts-expect-error queued events cannot carry a failure message
const queuedWithMessage: RunEvent = { kind: "queued", runId: "run-107", message: "not applicable" };

// @ts-expect-error archived is not a declared run-event kind
const unknownKind: RunEvent = { kind: "archived", runId: "run-108" };

void passedWithoutDuration;
void queuedWithMessage;
void unknownKind;

According to the official TypeScript 3.9 release notes, @ts-expect-error suppresses the diagnostic on the following line but reports an unused directive if that line no longer errors. That behavior makes it suitable for focused negative type cases. A plain @ts-ignore would remain silent after the type became too permissive.

Keep those directives close to simple assignments. If one directive masks three unrelated mistakes on a long expression, a module change or spelling error can satisfy the expectation while the intended contract is no longer tested. Small fixtures make the causal link reviewable.

The second worked example concerns a deployment command. An early model uses status: "ok" | "error", an optional releaseId, and an optional reason. The switch handles both status values, so the never check is happy, yet {status: "ok", reason: "permission denied"} remains assignable. Splitting the result into {status: "ok"; releaseId: string} and {status: "error"; reason: string} fixes a different defect from missing-case exhaustiveness. Add one rejected object for each cross-state field combination. The compiler gate should protect both the closed list of statuses and the data required by each status.

Diagnose the evidence before editing the switch

A red type-checking job is useful only when you can show it reached the intended program. Start with the compiler executable, selected project, effective configuration, and included files. The official CLI reference documents --version, --project, --showConfig, --listFilesOnly, and --noEmit. With TypeScript 5.9.3, supplying input files directly causes tsconfig.json files to be ignored, which can produce a local result that does not match CI. TypeScript 6.x instead reports TS5112 when input files are supplied while a tsconfig.json is present, unless the invocation includes --ignoreConfig.

Run the repository-local compiler rather than a globally installed version. The commands below create diagnostic artifacts without emitting JavaScript. Change the project path to the package that owns the union.

Shell
#!/usr/bin/env bash
set -euo pipefail

TSC="./node_modules/.bin/tsc"
PROJECT="packages/run-model/tsconfig.compile-tests.json"
ARTIFACT_DIR="artifacts/type-contracts"

mkdir -p "$ARTIFACT_DIR"
"$TSC" --version | tee "$ARTIFACT_DIR/typescript-version.txt"
"$TSC" --showConfig --project "$PROJECT" > "$ARTIFACT_DIR/effective-config.json"
"$TSC" --listFilesOnly --project "$PROJECT" > "$ARTIFACT_DIR/included-files.txt"
"$TSC" --noEmit --pretty false --project "$PROJECT" \
  2>&1 | tee "$ARTIFACT_DIR/diagnostics.txt"

The order of interpretation matters. First, confirm that run-event.ts and its type fixture appear in included-files.txt. The official listFiles reference describes this option specifically as a way to check whether an expected file participates in compilation. If the files are absent, a green result proves nothing about them. Fix files, include, exclude, or project references before inspecting the switch.

Next, inspect the effective configuration. A compile-test project often extends two or three base files, and the checked-in leaf does not reveal the final include paths or strictness options by itself. --showConfig prints the calculated configuration rather than building. Save it as evidence, but do not mistake it for a successful type check. The final --noEmit command checks the selected project while disabling output generation, provided the configuration has not enabled noCheck; confirm that setting in the effective output.

When an unhandled named member reaches a never assignment, the handbook shows diagnostic TS2322 in the form “Type 'Triangle' is not assignable to type 'never'.” A function-style guard normally reports the equivalent argument incompatibility at the assertNever call. The member name and source location are the valuable parts. They tell you which policy is missing and which consumer still needs it. Wording and layout may vary with the compiler version and pretty-print setting, so pin the toolchain and avoid snapshotting a whole colored diagnostic.

An unused @ts-expect-error message means the negative line no longer produces an error. That can be a legitimate contract change, such as making duration optional after a product decision. It can also mean the type widened accidentally, an any entered the import path, or the directive moved away from the intended line. Removing the comment just to restore green discards the alarm. Review why the expected rejection vanished.

Module-resolution errors are a different class. If the fixture cannot import ./run-event, the compiler never compares the invalid values with the union. A nonzero exit is not automatically a passing negative test. Read the first useful diagnostic and require it to point at the contract line, not a missing module, syntax error, or unavailable library declaration.

The third worked example starts with a puzzling CI result. A library package adds TimedOut, its own build succeeds after updating one local formatter, but an application formatter with no timed-out case also stays green. The file list reveals that the application compile project reads a generated .d.ts from the previous package build instead of the changed source or rebuilt declaration. Nothing is wrong with never; the consumer saw the old union. Rebuild the referenced package in the declared dependency graph, then rerun the application check. Adding a fallback case would hide the stale-artifact problem and still leave CI capable of testing yesterday’s contract.

Record the exit code through the CI platform rather than wrapping tsc in a script that always exits zero. If output must be piped through tee, set -o pipefail ensures a compiler failure remains a script failure. The bash example enables it. This small shell detail separates a diagnostic artifact from an enforcement gate.

Tell an unhandled member from its look-alikes

Several faults can produce the same visible symptom: a formatter reaches its final branch with an unfamiliar kind. Treating all of them as a missing switch case leads to bad fixes.

One near-miss is unvalidated JSON. Imagine a review service whose declared result is Approved | Declined. The switch handles both and compiles cleanly. At runtime, an older server sends {kind: "manual_review"} after a gradual rollout. If the client used response.json() as ReviewResult, the assertion bypassed structural checking and the assertNever function throws. Adding manual_review may be correct if it is now part of the supported protocol, but the evidence must come from the API contract and rollout plan. If the value is corrupt or unsupported, the fix belongs in boundary validation and compatibility handling.

The evidence for that case is a green compile using the current source, plus a captured untrusted payload whose discriminant is outside the union. The stack reaches the guard at runtime rather than failing tsc at the guard. Static and runtime failures share a line but have different causes. Keep the thrown value free of secrets if logs capture it; a production assertNever may choose a safe fixed message while structured boundary logging records a sanitized discriminator.

Another look-alike is a broad compatibility member. A type such as KnownEvent | {kind: string; payload: unknown} intentionally leaves the domain open. The fallback member overlaps every literal discriminant, so ordinary switch narrowing may not isolate the known variants the way a closed union does. For an SDK that must preserve unknown future server events, openness can be a valid requirement. Do not force never onto that consumer. Return an explicit unknown result and make callers handle it as part of the public type.

A third case is control-flow incompleteness rather than domain incompleteness. A developer removes return from one case and unintentionally falls through. The member list has not changed, but behavior has. noFallthroughCasesInSwitch can report non-empty fallthrough cases, and runtime unit tests should verify the label for every current member. The exhaustive guard is not a substitute for those checks. It proves that each type is removed by some path, not that the branch computes the right string or stops where expected.

Optional shared properties create another deceptive green. Handling every discriminant may narrow to never correctly while a branch reads a possibly absent field or uses a default that masks its absence. The remedy is to remodel members so state-specific data is required, then retain negative shape fixtures. Settings such as exactOptionalPropertyTypes affect what an optional property means, but they do not turn one bag of optionals into a precise state machine.

Configuration drift can invert the signal. An editor may use the nearest package config while CI selects a root solution config. One may include the consumer, the other only declarations. Compare the exact project argument and --showConfig output rather than assuming both invocations mean “run TypeScript.” Under TypeScript 5.9.3, passing individual input files ignores tsconfig files, so a reproduction like tsc run-event.ts can silently drop repository options. Under TypeScript 6.x, the same command reports TS5112 when a tsconfig.json is present unless --ignoreConfig is supplied.

Generated code needs an ownership decision. If an OpenAPI or schema generator owns the union, hand-editing the generated file will be overwritten. Put the exhaustive consumer in maintained source, make generation run before the compile gate, and keep the schema or generator input as the authority. A diff in generated members should then break maintained consumers. The cost is build ordering and potentially slower feedback. The benefit is that the test observes the same contract the application imports.

Finally, distinguish a deliberate catch-all from an accidental one. Telemetry ingestion may need to accept unknown event names so one new producer cannot stop all processing. A UI labeler may need to reject them because showing “success” for an unknown terminal state is dangerous. Exhaustiveness is a policy tool, not a universal style rule. Apply it where the consumer promises behavior for every closed member.

Roll the gate into an existing suite without hiding failures

Start with one union whose missing case has a real consequence. Inventory its consumers: formatters, reducers, serializers, permission decisions, report builders, and test-data factories. A search for the discriminant property and literal values finds obvious switches, but type references from the editor or language service are better for wrappers and aliases. Classify each consumer as closed or intentionally forward-compatible before inserting guards.

Add the discriminated members first. This may surface invalid fixtures that relied on shared optional fields. Repair those fixtures by representing valid states, not by adding casts. Next, place the guard in one policy-heavy consumer and run its owning project. The initial breakage is valuable inventory. Record each unhandled member and choose behavior with the feature owner.

Large migrations need sequencing. Changing a widely exported union can break many packages in one commit, which makes diagnostic review noisy and blocks unrelated work. One approach is to update the union and all closed consumers together within a package boundary, then move outward through project references. Another is to introduce a new precise type beside a legacy open type and migrate consumers deliberately. The second approach costs temporary duplication and conversion code, but it avoids teaching developers to silence dozens of errors with assertions.

Once production consumers compile, add focused shape fixtures that import the public type. Use @ts-expect-error only where an invalid assignment is the subject of the test. Keep ordinary runtime tests for labels, formatting, side effects, and current branch semantics. Compilation can prove that Failed has a case; it cannot prove the case displays the right message or sends the right metric.

Wire the same project into CI. The following GitHub Actions job assumes the repository pins pnpm through its packageManager field. The pnpm setup runs before Node dependency caching asks for the pnpm executable, avoiding an ordering failure. Replace the Node version and project path with values already supported by the repository.

YAML
name: type contracts

on:
  pull_request:

jobs:
  exhaustive-unions:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - name: Check compile-time contracts
        run: pnpm exec tsc --noEmit --pretty false --project tsconfig.compile-tests.json

Keep the compiler command visible in the job log. A wrapper is reasonable when several packages share it, but the wrapper must preserve the compiler exit code and print the selected projects. Publish diagnostics on failure if logs are short-lived. There is no reason to retry a deterministic type error with identical source, compiler, dependencies, and configuration. A pass after retry points to changed inputs, generated-file timing, cache state, or an installation race.

Do not make the first rollout depend on an elaborate mutation harness. The production guard already fails when the production union gains an unhandled member. A reviewer can demonstrate the relationship once by temporarily adding a member locally and observing the compiler diagnostic, then removing it. The durable CI oracle is the actual union plus actual consumers under the actual project configuration.

If a team does adopt automated mutation, require the harness to change the source contract that the consumer imports and to verify the expected diagnostic location. A hard-coded “bad” fixture containing both a fixed extra member and a fixed failed assertion can remain red regardless of production changes, which proves nothing. Ask of every negative oracle: what plausible change to the code under test makes this check fail? If no production change affects it, it is a demonstration, not a regression test.

Expect a concrete maintenance cost. Every legitimate new member now breaks all closed consumers until their owners decide behavior. That increases coordination for shared-domain changes. Compile time may also grow when a focused project begins including more packages or declarations. Keep project boundaries narrow and avoid placing every repository test in one global config merely for convenience. The goal is reliable pressure at policy points, not maximum file count.

Code review also changes. A patch that adds a new case should explain the product rule, not merely satisfy never. Review the data used in that branch, its user-visible output, and any telemetry or retry behavior. Then add a runtime assertion for the chosen outcome. The compiler opens the conversation; it cannot make the decision.

Know when exhaustiveness is the wrong contract

Avoid a closed union when the consumer is explicitly designed to accept values introduced independently. Analytics collectors, plugin registries, protocol proxies, and pass-through storage often need to preserve unknown kinds. Modeling those inputs as a finite union creates a false promise and encourages unsafe casts whenever a new producer appears. Represent the unknown case honestly and make its handling visible in the return type.

Do not add an exhaustive switch solely to replace runtime validation. Values parsed from JSON, read from a database, received through postMessage, or supplied by JavaScript remain untrusted until checked. A static annotation does not inspect bytes. Validate the discriminant and required fields at the boundary, then pass the validated union to closed internal consumers. That separation yields clearer failure evidence: validation rejects unsupported input, while compilation rejects missing internal policy.

Skip the pattern for boolean choices or enums that already have one centralized mapping and no variant-specific data if a simpler typed record expresses the requirement more clearly. For example, a Record<Status, string> can force a key for every status when the task is only a lookup. A switch is better when branches use different fields, perform different control flow, or need distinct side effects. Choose the smallest construct whose type failure points at the missing decision.

Be cautious with public libraries that promise forward compatibility. Throwing from assertNever may be appropriate for an impossible internal state but hostile for a client receiving a newer server response. An explicit {kind: "unknown"; rawKind: string} result after validation can preserve availability without pretending the value is one of the known members. That design gives downstream code a branch it can test and observe.

Do not use exhaustive compilation as a coverage claim. It cannot verify branch outputs, exception messages, asynchronous work, logging, rendering, authorization effects, or performance. A perfectly exhaustive reducer can still map failed to the success color. Keep one runtime test per meaningful current behavior, especially where two members have similar fields but different business consequences.

Avoid enforcing the technique in generated or third-party declaration files you do not own. Wrap external types at your boundary and define the stable internal union your application can support. Otherwise a dependency upgrade can trigger widespread policy errors for states the application never receives, or developers may patch generated artifacts that disappear on the next install.

There is also a point where a union is too broad for one owner. A hundred event variants handled by one switch may be technically exhaustive and operationally unreviewable. Split policy by domain, use smaller closed unions, and route validated events to the responsible module. The trade-off is an additional dispatch layer. The gain is that each compile error reaches a team capable of deciding the behavior.

The strongest use case has four properties: the member set is intentionally closed, a new member requires a conscious policy choice, production code owns the switch, and CI compiles that code with the configuration used by its package. Under those conditions, the gate catches a class of omissions that runtime fixtures routinely miss. Outside them, an open model, boundary validator, typed lookup, or ordinary behavioral test may communicate the contract more honestly.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 26, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official typescriptlang.org reference

    typescriptlang.org

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official typescriptlang.org reference

    typescriptlang.org

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official typescriptlang.org reference

    typescriptlang.org

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official typescriptlang.org reference

    typescriptlang.org

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

How do I make TypeScript fail when a union gains a member?

Put a literal discriminant on every member and pass the value remaining after the switch to a function that accepts never. When a new member lacks a case, that value no longer narrows to never, so the compiler rejects the call.

Is a default case enough to prove a switch is exhaustive?

No. A default that returns a fallback can hide a new member forever. The default has to contain a never assignment or call an assertNever function without a cast if it is meant to enforce exhaustive handling.

Should I use ts-expect-error in a compile test?

Use it for a small invalid example whose rejection is part of the contract. TypeScript reports an unused directive when that line stops producing an error, which turns accidental type widening into a visible failure.

Why does the compile check pass even though CI missed a union case?

Usually the relevant consumer or fixture is outside the selected tsconfig, or the package is compiling against an older declaration file. Inspect the effective configuration and included file list before changing the switch.

Can an exhaustive switch validate API data at runtime?

Static exhaustiveness starts only after a value has the union type. JSON, JavaScript callers, and unsafe casts can still supply an unknown discriminant, so validate untrusted input before treating it as the union.