This TypeScript error occurs when a discriminant property in a union type cannot effectively narrow the type due to issues like non-literal types, assignable types overlapping, or intersection conflicts. Proper discriminated unions require literal type discriminants and non-overlapping type members.
TypeScript's discriminated unions are a powerful pattern for working with union types that share a common literal property (the discriminant). When you check the discriminant property in a conditional, TypeScript can narrow the union to the specific type that matches the discriminant value. However, TypeScript cannot perform this narrowing when: 1. **The discriminant property is not a literal type**: Using `string` or `boolean` instead of specific literal values like `"success"` or `true` prevents narrowing 2. **Types in the union are assignable to each other**: When one type's shape can satisfy another type in the union, TypeScript cannot definitively narrow 3. **The discriminant property appears in intersections or complex type manipulations**: Type operations can break the compiler's ability to recognize the discriminant pattern 4. **The discriminant is accessed from a wider union before narrowing**: When narrowing from a broader type (like `Record<string, any> | MyUnion`), the discriminant may not work correctly This error typically manifests as TypeScript refusing to narrow a union type even when you've checked the discriminant property, leaving you with the full union type instead of the specific narrowed type you expected.
The discriminant property must use literal types, not general types like string or boolean:
// WRONG - discriminant uses general string type
type BadResult =
| { status: string; data: string }
| { status: string; error: Error };
function process(result: BadResult) {
if (result.status === "success") {
// TypeScript cannot narrow - 'data' might not exist
console.log(result.data); // Error
}
}
// CORRECT - discriminant uses literal types
type GoodResult =
| { status: "success"; data: string }
| { status: "error"; error: Error };
function processGood(result: GoodResult) {
if (result.status === "success") {
// TypeScript narrows successfully
console.log(result.data); // OK - knows this is the success type
}
}Each union member must have a unique literal value for the discriminant property.
If one type in your union can satisfy another type's structure, TypeScript cannot narrow reliably:
// WRONG - Person is assignable to Employee (all Employee properties exist on Person)
type Employee = { name: string; role: string };
type Person = { name: string; role: string; age: number };
type BadUnion = Employee | Person;
// CORRECT - use discriminant to make types mutually exclusive
type GoodEmployee = { kind: "employee"; name: string; role: string };
type GoodPerson = { kind: "person"; name: string; role: string; age: number };
type GoodUnion = GoodEmployee | GoodPerson;
function identify(entity: GoodUnion) {
if (entity.kind === "employee") {
// Narrowed to GoodEmployee
console.log(entity.name, entity.role);
} else {
// Narrowed to GoodPerson
console.log(entity.name, entity.age);
}
}Add a discriminant property with unique literal values to make the types mutually exclusive.
Intersecting a discriminated union with other types can prevent TypeScript from recognizing the discriminant pattern:
type Action =
| { type: "increment"; amount: number }
| { type: "decrement"; amount: number };
// WRONG - intersection breaks narrowing
type TimestampedAction = Action & { timestamp: Date };
function handle(action: TimestampedAction) {
if (action.type === "increment") {
// May not narrow correctly depending on TypeScript version
console.log(action.amount);
}
}
// CORRECT - add timestamp to each union member individually
type BetterAction =
| { type: "increment"; amount: number; timestamp: Date }
| { type: "decrement"; amount: number; timestamp: Date };
function handleBetter(action: BetterAction) {
if (action.type === "increment") {
// Narrows correctly
console.log(action.amount, action.timestamp);
}
}Distribute shared properties across all union members instead of using intersections.
When narrowing a variable that starts as a wider union, the discriminant may not work:
type MyData =
| { type: "text"; content: string }
| { type: "image"; url: string };
// WRONG - wider initial type prevents narrowing
type ApiResponse = Record<string, any> | MyData;
function process(response: ApiResponse) {
if (typeof response === "object" && "type" in response) {
if (response.type === "text") {
// May not narrow - still typed as ApiResponse
console.log(response.content); // Error
}
}
}
// CORRECT - use specific type or type guard
function isMyData(value: ApiResponse): value is MyData {
return (
typeof value === "object" &&
value !== null &&
"type" in value &&
(value.type === "text" || value.type === "image")
);
}
function processCorrect(response: ApiResponse) {
if (isMyData(response)) {
// Now response is MyData
if (response.type === "text") {
console.log(response.content); // OK
}
}
}Use type guards to narrow to your discriminated union first, then narrow within it.
If the discriminant property is optional in some union members, narrowing fails:
// WRONG - discriminant is optional
type BadState =
| { status?: "loading" }
| { status: "success"; data: string }
| { status: "error"; error: string };
function render(state: BadState) {
if (state.status === "success") {
// Cannot narrow reliably - status might be undefined
console.log(state.data); // Error
}
}
// CORRECT - discriminant is required in all members
type GoodState =
| { status: "loading" }
| { status: "success"; data: string }
| { status: "error"; error: string };
function renderGood(state: GoodState) {
if (state.status === "success") {
console.log(state.data); // OK
}
}Make the discriminant property required (non-optional) in every union member.
When creating discriminated union values, use as const to ensure literal types:
type Result =
| { type: "success"; value: number }
| { type: "failure"; error: string };
// WRONG - type is inferred as string, not literal "success"
const result1 = { type: "success", value: 42 };
// Type: { type: string; value: number }
// This won't narrow correctly
function handle(r: Result) {
if (r.type === "success") {
console.log(r.value); // May error
}
}
// CORRECT - use const assertion
const result2 = { type: "success", value: 42 } as const;
// Type: { readonly type: "success"; readonly value: 42 }
// Or explicitly type it
const result3: Result = { type: "success", value: 42 };
// Type: Result (which narrows correctly)Use as const or explicit type annotations when creating discriminated union values.
### TypeScript Version Differences
Discriminated union narrowing has improved significantly across TypeScript versions. Earlier versions (pre-3.5) had more limitations. If you're experiencing issues:
- Upgrade to the latest TypeScript version (5.x+ recommended)
- Check the [TypeScript release notes](https://www.typescriptlang.org/docs/handbook/release-notes/overview.html) for narrowing improvements
- Some edge cases may be known bugs tracked in the TypeScript GitHub issues
### Complex Generic Discriminated Unions
When using generics with discriminated unions, ensure the discriminant survives type parameter substitution:
type Result<T> =
| { status: "success"; data: T }
| { status: "error"; error: Error };
function unwrap<T>(result: Result<T>): T {
if (result.status === "success") {
return result.data; // Correctly narrowed
}
throw result.error;
}### Exhaustiveness Checking
Use the never type to ensure all discriminated union cases are handled:
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "reset" };
function reducer(action: Action) {
switch (action.type) {
case "increment":
return;
case "decrement":
return;
case "reset":
return;
default:
// If you add a new Action type and forget to handle it,
// this line will error because action won't be 'never'
const _exhaustive: never = action;
return _exhaustive;
}
}### Union Distribution in Conditional Types
Discriminated unions interact with conditional types through distribution. This can affect narrowing in mapped types:
type ExtractByType<T, K extends string> = T extends { type: K } ? T : never;
type Action =
| { type: "add"; payload: number }
| { type: "remove"; payload: number };
// Extracts only the "add" action type
type AddAction = ExtractByType<Action, "add">;
// Result: { type: "add"; payload: number }### Known Limitations
Some patterns inherently prevent discriminant narrowing and are tracked as TypeScript issues:
- Issue #48522: Non-literal discriminator properties don't narrow
- Issue #56106: Narrowing fails when types are assignable to each other
- Issue #55425: Narrowing breaks when discriminating from wider union types
- Issue #31404: Union discriminant properties with union values
- Issue #9919: Intersection with discriminated unions breaks narrowing
For these cases, consider using explicit type guards or restructuring your types to avoid the problematic pattern.
Function expression requires a return type
Function expression requires a return type
Value of type 'string | undefined' is not iterable
How to fix "Value is not iterable" in TypeScript
Type 'undefined' is not assignable to type 'string'
How to fix "Type undefined is not assignable to type string" in TypeScript
Type narrowing from typeof check produces 'never'
How to fix "Type narrowing produces never" in TypeScript
Type parameter 'T' has conflicting constraints
How to fix "Type parameter has conflicting constraints" in TypeScript