Fix: TypeScript Discriminated Union Error — Property Does Not Exist or Narrowing Not Working
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 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.
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 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, Fix: TypeScript Template Literal Type Error, Fix: TypeScript Conditional Types Not Working, and Fix: TypeScript Generic Constraint 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.