Have 30-200 Employees? Make $50k-$500k selling your data for AI Training.

Learn More

TypeScript Discriminated Unions: Refactoring Boolean Flags into State Machines

SitePoint Team
SitePoint Team
Published in

The AI briefing for Developers

Stay up to date with AI tools, model releases, and developer workflows that matter.

Weekly. Free. One click to leave.

Share this article

TypeScript Discriminated Unions: Refactoring Boolean Flags into State Machines
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

Prerequisites: Code samples require TypeScript 4.0 or later and a tsconfig.json with "target": "ES2016" or higher and "strict": true. The FormState recursive type alias requires TypeScript 3.7+.

Boolean flags paired with optional fields are a common source of impossible-state bugs in TypeScript codebases. Discriminated unions eliminate these invalid states at compile time, so the compiler rejects objects that combine fields illegally. This article traces the pattern from its simplest form through a common the pitfalls of building members from booleans and into a fully typed multi-step form state machine with an exhaustive reducer.

Table of Contents

The Problem with Boolean Flags and Optional Fields

A Flat Object That Lies to You

Consider a common pattern: a response object shaped as { success: boolean; data?: string[] }, where data exists only when success is true. TypeScript cannot enforce this relationship. The type system sees data as string[] | undefined regardless of the value of success, because both fields belong to a single interface. Nothing prevents a caller from constructing { success: true } with no data, or { success: false, data: ['oops'] }.

interface ApiResponse {
  success: boolean;
  data?: string[];
}

function processResponse(res: ApiResponse) {
  if (res.success) {
    // TypeScript still considers res.data to be string[] | undefined
    // You need a non-null assertion (res.data!) or guard
    console.log(res.data!.join(', '));
  }
}

// Runtime bug illustration — wrapped to prevent unhandled crash:
try {
  processResponse({ success: true }); // TypeError: Cannot read properties of undefined
} catch (e) {
  console.error('Caught expected crash:', (e as Error).message);
}

The non-null assertion hides the real problem. The type permits a state that the business logic considers impossible, and the compiler cannot help.

Why if (obj.b) Doesn't Narrow Optional Fields

Control-flow narrowing in TypeScript operates on the type of the property being checked, not the shape of the enclosing object. When the type is a single interface rather than a union, checking if (res.success) narrows res.success from boolean to true within that branch, but it does not change the type of res itself. The object's shape remains ApiResponse, and data stays string[] | undefined. To narrow an object's shape, its type must be a union of distinct types in the first place. Without that union, the compiler has nothing to narrow to.

Discriminated Unions: The Core Pattern

Replacing the Flag with Literal Discriminants

Discriminated unions combine multiple object types that share a common property whose type is a unique string, number, or boolean literal in each variant. This shared property is called the discriminant property. The pattern is also known as a tagged union or algebraic data type. When code branches on the discriminant, TypeScript narrows the entire object to the specific variant, making all fields of that variant available without assertions.

type ApiResponse =
  | { success: true; data: string[] }
  | { success: false };

function processResponse(res: ApiResponse) {
  switch (res.success) {
    case true:
      // res is narrowed to { success: true; data: string[] }
      console.log(res.data.join(', ')); // no assertion needed
      break;
    case false:
      // res is narrowed to { success: false }
      console.log('Request failed');
      break;
  }
}

// This now produces a compile error — data is required when success is true
// processResponse({ success: true }); // Error: Property 'data' is missing

The compiler rejects the impossible state entirely. If success is true, data must be present. If success is false, providing data causes a type error.

Building a Union Member from a boolean Variable

A different construction problem shows up when a union member is built from a runtime boolean. A variable typed boolean is the union true | false, and the object { b: flag, c: value } has to fit one member of Result below. Before TypeScript 3.5 the compiler rejected that assignment (I confirmed the error on 3.4.5). Since 3.5 it accepts it when the discriminant is a boolean or another small union of literals, so on a current compiler the code in the comment below compiles.

That acceptance is a convenience, not a guarantee that the object is valid. The accepted object can still carry c when b is false. Building each variant in its own branch keeps the data honest and works on every version. If you support older compilers, you will also see the old error in existing Stack Overflow answers.

type Result =
  | { b: true; c: number }
  | { b: false };

// Compiles on TypeScript 3.5+; rejected on 3.4 and earlier:
// function makeResult(flag: boolean, value: number): Result {
//   return { b: flag, c: value };
// }

// Option 1: explicit conditional construction (works on every version)
function makeResultFixed(flag: boolean, value: number): Result {
  if (flag) {
    return { b: true, c: value };
  } else {
    return { b: false };
  }
}

// Option 2: generic helper constrained to literal booleans (requires TypeScript 4.0+)
function createResult<B extends true | false>(
  b: B,
  ...args: B extends true ? [c: number] : []
): Result {
  if (b) {
    const c = (args as [number])[0];
    if (typeof c !== 'number') {
      throw new Error('createResult: c is required when b is true');
    }
    return { b: true, c };
  }
  return { b: false };
}

The first option builds each variant in its own branch. Each branch returns an object literal that matches exactly one union member, so c only exists when b is true. The second option uses a generic type parameter constrained to true | false, allowing the compiler to resolve the correct branch based on the argument's literal type at each call site. A runtime guard protects against callers passing a widened boolean value, which can bypass the compile-time conditional rest type guarantee (a boolean-typed argument makes the rest type [c: number] | []).

Narrowing in Practice: switch, in, and Control Flow

switch Statements and Literal Discriminants

switch on a discriminant property narrows the union to exactly one variant per case block. Each case branch sees only the fields defined for that variant. While if/else if chains work identically for narrowing purposes, switch is the conventional choice for tagged unions because it maps one branch per variant and integrates naturally with exhaustiveness checking via a default clause.

The in Operator as an Alternative Discriminant

When a unique property exists only in one variant, the in operator can narrow without a dedicated discriminant field. Checking if ('data' in obj) narrows to the variant containing data. However, this approach is less explicit, harder to exhaust systematically, and breaks when two variants share a property name, since the in check can no longer distinguish between them. Use it to supplement, not replace, literal discriminants.

type CSVFieldResult =
  | { kind: 'number'; value: number }
  | { kind: 'nan'; raw: string };

function parseCSVField(raw: string): CSVFieldResult {
  // Number() rejects trailing non-numeric characters ('42abc' → NaN)
  const parsed = Number(raw);
  if (Number.isNaN(parsed)) {
    return { kind: 'nan', raw };
  }
  return { kind: 'number', value: parsed };
}

// parseCSVField('42abc') → { kind: 'nan', raw: '42abc' }
// parseCSVField('42')    → { kind: 'number', value: 42 }
// parseCSVField('3.14')  → { kind: 'number', value: 3.14 }

const result = parseCSVField('42abc');
if (result.kind === 'number') {
  console.log(result.value);
} else {
  console.log('Not a number:', result.raw);
}

This example demonstrates discriminated union narrowing outside of object configuration patterns. It uses Number() rather than parseInt to correctly reject strings with trailing non-numeric characters ('42abc' → NaN) and to preserve decimal values ('3.14' → 3.14). The discriminated return type forces callers to handle both outcomes explicitly.

Exhaustive Checking with the never Type

What Is Exhaustiveness and Why It Matters

Exhaustiveness means branching logic handles every member of a union. When a new variant is added to the union, the compiler flags all unhandled cases. Without exhaustiveness, adding a variant silently falls through existing branches, typically into a default case that was written for a different set of states, producing runtime errors that surface only after refactors reach production.

The assertNever Pattern

The canonical exhaustiveness technique places an assertNever function in the default branch of a switch. If every variant is handled, the remaining type in default is never. If a variant is missed, the type is not never, and the assignment to the never parameter fails at compile time. In a pure TypeScript codebase this line is unreachable at runtime; it guards against JavaScript callers or unsafe type assertions that bypass compile-time checking.

export function assertNever(x: never): never {
  let description: string;
  try {
    description = JSON.stringify(x);
  } catch {
    description = String(x);
  }
  throw new Error(`Unexpected value: ${description}`);
}

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
  | { kind: 'triangle'; base: number; height: number }; // new variant added

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle': return Math.PI * (shape.radius ** 2);
    case 'square': return shape.side ** 2;
    // 'triangle' is not handled — intentionally incomplete to demonstrate compile error
    default: return assertNever(shape);
    // Compile error: Argument of type '{ kind: "triangle"; base: number; height: number }'
    //                is not assignable to parameter of type 'never'
  }
}

The error appears the moment the code compiles, before any test runs. Adding case 'triangle' resolves it and restores exhaustiveness.

Building a Multi-Step Form State Machine

Defining the State Union

A FormState type with a kind discriminant models each step as a distinct variant. Each variant carries only the data relevant to that step, making impossible combinations unrepresentable. The FormState type uses a recursive type alias (supported since TypeScript 3.7) to allow the error variant to reference FormState itself.

type FormState =
  | { kind: 'step1'; name: string; email: string }
  | {
      kind: 'step2';
      step1Data: { name: string; email: string };
      address: string;
      city: string;
    }
  | {
      kind: 'submitting';
      step1Data: { name: string; email: string };
      step2Data: { address: string; city: string };
    }
  | {
      kind: 'done';
      confirmationId: string;
    }
  | {
      kind: 'error';
      message: string;
      lastState: Exclude<FormState, { kind: 'error' }>;
    };

The step2 variant includes validated step1Data, ensuring that reaching step two without completing step one is structurally impossible. The error variant carries lastState for retry logic, but excludes nested errors via Exclude.

Defining Actions as a Discriminated Union

Actions model the transitions between states. Each action is its own tagged union member. The NEXT action is split into step-specific variants so that each transition carries only the data relevant to its source step, preventing structurally incomplete payloads.

type FormAction =
  | { type: 'NEXT_STEP1'; name: string; email: string }
  | { type: 'NEXT_STEP2'; address: string; city: string }
  | { type: 'SUBMIT' }
  | { type: 'SUBMIT_SUCCESS'; confirmationId: string }
  | { type: 'SUBMIT_FAILURE'; message: string }
  | { type: 'RETRY' }
  | { type: 'RESET' };

The Exhaustive Reducer

The reducer uses nested switch statements. The outer switch dispatches on state.kind, the inner on action.type where multiple actions apply to a single state. Both levels use assertNever to guarantee exhaustiveness.

Each inner switch must enumerate every member of FormAction, either as a handled transition or an explicit no-op, for the default branch to see never. This verbosity is the cost of the pattern, and it pays for itself: adding a new action type to FormAction produces compile errors in every inner switch that does not list it.

import { assertNever } from './shared/assertNever';

const INITIAL_STATE: FormState = { kind: 'step1', name: '', email: '' };

function formReducer(state: FormState, action: FormAction): FormState {
  switch (state.kind) {
    case 'step1':
      switch (action.type) {
        case 'NEXT_STEP1':
          return {
            kind: 'step2',
            step1Data: { name: action.name, email: action.email },
            address: '',
            city: '',
          };
        case 'RESET': return INITIAL_STATE;
        case 'NEXT_STEP2': case 'SUBMIT': case 'SUBMIT_SUCCESS': case 'SUBMIT_FAILURE': case 'RETRY':
          return state; // invalid transitions — no-op
        default: return assertNever(action);
      }

    case 'step2':
      switch (action.type) {
        case 'NEXT_STEP2':
          return {
            kind: 'step2',
            step1Data: state.step1Data,
            address: action.address,
            city: action.city,
          };
        case 'SUBMIT':
          return {
            kind: 'submitting',
            step1Data: state.step1Data,
            step2Data: { address: state.address, city: state.city },
          };
        case 'RESET': return INITIAL_STATE;
        case 'NEXT_STEP1': case 'SUBMIT_SUCCESS': case 'SUBMIT_FAILURE': case 'RETRY':
          return state;
        default: return assertNever(action);
      }

    case 'submitting':
      switch (action.type) {
        case 'SUBMIT_SUCCESS': return { kind: 'done', confirmationId: action.confirmationId };
        case 'SUBMIT_FAILURE': return { kind: 'error', message: action.message, lastState: state };
        case 'NEXT_STEP1': case 'NEXT_STEP2': case 'SUBMIT': case 'RETRY': case 'RESET':
          return state;
        default: return assertNever(action);
      }

    case 'done':
      switch (action.type) {
        case 'RESET': return INITIAL_STATE;
        case 'NEXT_STEP1': case 'NEXT_STEP2': case 'SUBMIT': case 'SUBMIT_SUCCESS': case 'SUBMIT_FAILURE': case 'RETRY':
          return state;
        default: return assertNever(action);
      }

    case 'error':
      switch (action.type) {
        case 'RETRY': return state.lastState;
        case 'RESET': return INITIAL_STATE;
        case 'NEXT_STEP1': case 'NEXT_STEP2': case 'SUBMIT': case 'SUBMIT_SUCCESS': case 'SUBMIT_FAILURE':
          return state;
        default: return assertNever(action);
      }

    default: return assertNever(state);
  }
}

Why This Beats a Boolean-Flags Approach

A flat alternative like { step: number; isSubmitting: boolean; error?: string; data?: object } permits states that should never exist: isSubmitting: true with error defined simultaneously, step: 3 with no data from steps one or two, or error populated while isSubmitting remains true. With the flat type, you write guards like if (state.isSubmitting && !state.error) at every consumption site. The discriminated union eliminates these guards structurally. Adding a sixth state to the union triggers compile errors everywhere the reducer and any consuming code fails to handle it.

Comparison: ?: never vs. Discriminated Unions

Criterion ?: never Pattern Discriminated Union
Narrowing ergonomics Requires in checks or type predicates; no single property to switch on switch (obj.kind) narrows instantly
Exhaustiveness errors No built-in mechanism; manual checks required assertNever in default gives compile-time errors
Unsound covariant writes Potentially unsound: structural subtyping may allow a wider-typed variable to bypass the exclusive-field constraint; you should still validate at runtime Each variant is a distinct type; covariant writes do not silently merge shapes
Explicit discriminant required? No. Relies on structural absence of properties Yes. Requires a shared literal property across all variants
Best use case Small, two-variant objects where adding a kind field is impractical Three or more variants, state machines, reducers, any code path requiring exhaustiveness

The ?: never pattern works by marking a property as optional with type never, which prevents any non-undefined value from being assigned; undefined remains assignable unless exactOptionalPropertyTypes: true is set in tsconfig.json. It is useful for lightweight exclusive-field constraints but provides none of the narrowing or exhaustiveness guarantees that discriminated unions offer.

Practical Guidelines and Pitfalls

When to Reach for Discriminated Unions

Discriminated unions fit well in these scenarios:

  • Three or more states that share a common shape
  • Any state machine or workflow with defined transitions
  • API response types with distinct shapes per outcome
  • Reducer patterns where transitions must be exhaustively handled

Two-variant unions also get compile-time exhaustiveness and narrowing from the pattern, as the ApiResponse example earlier demonstrates.

Common Mistakes

Using string instead of a string literal for the discriminant defeats narrowing entirely. With kind: string on every variant, switch (x.kind) does not narrow the union, so reading a variant-only field such as x.a inside case 'a' fails with error TS2339 (property does not exist on the union).

Building union members from a runtime boolean without branching can produce objects that carry fields the other variant should not have, even when the compiler accepts it (see above).

Nesting unions more than one level deep (a discriminated union inside another discriminated union) complicates narrowing and makes reducers harder to read. Prefer flat discriminants with descriptive kind values like 'step2-loading' over nested structures.

Next Steps

Boolean flags hide impossible states behind runtime assumptions. Discriminated unions surface those states at compile time, and exhaustive checking with assertNever ensures that future additions to the union cannot silently slip through existing code paths.

Find one boolean-flag type in your codebase, refactor it into a discriminated union, and let the compiler reveal every location that assumed an impossible state was safe.

SitePoint TeamSitePoint Team

Sharing our passion for building incredible internet things.

© 2000 – 2026 SitePoint Pty. Ltd.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.