What this usually means
TypeScript's control flow analysis relies on the discriminant property being a literal type and present in every union member. If any member omits the discriminant, makes it optional, or has a wider type (like string instead of 'a' | 'b'), the narrowing fails. Another common cause is excess property checks when creating an object literal: if you assign an object with more properties than expected, TypeScript may widen the type and lose the discriminant. Type assertions (as) can also override the inferred literal types and break narrowing.
The first ten minutes — establish facts before touching code.
- 1Run tsc --noEmit and check the exact error message; note the line and the property name.
- 2Inspect the union type definition: verify every member has the discriminant property with a literal type (e.g., type: 'circle' not type: string).
- 3Check for optional discriminant properties: ensure the discriminant is required and not optional (?).
- 4Look for excess properties in object literals: if you're creating an object inline, ensure it doesn't have extra properties beyond the union member's shape.
- 5Use TypeScript's 'Expand' utility or hover over the variable in VS Code to see what type TypeScript infers.
- 6Test with a simple if-else block without any helper functions to isolate the narrowing logic.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchThe union type definition (likely a type or interface declaration).
- searchThe condition where you check the discriminant (if, switch, ternary).
- searchObject literal creation sites where you assign to a variable of the union type.
- searchFunction signatures where the union type is used as a parameter or return type.
- searchType assertions or 'as' casts that might widen the type.
- searchThe tsconfig.json 'strict' settings (especially 'strictNullChecks').
Practical causes, not theory. These are the things you will actually find.
- warningThe discriminant property is optional in one or more union members.
- warningThe discriminant property is typed as a general string instead of a string literal union.
- warningObject literal has excess properties that cause TypeScript to widen the type to an interface with extra properties.
- warningUsing a type assertion (as) that erases the literal type of the discriminant.
- warningThe union includes a type that doesn't have the discriminant property at all.
- warningUsing a generic function where the discriminant type parameter is not constrained to a literal type.
Concrete fix directions. Pick the one that matches your root cause.
- buildMake the discriminant property required and typed as a string literal union (e.g., type: 'circle' | 'square').
- buildRemove excess properties from object literals or use variable assignment with explicit type annotation.
- buildReplace type assertions with 'satisfies' or 'as const' to preserve literal types.
- buildUse a discriminated union with a 'never' property for exhaustive checks.
- buildAdd a default case in switch statements and use 'assertNever' to catch missing cases.
- buildUse 'as const' on the discriminant value when defining the object (e.g., { kind: 'a' as const }).
A fix you cannot prove is a guess. Close the loop.
- verifiedAfter the fix, run tsc --noEmit and confirm no type errors on the narrowed branches.
- verifiedHover over the variable inside the if block to see that TypeScript infers the specific union member type.
- verifiedTest with runtime values to ensure the narrowing logic matches actual behavior.
- verifiedAdd a test case that triggers each branch and verify the code compiles and runs correctly.
- verifiedUse TypeScript's '// @ts-expect-error' to assert that certain lines should error before the fix and not after.
- verifiedCheck that the 'strict' mode in tsconfig is enabled (strictNullChecks, noImplicitAny).
Things that make this bug worse or harder to find.
- warningDon't use 'any' to bypass the type error; it hides the issue and breaks type safety.
- warningAvoid making discriminant properties optional; it defeats the purpose of discriminated unions.
- warningDon't assume TypeScript will infer literal types without 'as const' or explicit annotation.
- warningDon't use type assertions to force a narrower type; fix the root cause instead.
- warningDon't ignore excess property checks: they often indicate a design flaw in your types.
- warningDon't rely solely on runtime checks; ensure the types are correct at compile time.
The Misconfigured Shape Discriminator
Timeline
- 09:15Received bug report: 'Canvas crashes when trying to render circle with radius undefined'.
- 09:20Reviewed code: Shape union type defined as { kind: string; radius?: number; side?: number }.
- 09:25Noticed discriminant 'kind' is typed as string, not literal union.
- 09:30Changed kind to literal union: 'circle' | 'square'. But radius still optional.
- 09:35Ran tsc --noEmit: error on accessing radius inside if (shape.kind === 'circle') block.
- 09:40Realized radius is optional in both members, so narrowing doesn't exclude undefined.
- 09:45Made radius required for circle, side required for square, kind literal.
- 09:50All type errors resolved. Verified with runtime tests.
- 09:55Deployed fix. No further crashes.
I was called in because our canvas component was crashing when trying to render a circle. The error said 'radius is undefined', but I had a type guard checking for 'circle'. I assumed TypeScript would catch that. It didn't.
Looking at the Shape type, it was defined as { kind: string; radius?: number; side?: number }. The discriminant 'kind' was just a string, not a literal union. So TypeScript never narrowed the type—it just knew it was a Shape. Even the if statement didn't help because the type didn't specify which members exist.
After changing 'kind' to 'circle' | 'square' and making radius required for circle and side required for square, TypeScript immediately found the issue: inside the if block, radius was still optional because Shape had it optional. Once I restructured the union properly, the narrowing worked perfectly. The lesson: be explicit with literal types and required properties.
Root cause
Discriminant property typed as 'string' instead of a string literal union, and optional properties preventing proper narrowing.
The fix
Changed the Shape union to use literal discriminant 'kind': 'circle' | 'square' and made radius required for circle, side required for square.
The lesson
TypeScript's discriminated union narrowing only works if the discriminant is a literal type and each member's properties are required and distinct.
Excess property checks occur when you assign an object literal to a variable of a union type. If the object literal has properties not present in any union member, TypeScript will widen the type to an intersection or complain. For example, if you have type Shape = { kind: 'circle'; radius: number } | { kind: 'square'; side: number } and you write const s: Shape = { kind: 'circle', radius: 5, extra: true }, TypeScript will not allow 'extra'. But if you first assign to a variable without type annotation, the type might be inferred as { kind: string; radius: number; extra: boolean } which is not a Shape.
To avoid this, always annotate the type when creating objects of a discriminated union, or use 'as const' to preserve literal types. Another approach is to use builder functions that return the correct union member type.
Type assertions (as) can erase the literal type of the discriminant. For instance, const s = { kind: 'circle', radius: 5 } as Shape; will work but TypeScript won't narrow s.kind to 'circle' — it remains 'circle' only if Shape is a union with literal discriminants. However, if you use as Shape and Shape's discriminant is a general string, you lose the literal.
The 'satisfies' operator (TypeScript 4.9+) allows you to check that a type satisfies a condition without widening. For example, const s = { kind: 'circle', radius: 5 } satisfies Shape; will infer the literal type for kind while ensuring the object matches Shape. This is especially useful when you want to preserve the exact type for narrowing.
When using a generic function that accepts a discriminated union, TypeScript may not narrow the type inside the function body because the type parameter is not constrained to a literal. For example, function handle<T extends Shape>(shape: T) { if (shape.kind === 'circle') { shape.radius; // error } } because T could be a subtype with a different discriminant. To fix this, use a type guard that narrows based on the discriminant value, or use a discriminated union without generics.
A common workaround is to use a union type parameter: function handle(shape: Shape) { if (shape.kind === 'circle') { shape.radius; // works } }. If you need generics, consider using 'in' operator narrowing or custom type guards with type predicate returns.
Frequently asked questions
Why does TypeScript not narrow the type even though my if condition looks correct?
The most common reasons are: (1) the discriminant property is optional or missing in some union members, (2) the discriminant is typed as a general string instead of a literal union, (3) the object was created with excess properties that widened the type, or (4) a type assertion erased the literal type. Check each of these in order.
How do I debug the inferred type of a variable in TypeScript?
Hover over the variable in VS Code to see the inferred type. You can also use the 'Expand' utility type: type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never; and then type Test = Expand<typeof myVariable>. This shows the full expanded type without aliases.
What is the difference between 'as const' and 'satisfies' for preserving literal types?
'as const' makes the entire object deeply read-only and infers all properties as literal types. 'satisfies' checks that the type matches without widening, but does not make the object readonly. Use 'as const' when you need the most specific literal types and don't mind readonly. Use 'satisfies' when you want to keep mutable properties but still want literal inference.
Can I use discriminated unions with optional discriminants?
Yes, but then you lose the ability to narrow because TypeScript cannot determine which branch to take if the discriminant is undefined. If you must have an optional discriminant, you'll need to check for its existence before narrowing, or use a different pattern like a tagged union with a separate 'undefined' branch.
How do I ensure exhaustive checks in a switch statement with discriminated unions?
Use the 'assertNever' pattern: function assertNever(x: never): never { throw new Error('Unexpected value: ' + x); } then in the default case of your switch, call assertNever(shape). This ensures that if you add a new member to the union, the compiler will error if you haven't handled it.