Skip to content

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

FixDevs ·

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.

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 and Fix: TypeScript Template Literal Type 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