Skip to content

Fix: TypeScript Discriminated Union Error — Property Does Not Exist or Narrowing Not Working

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix TypeScript discriminated union errors — type guards, exhaustive checks, narrowing with in operator, never type, and common patterns for tagged unions.

The Problem

TypeScript reports a property doesn’t exist on a union type:

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number };

function area(shape: Shape): number {
  return Math.PI * shape.radius ** 2;
  // Error: Property 'radius' does not exist on type 'Shape'
  // Property 'radius' does not exist on type '{ kind: "square"; side: number }'
}

Or narrowing doesn’t work as expected:

type Result =
  | { success: true; data: User }
  | { success: false; error: string };

function handleResult(result: Result) {
  if (result.success) {
    console.log(result.data.name);  // Error: Property 'data' does not exist
  }
}

Or an exhaustive check doesn’t catch a missing case:

function getLabel(shape: Shape): string {
  switch (shape.kind) {
    case 'circle': return `Circle r=${shape.radius}`;
    // Missing 'square' case — no TypeScript error
  }
}

Why This Happens

TypeScript’s type narrowing works by tracking which type is “active” in each code branch. For it to work correctly:

  • The discriminant property must have literal typeskind: 'circle' (string literal) works. kind: string doesn’t — TypeScript can’t distinguish variants by a non-literal type.
  • Narrowing must happen before property access — accessing shape.radius without first checking shape.kind === 'circle' fails because TypeScript doesn’t know which variant is active.
  • switch/if without exhaustion — TypeScript doesn’t automatically warn about missing switch cases unless you add explicit exhaustive checking with the never type.

TypeScript implements narrowing through control-flow analysis. As the compiler walks the body of a function, it maintains an active type for every variable in scope and refines that type at branch points. The refinement is driven by a small set of “narrowing predicates” the compiler understands: typeof x === 'string', x instanceof Foo, x.kind === 'circle', 'radius' in x, equality against literal types, truthiness checks, and user-defined type guard functions that return a value is T type predicate. If your check does not match one of these patterns, the compiler cannot narrow and you get the “Property does not exist” error inside the branch.

The discriminated union pattern requires three things to line up: every variant of the union has a shared property name (the discriminant), that property has a literal type in each variant, and the values of the literal type are unique per variant. When all three hold, the compiler can refine the union to a single variant inside an equality check on the discriminant. The most common breakage is widening — assigning { kind: 'circle', radius: 5 } to a non-const variable widens kind from 'circle' to string, losing the discriminator. as const, the satisfies operator, or explicit Shape annotation prevents widening. Another silent failure is reassigning the variable inside a branch (shape = someOtherShape), which invalidates the narrowing because TypeScript can no longer prove the type after reassignment.

In Production: Incident Lens

The discriminated-union bugs that ship to production almost always trace back to a missing case after a variant was added in a recent feature branch. A team adds { kind: 'pending_review' } to an existing OrderState union to support a new approval workflow, the PR passes review because the new variant is correctly handled in the new code paths, but three older switch (state.kind) blocks scattered across reporting, analytics, and the audit log silently fall through. The reporting page returns undefined, the analytics counter does not increment, and the audit log writes null. None of these are errors at runtime, so monitoring stays green until the support team escalates a billing discrepancy a week later. Adding assertNever(state) to every switch on a union — and treating any new lint failure from that as a hard release blocker — prevents the entire class.

The second production failure mode is JSON deserialization. A discriminated union typed in TypeScript means nothing at the wire boundary: an API response of { ok: false, error: 'X' } parsed with JSON.parse produces an object whose ok field is typed as boolean, not the literal false. If you immediately read result.error without a runtime check, you get undefined whenever the server returns a malformed response. Validate every incoming payload with Zod, Valibot, or a hand-written type guard before treating it as the discriminated union — the type system cannot enforce what came in over the network.

The third recurring incident is reassignment inside an event handler. You narrow state.kind === 'editing' at the top of the handler, then call a setter that re-fetches state from a store, then read state.draft. TypeScript silently widens state back to the full union after the setter call (because the setter could have changed the reference), so the second read is unsafe. Copy the narrowed value into a const immediately after the narrowing check — const editing = state — and operate on the const for the rest of the handler.

How Other Tools Handle This

Discriminated unions are a foundational feature in languages with strong type systems, and the design choices vary in informative ways.

TypeScript discriminated unions vs Flow exact unions. Flow predates TypeScript’s discriminated union narrowing and uses “exact object types” ({| kind: 'circle', radius: number |}) to ensure no extra properties leak in. TypeScript chose structural typing without exact types as a default, which means { kind: 'circle', radius: 5, color: 'red' } is assignable to { kind: 'circle'; radius: number }. Flow’s exactness is sometimes desirable for serialization safety; TypeScript approximates it with the satisfies operator plus tools like exactOptionalPropertyTypes in tsconfig.json.

TypeScript vs ReasonML/ReScript variants. ReasonML and ReScript inherit OCaml’s variant types: type shape = Circle(float) | Square(float) | Rectangle(float, float). Pattern matching is exhaustive by default — the compiler refuses to compile a switch that misses a case. There is no separate “discriminant” because the variant tag is the type itself. TypeScript’s discriminated unions are an emulation of this pattern over plain JavaScript objects, with the discriminant string standing in for the variant constructor. The tradeoff is that ReScript’s variants are unambiguous and exhaustive without assertNever boilerplate, while TypeScript’s are interoperable with idiomatic JS object payloads.

TypeScript vs Rust enums. Rust’s enum Shape { Circle(f64), Square(f64), Rectangle(f64, f64) } plus match shape { ... } is the strongest version of this pattern. The compiler refuses non-exhaustive matches with error[E0004]: non-exhaustive patterns. Rust enums also support data per variant with full type checking inside each arm, no separate “tag field.” TypeScript developers coming from Rust often miss the compile-time exhaustiveness — the assertNever trick approximates it but requires manual setup on every switch.

TypeScript vs Kotlin sealed classes. Kotlin’s sealed class Shape with data class Circle(val radius: Double) : Shape() etc. produces the same shape: a closed union, exhaustive when expression, and no need for a discriminant string because the JVM class identity is the tag. Kotlin’s when (shape) { is Circle -> ...; is Square -> ... } is the closest spiritual match to TypeScript’s narrowing, but works via instanceof-style checks rather than a string field. Kotlin also enforces exhaustiveness when when is used as an expression (not as a statement), unless an else branch is provided.

Exhaustiveness checks compared. Rust, ReScript, Kotlin (as expression), and Swift enforce exhaustiveness by default. TypeScript and Flow require explicit assertNever or satisfies never. The cost of the TypeScript approach is real: adding a new variant to a union does not immediately surface every place that handles it incompletely — only the places that included an exhaustiveness check break. The mitigation is to make assertNever (or shape satisfies never) a hard rule in every switch on a discriminated union, enforced by lint or code review. The benefit of TypeScript’s approach is gradualism: you can adopt discriminated unions piecemeal without forcing every consumer to handle every variant.

Tag field conventions. TypeScript conventions are unsettled. kind is the most common (because the TypeScript docs use it), type is second (which collides with the type keyword and confuses search), tag is the Redux/FP convention, and _tag is used by libraries that interoperate with fp-ts and Effect-TS. There is no functional difference, but consistency within a codebase matters because Extract<Shape, { kind: 'circle' }> does not work on a union that uses type for some variants and kind for others. Pick one name and enforce it project-wide.

Fix 1: Narrow Before Accessing Variant Properties

Use the discriminant to narrow the type before accessing variant-specific properties:

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
  | { kind: 'rectangle'; width: number; height: number };

// WRONG — no narrowing
function area(shape: Shape): number {
  return Math.PI * shape.radius ** 2;  // Error: 'radius' doesn't exist on all variants
}

// CORRECT — narrow with if
function area(shape: Shape): number {
  if (shape.kind === 'circle') {
    return Math.PI * shape.radius ** 2;  // shape is { kind: 'circle'; radius: number }
  }
  if (shape.kind === 'square') {
    return shape.side ** 2;  // shape is { kind: 'square'; side: number }
  }
  // shape is { kind: 'rectangle'; width: number; height: number }
  return shape.width * shape.height;
}

// CORRECT — narrow with switch
function describe(shape: Shape): string {
  switch (shape.kind) {
    case 'circle':
      return `Circle with radius ${shape.radius}`;  // radius is available here
    case 'square':
      return `Square with side ${shape.side}`;
    case 'rectangle':
      return `Rectangle ${shape.width}×${shape.height}`;
  }
}

Fix 2: Use the never Type for Exhaustive Checks

Force TypeScript to warn about missing cases with a never assertion:

// Exhaustive switch — fails at compile time if a case is missing
function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'square':
      return shape.side ** 2;
    case 'rectangle':
      return shape.width * shape.height;
    default:
      return assertNever(shape);  // Error if shape can still be something
      // If you add a new Shape variant without handling it here,
      // TypeScript reports: "Argument of type 'NewShape' is not assignable to parameter of type 'never'"
  }
}

// Adding a new variant without updating area() causes a compile error:
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
  | { kind: 'rectangle'; width: number; height: number }
  | { kind: 'triangle'; base: number; height: number };  // New variant

// area() now fails at compile time: 'triangle' case missing

Pattern without assertNever (shorter):

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle': return Math.PI * shape.radius ** 2;
    case 'square': return shape.side ** 2;
    case 'rectangle': return shape.width * shape.height;
  }
  // TypeScript reports error here if not all cases handled:
  // "Function lacks ending return statement and return type does not include 'undefined'"
  shape satisfies never;  // TypeScript 4.9+ — explicit never assertion
}

Fix 3: Type Guards for Complex Narrowing

When the discriminant isn’t a simple equality check, use type guard functions:

// Using 'in' operator
type ApiResponse =
  | { kind: 'user'; name: string; email: string }
  | { kind: 'product'; title: string; price: number };

function isUser(response: ApiResponse): response is { kind: 'user'; name: string; email: string } {
  return response.kind === 'user';
}

// More complex type guards
type NetworkState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: unknown }
  | { status: 'error'; message: string };

function isSuccess(state: NetworkState): state is Extract<NetworkState, { status: 'success' }> {
  return state.status === 'success';
}

function isError(state: NetworkState): state is Extract<NetworkState, { status: 'error' }> {
  return state.status === 'error';
}

// Usage
function renderState(state: NetworkState) {
  if (isSuccess(state)) {
    return `Data: ${JSON.stringify(state.data)}`;  // state.data is available
  }
  if (isError(state)) {
    return `Error: ${state.message}`;  // state.message is available
  }
  return state.status;  // 'idle' | 'loading'
}

Using the in operator for narrowing:

type Cat = { meow: () => void };
type Dog = { bark: () => void; fetch: () => void };
type Animal = Cat | Dog;

function makeSound(animal: Animal) {
  if ('meow' in animal) {
    animal.meow();  // animal is Cat
  } else {
    animal.bark();  // animal is Dog
  }
}

Fix 4: Extract and Exclude Utility Types

Use TypeScript’s built-in utility types to work with union members:

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
  | { kind: 'rectangle'; width: number; height: number };

// Extract a specific union member
type Circle = Extract<Shape, { kind: 'circle' }>;
// { kind: 'circle'; radius: number }

// Exclude a specific union member
type NonCircle = Exclude<Shape, { kind: 'circle' }>;
// { kind: 'square'; side: number } | { kind: 'rectangle'; width: number; height: number }

// Get all discriminant values
type ShapeKind = Shape['kind'];
// 'circle' | 'square' | 'rectangle'

// Map over union members
type ShapeArea = {
  [K in ShapeKind]: (shape: Extract<Shape, { kind: K }>) => number;
};

const areaCalculators: ShapeArea = {
  circle: (s) => Math.PI * s.radius ** 2,
  square: (s) => s.side ** 2,
  rectangle: (s) => s.width * s.height,
};

Fix 5: Discriminated Unions for API Responses

A real-world pattern for typed API responses:

// Typed API response pattern
type ApiResult<T> =
  | { ok: true; data: T; status: number }
  | { ok: false; error: string; status: number; code?: string };

async function fetchUser(id: string): Promise<ApiResult<User>> {
  const res = await fetch(`/api/users/${id}`);
  if (res.ok) {
    const data = await res.json();
    return { ok: true, data, status: res.status };
  }
  const error = await res.text();
  return { ok: false, error, status: res.status };
}

// Usage — TypeScript knows the exact shape
async function loadUser(id: string) {
  const result = await fetchUser(id);
  if (result.ok) {
    displayUser(result.data);  // result.data is User
  } else {
    showError(result.error);   // result.error is string
    if (result.status === 401) redirectToLogin();
  }
}

// Async state machine
type AsyncState<T> =
  | { phase: 'idle' }
  | { phase: 'loading' }
  | { phase: 'success'; data: T; fetchedAt: Date }
  | { phase: 'error'; error: Error; retryCount: number };

function useAsyncState<T>(fetcher: () => Promise<T>) {
  const [state, setState] = React.useState<AsyncState<T>>({ phase: 'idle' });

  const fetch = async () => {
    setState({ phase: 'loading' });
    try {
      const data = await fetcher();
      setState({ phase: 'success', data, fetchedAt: new Date() });
    } catch (error) {
      setState({ phase: 'error', error: error as Error, retryCount: 0 });
    }
  };

  return { state, fetch };
}

Fix 6: Discriminated Unions with Classes

Classes can participate in discriminated unions:

class HttpOk<T> {
  readonly kind = 'ok' as const;
  constructor(public readonly data: T, public readonly status = 200) {}
}

class HttpError {
  readonly kind = 'error' as const;
  constructor(
    public readonly message: string,
    public readonly status: number,
    public readonly code?: string
  ) {}
}

class HttpRedirect {
  readonly kind = 'redirect' as const;
  constructor(public readonly url: string, public readonly status = 302) {}
}

type HttpResult<T> = HttpOk<T> | HttpError | HttpRedirect;

function handleResponse<T>(result: HttpResult<T>): T | null {
  switch (result.kind) {
    case 'ok':
      return result.data;  // result is HttpOk<T>
    case 'error':
      console.error(`${result.status}: ${result.message}`);
      return null;
    case 'redirect':
      window.location.href = result.url;
      return null;
  }
}

Still Not Working?

Discriminant must be a literal typekind: string doesn’t work as a discriminant. It must be a literal (kind: 'circle') or a literal union (kind: 'circle' | 'oval'). TypeScript requires the discriminant to narrow to a specific type.

Narrowing breaks after function calls — TypeScript’s control flow analysis doesn’t track narrowing across function calls. If you narrow shape.kind === 'circle', then call a function that might change shape, the narrowing is lost. Store the narrowed value in a const before calling other functions.

Union type with optional properties vs discriminated union — an interface with optional properties ({ radius?: number; side?: number }) is harder to work with than a discriminated union. Refactor to tagged unions for better TypeScript narrowing.

satisfies operator (TypeScript 4.9+) — use satisfies to check that a value matches a type without widening:

const config = {
  kind: 'circle',
  radius: 5,
} satisfies Shape;
// config.radius is number (not narrowed away), and it must satisfy Shape

For related TypeScript issues, see Fix: TypeScript Mapped Type Error, Fix: TypeScript Template Literal Type Error, Fix: TypeScript Conditional Types Not Working, and Fix: TypeScript Generic Constraint Error.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles