Fix: TypeScript Discriminated Union Error — Property Does Not Exist or Narrowing Not Working
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 types —
kind: 'circle'(string literal) works.kind: stringdoesn’t — TypeScript can’t distinguish variants by a non-literal type. - Narrowing must happen before property access — accessing
shape.radiuswithout first checkingshape.kind === 'circle'fails because TypeScript doesn’t know which variant is active. switch/ifwithout exhaustion — TypeScript doesn’t automatically warn about missing switch cases unless you add explicit exhaustive checking with thenevertype.
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 missingPattern 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 type — kind: 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 ShapeFor related TypeScript issues, see Fix: TypeScript Mapped Type Error and Fix: TypeScript Template Literal Type Error.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: TypeScript Conditional Types Not Working — infer Not Extracting, Distributive Behavior Unexpected, or Type Resolves to never
How to fix TypeScript conditional type issues — infer keyword usage, distributive conditional types, deferred evaluation, naked type parameters, and common conditional type patterns.
Fix: TypeScript Template Literal Type Error — Type Not Assignable or Inference Fails
How to fix TypeScript template literal type errors — string combination types, conditional inference, Extract and mapped types with template literals, and common pitfalls.
Fix: CodeMirror Not Working — Editor Not Rendering, Extensions Not Loading, or React State Out of Sync
How to fix CodeMirror 6 issues — basic setup, language and theme extensions, React integration, vim mode, collaborative editing, custom keybindings, and read-only mode.
Fix: GSAP Not Working — Animations Not Playing, ScrollTrigger Not Firing, or React Cleanup Issues
How to fix GSAP animation issues — timeline and tween basics, ScrollTrigger setup, React useGSAP hook, cleanup and context, SplitText, stagger animations, and Next.js integration.