Resource library

QA How-To

TypeScript Discriminated Unions Test Results: A Practical Model (2026)

Build safer automation with typescript discriminated unions test results, including passes, failures, skips, retries, exhaustive reports, and validation.

18 min read | 2,572 words

TL;DR

Model each outcome as a separate object type with a shared literal status such as passed, failed, or skipped. Consumers switch on status, gain correctly narrowed fields, and use a never check so newly added outcomes cannot be silently ignored.

Key Takeaways

  • Give every test outcome a shared literal status field so TypeScript can narrow it safely.
  • Keep outcome-specific data on the matching union member instead of making every field optional.
  • Use an assertNever helper to make reporters fail at compile time when a new status is unhandled.
  • Separate raw runner data from the stable result model used by reporters and CI integrations.
  • Represent retries as attempt history, then derive the final outcome without losing diagnostic evidence.
  • Validate untrusted JSON at the system boundary before treating it as a typed TestResult.

TypeScript discriminated unions test results give each test outcome an explicit shape. A passed result contains timing data, a failed result contains error data, and a skipped result contains a reason. The compiler then prevents a reporter from reading fields that do not exist for that outcome.

This tutorial builds a small result pipeline you can paste into a Node and TypeScript project. It fits into the broader architecture explained in the TypeScript test framework design complete guide, where stable domain models sit between test runners and reporting integrations.

You will start with three outcomes, add exhaustive reporting, model retry attempts, summarize a suite, and protect the model at a JSON boundary. The same design works around Playwright, Vitest, WebdriverIO, API tests, and custom runners because it does not depend on a runner-specific API.

TL;DR

Use one required literal property as the discriminant. Avoid a single interface full of optional fields.

Approach Compiler narrowing Invalid states Adding an outcome
Optional fields Weak Easy to create Consumers may miss it
Class hierarchy Possible with instanceof Controlled Coupled to runtime classes
Discriminated union Strong with status Hard to create Exhaustive checks fail usefully
type TestResult =
  | { status: 'passed'; testId: string; durationMs: number }
  | { status: 'failed'; testId: string; durationMs: number; error: TestError }
  | { status: 'skipped'; testId: string; reason: string };

A switch (result.status) narrows each branch. Finish the switch with assertNever(result) to receive a compile error whenever the union grows and a consumer has not been updated.

What You Will Build

By the end, you will have:

  • A TestResult union for passed, failed, and skipped tests.
  • Factory functions that construct only valid results.
  • A formatter with compile-time exhaustive handling.
  • Retry history that preserves every attempt.
  • Suite totals derived without unsafe casts.
  • A boundary parser for result JSON from another process.
  • Automated tests using the stable node:test API.

The finished example has no production dependencies. That makes the domain model portable even if your actual runner changes.

Prerequisites

Use Node.js 22 or newer and TypeScript 5.8 or newer. Node 22 is a suitable current LTS baseline, while the code relies only on long-standing TypeScript features. Check your versions:

node --version
npx tsc --version

Create an empty project and install TypeScript plus Node type definitions:

mkdir typed-test-results
cd typed-test-results
npm init -y
npm install --save-dev typescript @types/node
mkdir src

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src/**/*.ts"]
}

Add scripts to package.json:

{
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "test": "npm run build && node --test dist/**/*.test.js"
  }
}

Keep strict enabled. The tutorial loses much of its value if strictNullChecks is disabled, because missing values become easier to misuse.

Step 1: Define typescript discriminated unions test results

Create src/test-result.ts. Start with shared metadata and three outcome types:

export interface TestError {
  readonly name: string;
  readonly message: string;
  readonly stack?: string;
}

interface ResultBase {
  readonly testId: string;
  readonly title: string;
  readonly startedAt: string;
}

export interface PassedResult extends ResultBase {
  readonly status: 'passed';
  readonly durationMs: number;
}

export interface FailedResult extends ResultBase {
  readonly status: 'failed';
  readonly durationMs: number;
  readonly error: TestError;
}

export interface SkippedResult extends ResultBase {
  readonly status: 'skipped';
  readonly reason: string;
}

export type TestResult = PassedResult | FailedResult | SkippedResult;

The status field is the discriminant. Its type is not the broad string; each member has one string literal. Shared identity fields belong in ResultBase, while outcome-specific evidence stays on its member. A skipped test has no fake duration, and a passed test cannot accidentally carry an error.

Do not add error?: TestError and reason?: string to the base interface. Optional properties allow nonsense such as a passed result with an error or a failed result without one. The union encodes the business rules directly.

Verify the step: run npm run build. It should exit without diagnostics and create dist/test-result.js. Then temporarily add const bad: TestResult = { status: 'failed' };. The compiler should reject it because the failure is missing its required identity, duration, and error fields. Remove that line after confirming the guard.

Step 2: Construct Valid Results with Factory Functions

Callers should not repeatedly assemble timestamps and error details. Add these functions below the types in src/test-result.ts:

type CommonInput = Pick<ResultBase, 'testId' | 'title' | 'startedAt'>;

export function passed(
  input: CommonInput,
  durationMs: number,
): PassedResult {
  return { ...input, status: 'passed', durationMs };
}

export function failed(
  input: CommonInput,
  durationMs: number,
  error: unknown,
): FailedResult {
  return {
    ...input,
    status: 'failed',
    durationMs,
    error: normalizeError(error),
  };
}

export function skipped(
  input: CommonInput,
  reason: string,
): SkippedResult {
  return { ...input, status: 'skipped', reason };
}

function normalizeError(error: unknown): TestError {
  if (error instanceof Error) {
    return {
      name: error.name,
      message: error.message,
      ...(error.stack === undefined ? {} : { stack: error.stack }),
    };
  }

  return { name: 'UnknownError', message: String(error) };
}

Accept unknown at the error boundary because JavaScript permits throwing any value. normalizeError converts an Error, string, number, or object into a predictable serializable shape. The conditional spread also respects exactOptionalPropertyTypes: it omits stack instead of assigning undefined.

Factories centralize construction but do not replace the union. Their return types document the precise outcome and preserve narrowing for consumers. They are also a convenient location for later invariants such as rejecting a negative duration.

Verify the step: create src/demo.ts and compile it:

import { failed, passed, skipped } from './test-result.js';

const base = {
  testId: 'checkout-1',
  title: 'guest completes checkout',
  startedAt: new Date().toISOString(),
};

console.log(passed(base, 184));
console.log(failed(base, 92, new Error('button not found')));
console.log(skipped(base, 'payment sandbox unavailable'));

Run npm run build && node dist/demo.js. Expect three objects whose statuses are passed, failed, and skipped. Only the failed object should contain error; only the skipped object should contain reason.

Step 3: Narrow Outcomes and Enforce an Exhaustive Switch

A result model becomes useful when consumers can read it without casts. Add this formatter to src/test-result.ts:

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

export function formatResult(result: TestResult): string {
  switch (result.status) {
    case 'passed':
      return `PASS ${result.title} (${result.durationMs} ms)`;
    case 'failed':
      return `FAIL ${result.title} (${result.durationMs} ms): ${result.error.message}`;
    case 'skipped':
      return `SKIP ${result.title}: ${result.reason}`;
    default:
      return assertNever(result);
  }
}

Inside case 'failed', TypeScript knows result is a FailedResult, so result.error.message is safe. Inside case 'passed', trying to access result.error produces a compiler error. You do not need as FailedResult, non-null assertions, or optional chaining.

The default branch is more than defensive runtime code. After all current statuses are handled, result narrows to never. If you later add a timedOut member but forget this formatter, result no longer narrows to never, and compilation fails at assertNever(result). That failure points to every consumer needing a decision.

Verify the step: update src/demo.ts to import formatResult and call it for the three objects. Rebuild and expect lines beginning with PASS, FAIL, and SKIP. For a deliberate compile check, add interface CancelledResult extends ResultBase { readonly status: 'cancelled'; readonly reason: string }, include it in TestResult, and run the build. The formatter should fail to compile. Revert the temporary member before continuing.

Step 4: Preserve Retry Attempts Without Losing Evidence

A flaky test can fail twice and pass on its third attempt. Storing only the final pass hides useful failure evidence. Model attempt results separately, then add a final retried outcome. Append this code:

export type AttemptResult = PassedResult | FailedResult;

export interface RetriedResult extends ResultBase {
  readonly status: 'retried';
  readonly attempts: readonly AttemptResult[];
  readonly finalStatus: 'passed' | 'failed';
}

export type CompleteTestResult = TestResult | RetriedResult;

export function retried(
  input: CommonInput,
  attempts: readonly AttemptResult[],
): RetriedResult {
  if (attempts.length < 2) {
    throw new Error('A retried result requires at least two attempts');
  }

  const last = attempts.at(-1);
  if (last === undefined) {
    throw new Error('Retry history cannot be empty');
  }

  return {
    ...input,
    status: 'retried',
    attempts: [...attempts],
    finalStatus: last.status,
  };
}

AttemptResult excludes skipped because a retry attempt here represents executed test code. Your runner may have different semantics, so encode those explicitly rather than widening the type casually. readonly communicates that reporting code must not rewrite history. The factory copies the incoming array so a caller cannot mutate the stored sequence through its original reference.

Using CompleteTestResult avoids changing earlier teaching code. In an application, choose one exported aggregate type and migrate all consumers together. Exhaustive checks make that migration visible.

Verify the step: add two failures and one pass to an attempts array, call retried(base, attempts), and log the result. finalStatus should be passed, and attempts.length should be 3. Call retried(base, [passed(base, 100)]); it should throw the documented validation error.

Step 5: Summarize TypeScript Discriminated Unions Test Results

Reporters usually need totals as well as individual lines. Add a summary type and reducer:

export interface SuiteSummary {
  readonly passed: number;
  readonly failed: number;
  readonly skipped: number;
  readonly flaky: number;
  readonly total: number;
}

export function summarize(
  results: readonly CompleteTestResult[],
): SuiteSummary {
  let passedCount = 0;
  let failedCount = 0;
  let skippedCount = 0;
  let flakyCount = 0;

  for (const result of results) {
    switch (result.status) {
      case 'passed':
        passedCount += 1;
        break;
      case 'failed':
        failedCount += 1;
        break;
      case 'skipped':
        skippedCount += 1;
        break;
      case 'retried':
        if (result.finalStatus === 'passed') {
          passedCount += 1;
          flakyCount += 1;
        } else {
          failedCount += 1;
        }
        break;
      default:
        assertNever(result);
    }
  }

  return {
    passed: passedCount,
    failed: failedCount,
    skipped: skippedCount,
    flaky: flakyCount,
    total: results.length,
  };
}

This policy counts a test by its final outcome and separately marks a recovered retry as flaky. That is a product decision, not a universal truth. Some teams report retried tests in their own top-level bucket. Name and test your policy so dashboards do not disagree.

Notice that the reducer switches on the discriminant instead of filtering with arbitrary property checks such as 'error' in result. A status describes business meaning clearly, while incidental fields can change. The exhaustive default also makes a new outcome impossible to ignore silently.

Verify the step: pass one direct pass, one failure, one skip, and one retried pass to summarize. Expect { passed: 2, failed: 1, skipped: 1, flaky: 1, total: 4 }. Log the object or assert it in the next step.

Step 6: Test the Result Model with Node Test Runner

Create src/test-result.test.ts. These tests cover narrowing behavior indirectly through public functions and verify the retry policy:

import assert from 'node:assert/strict';
import test from 'node:test';
import { failed, formatResult, passed, retried, skipped, summarize } from './test-result.js';

const base = {
  testId: 'login-1',
  title: 'user signs in',
  startedAt: '2026-07-15T08:00:00.000Z',
};

test('formats outcome-specific evidence', () => {
  assert.equal(formatResult(passed(base, 40)), 'PASS user signs in (40 ms)');
  assert.match(formatResult(failed(base, 55, new Error('401'))), /401/);
  assert.match(formatResult(skipped(base, 'maintenance')), /maintenance/);
});

test('counts a recovered retry as passed and flaky', () => {
  const retry = retried(base, [
    failed(base, 30, new Error('timeout')),
    passed(base, 25),
  ]);

  assert.deepEqual(summarize([
    passed({ ...base, testId: 'a' }, 20),
    failed({ ...base, testId: 'b' }, 25, 'boom'),
    skipped({ ...base, testId: 'c' }, 'blocked'),
    retry,
  ]), { passed: 2, failed: 1, skipped: 1, flaky: 1, total: 4 });
});

test('rejects a retry with fewer than two attempts', () => {
  assert.throws(
    () => retried(base, [passed(base, 20)]),
    /at least two attempts/,
  );
});

The test imports include .js because NodeNext TypeScript source uses the extension that will exist after compilation. TypeScript resolves it back to the .ts source during development. This avoids module-resolution surprises in the emitted JavaScript.

Verify the step: run npm test. Node should report three passing tests and zero failures. Exact console decoration may differ across Node releases, so verify the pass and fail counts rather than snapshotting runner output. Also run npm run build independently in CI so type errors are reported clearly.

Step 7: Validate Untrusted Result JSON at the Boundary

TypeScript types disappear at runtime. JSON.parse(text) as TestResult does not validate anything. If a worker process, artifact file, queue, or API supplies results, parse to unknown and validate before the domain layer. Add a small dependency-free parser:

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null;
}

function hasCommonFields(value: Record<string, unknown>): boolean {
  return typeof value.testId === 'string'
    && typeof value.title === 'string'
    && typeof value.startedAt === 'string';
}

export function parseTestResult(text: string): TestResult {
  const value: unknown = JSON.parse(text);
  if (!isRecord(value) || !hasCommonFields(value)) {
    throw new Error('Invalid test result metadata');
  }

  switch (value.status) {
    case 'passed':
      if (typeof value.durationMs !== 'number') break;
      return value as unknown as PassedResult;
    case 'failed':
      if (typeof value.durationMs !== 'number' || !isRecord(value.error)) break;
      if (typeof value.error.name !== 'string' || typeof value.error.message !== 'string') break;
      return value as unknown as FailedResult;
    case 'skipped':
      if (typeof value.reason !== 'string') break;
      return value as unknown as SkippedResult;
  }

  throw new Error('Invalid test result outcome');
}

The assertions occur only after explicit checks. For a large schema, replace manual parsing with a schema library and infer the TypeScript type from that schema. The runtime validation of TypeScript test data with Zod tutorial shows the scalable approach and prevents the runtime schema from drifting away from the static type.

You can strengthen this parser by validating ISO timestamps, finite nonnegative durations, allowed extra fields, and optional stack types. Decide whether unknown fields should be preserved, stripped, or rejected based on compatibility needs.

Verify the step: parse a JSON string created with JSON.stringify(passed(base, 10)); it should return a passed result. Then parse '{"status":"failed"}'; it should throw Invalid test result metadata. Add both cases to the test file if the boundary is production-critical.

Step 8: Adapt Runner Events into the Domain Model

Keep framework callbacks at the edge. Convert runner-specific objects once, then let Slack reporters, HTML generators, CI gates, and databases consume CompleteTestResult. A generic adapter can look like this:

interface RunnerEvent {
  id: string;
  name: string;
  startTime: Date;
  durationMs?: number;
  state: 'ok' | 'error' | 'ignored';
  error?: unknown;
  skipReason?: string;
}

export function fromRunnerEvent(event: RunnerEvent): TestResult {
  const common = {
    testId: event.id,
    title: event.name,
    startedAt: event.startTime.toISOString(),
  };

  switch (event.state) {
    case 'ok':
      return passed(common, event.durationMs ?? 0);
    case 'error':
      return failed(common, event.durationMs ?? 0, event.error);
    case 'ignored':
      return skipped(common, event.skipReason ?? 'No reason supplied');
    default:
      return assertNever(event.state);
  }
}

In a real adapter, do not assume a missing duration means zero unless that matches your reporting contract. You might reject it or model an interrupted outcome instead. The example uses a default only to keep the external event deliberately small.

This anti-corruption layer protects the rest of your framework from runner naming and object-shape changes. It also makes migrations easier: implement a new adapter while preserving the reporter-facing result union. For stronger reusable component contracts, continue with TypeScript generics for page and component models.

Verify the step: pass events for ok, error, and ignored through fromRunnerEvent, then format the returned results. You should receive the same PASS, FAIL, and SKIP strings as factory-created values. Run npm test once more to confirm the adapter did not weaken the existing model.

Troubleshooting

Problem: TypeScript does not narrow after checking status -> Ensure every member declares the same required property with distinct literal values. status: string is too broad. Also avoid destructuring in ways that separate the discriminant from the object on older compiler configurations; switching directly on result.status is clearest.

Problem: assertNever(result) reports a compile error -> Treat it as a useful migration checklist. A union member was added or a case was omitted. Add a deliberate branch with the correct reporting policy instead of casting result as never.

Problem: valid JSON was accepted but later code crashed -> A type assertion is not validation. Parse to unknown, validate every required property, and check nested error data. Use a runtime schema when the boundary is large or shared.

Problem: exactOptionalPropertyTypes rejects stack: undefined -> Omit the property when no stack exists. The conditional spread in normalizeError demonstrates this. Alternatively, explicitly declare stack: string | undefined if presence with an undefined value has real semantic meaning.

Problem: retry counts differ between reports -> Define whether recovered retries count as passed, flaky, both, or a separate status. Put the rule in one summarizer and test the exact totals. Do not let every reporter reinterpret attempts independently.

Problem: Node cannot resolve imports after compilation -> With module and moduleResolution set to NodeNext, write relative TypeScript imports using the emitted .js extension. Confirm package module settings and avoid mixing CommonJS require with ESM syntax accidentally.

Common Mistakes and Best Practices

  • Do use one stable semantic discriminant such as status. Do not discriminate through field presence unless the field itself defines the domain state.
  • Do keep fields required on the outcome that owns them. Do not create a result interface where most fields are optional.
  • Do normalize thrown unknown values. Do not assume every failure is an Error.
  • Do preserve retry attempts when diagnostics matter. Do not overwrite the first failure with the final pass.
  • Do make every important consumer exhaustive. Do not add a default that silently returns an empty value.
  • Do validate data at process and storage boundaries. Do not trust as TestResult after JSON.parse.
  • Do keep runner adapters thin. Do not spread runner-specific types throughout reports and business rules.
  • Do use readonly for recorded facts. Do not let presentation code mutate test evidence.
  • Do test reporting policies with exact totals. Do not leave concepts such as flaky or interrupted ambiguous.

Literal statuses are also useful in test metadata and discovery systems. If you build annotation-driven registration, TypeScript decorators for test metadata explains how to keep that layer separate from execution results.

Interview Questions and Answers

Q: What makes a TypeScript union discriminated?

Every member shares a property whose type is a distinct literal, such as status: 'passed' or status: 'failed'. Checking that property lets the compiler narrow the complete object to one member.

Q: Why is a union better than optional result fields?

A union makes required evidence depend on the outcome. A failure must have an error, while a skip must have a reason. Optional fields permit invalid combinations and push checks into every consumer.

Q: What does never contribute to exhaustive handling?

After all known members have been handled, the remaining value has type never. Passing it to assertNever compiles today but fails when a new unhandled member is added. It turns model evolution into a visible compiler task list.

Q: Are discriminated unions runtime validation?

No. They provide compile-time guarantees for values TypeScript already trusts. External JSON must still be parsed as unknown and validated before it becomes a domain value.

Q: How should retries be modeled?

Preserve an ordered collection of attempt outcomes and expose a deliberate final status. This keeps failure evidence while allowing suite summaries to apply a consistent flaky-test policy.

Q: Where should runner-specific result types live?

Keep them in adapters at the framework boundary. Convert them into a stable domain union once, and make reporters and CI policies depend on that union instead of runner internals.

Where To Go Next

Your result union is now a stable contract between execution and reporting. Use the complete TypeScript test framework design guide to place that contract alongside configuration, fixtures, drivers, and reporters.

Then extend the architecture in focused tutorials:

Before integrating with a runner, decide your domain vocabulary. Common additions include timedOut, interrupted, and expectedFailure. Add only statuses with distinct behavior or evidence, then let exhaustive switches show exactly which consumers need updates.

Conclusion

TypeScript discriminated unions test results replace ambiguous optional fields with explicit, compiler-checked outcomes. A shared literal status enables safe narrowing, outcome-specific required data prevents invalid objects, and never checks make future model changes hard to overlook.

Start with passed, failed, and skipped. Add retry history and boundary validation when your pipeline needs them, then adapt runner events into this stable model. The result is reporting code that is simpler to read, safer to evolve, and easier to test.

Interview Questions and Answers

Explain discriminated unions using a test result example.

I define separate passed, failed, and skipped object types with a shared literal status field. A failed member requires error details, while a skipped member requires a reason. When code checks result.status, TypeScript narrows the entire object and exposes only fields valid for that outcome.

How do you enforce exhaustive result handling in TypeScript?

I switch on the discriminant and pass the value in the default branch to a function accepting never. Once all members are handled, the value is never. Adding a new member causes compile errors in consumers that have not defined behavior for it.

Why is one interface with optional properties risky for test outcomes?

It represents states the application should never create, such as a failure without an error or a pass with a skip reason. Consumers must repeatedly check for missing values. A union makes outcome-specific evidence required and moves those rules into the type system.

How do you handle values thrown from test code safely?

I catch them as unknown because JavaScript can throw any value. A normalization function converts Error objects and non-Error values into a serializable TestError shape. This keeps result construction safe without using any.

How would you model test retries without hiding failures?

I store each executed attempt as a passed or failed result in an ordered readonly array. The aggregate retry result exposes the final status while retaining all attempt evidence. A centralized summarizer then applies the team's chosen flaky-test counting policy.

What is the difference between static typing and runtime validation here?

Static typing protects code after a value is trusted by TypeScript, but it does not inspect JSON or messages at runtime. At external boundaries I parse to unknown, validate required fields and literals, and only then return the domain union. Both layers are needed for external result data.

Frequently Asked Questions

What is a discriminated union for TypeScript test results?

It is a union of result object types that share a literal field such as status. Each status has its own required data, so checking status safely narrows the object to a passed, failed, skipped, or other known outcome.

Why not use optional error and skipReason fields on one interface?

Optional fields allow invalid combinations, including a failed result without an error or a passed result with a skip reason. A discriminated union encodes which fields are required for each outcome and reduces repeated defensive checks.

How do I make a switch exhaustive in TypeScript?

Create an assertNever function that accepts a never value and call it in the switch default branch. When a union gains a member that the switch does not handle, TypeScript reports a compile error at that call.

Do TypeScript discriminated unions validate JSON at runtime?

No. TypeScript types are erased when JavaScript runs. Parse external JSON to unknown and validate its fields with guards or a runtime schema library before treating it as a TestResult.

How should flaky retries appear in a test result model?

Store the ordered attempt history and derive a final passed or failed status. Your summary can then count a recovered test as passed while also incrementing a flaky counter, without discarding earlier failure evidence.

Can this result model work with Playwright or Vitest?

Yes. Keep runner-specific callbacks in a small adapter that maps their values into your domain union. Reporters, CI gates, and storage code can then remain independent of the selected test runner.

Related Guides