Fixing TypeScript Discriminated Union Narrowing That Silently Breaks After Adding a Shared Base Interface
A switch statement on a discriminated union's tag field used to narrow each case perfectly. After extracting shared fields into a common base interface, the same switch statement stops narrowing — every branch falls back to the full union type, and properties that should be safely accessible now need optional chaining or a type assertion that shouldn't be necessary.
The Problem
A discriminated union — a set of interfaces sharing a common literal-typed field like type: "circle" | "square" — narrows cleanly inside a switch or if check on that field: inside the "circle" branch, TypeScript correctly knows only the circle-specific properties are available. After refactoring to reduce duplication — pulling shared fields like id and createdAt into a common base interface that each variant extends — the exact same switch statement stops narrowing. Every branch now sees the full union type instead of the specific variant, autocomplete offers properties from every variant simultaneously, and code that used to compile safely suddenly needs a type assertion or optional chaining that has no runtime justification.
Why It Happens
Discriminated union narrowing depends on the discriminant being a literal type, not a widened one
TypeScript narrows a union based on a field whose type is a specific literal ("circle") rather than the general string. If a refactor accidentally causes the discriminant field's type to get inferred as string instead of the literal — often because it moved through a generic base interface or a factory function without an explicit literal type — narrowing silently stops working, because TypeScript no longer has a distinct literal per variant to switch on.
Extending a shared base interface can flatten the discriminant into a single shared type
A naive base interface written as interface Shape { type: string; id: string } and extended by each variant redefines the field, but if any variant doesn't re-narrow type to its own literal, or if the base interface's type: string is what TypeScript resolves to during inference in some code path, the discriminant loses its per-variant specificity exactly where it matters.
A generic factory or mapper function can erase literal types even when every individual interface is still correct
A helper function that constructs union members generically — function makeShape<T extends Shape>(data: T): T — can return a type where the literal narrowness of type has been widened to string by the generic's own inference, even though the interfaces themselves, read in isolation, still declare literal types correctly.
The break is invisible until narrowing is actually attempted somewhere downstream
Because the widening usually happens silently — no error at the point where the type gets flattened — the first visible symptom is often far from the actual cause: a switch statement three files away suddenly failing to narrow, with nothing in that file itself having changed.
The Fix
1. Verify the discriminant is still inferred as a literal type, not string, at the point of construction
interface Circle { type: "circle"; id: string; radius: number }
interface Square { type: "square"; id: string; side: number }
type Shape = Circle | Square;
// Hover over "circle" here in the editor — it must show as the literal "circle",
// not string, or narrowing downstream will already be broken
const shape: Shape = { type: "circle", id: "1", radius: 5 };
Checking the inferred type directly at construction (via the editor's hover tooltip, not just assuming it's correct) catches the widening at its actual source, rather than debugging from the symptom several files downstream where narrowing eventually fails.
2. Keep the discriminant field out of the base interface, or use a generic base that preserves the literal per variant
// Instead of a base interface owning "type" as string:
interface BaseShape { id: string; createdAt: string }
interface Circle extends BaseShape { type: "circle"; radius: number }
interface Square extends BaseShape { type: "square"; side: number }
type Shape = Circle | Square;
Extracting only the genuinely shared, non-discriminant fields into the base interface — and letting each variant declare its own literal type independently — removes the ambiguity that let the discriminant get widened during the refactor in the first place.
3. Use "as const" or an explicit literal type when constructing union members through a helper
function makeCircle(radius: number): Circle {
return { type: "circle", id: crypto.randomUUID(), radius }; // return type annotation forces literal narrowing
}
// Or, when a value is built inline without an annotated return type:
const shape = { type: "circle" as const, id: "1", radius: 5 };
Annotating a factory function's return type explicitly, or using as const on an inline literal, forces TypeScript to keep the discriminant as its specific literal rather than inferring the general string from the object literal's shape alone.
4. Add a compile-time exhaustiveness check to catch silently-broken narrowing immediately
function assertNever(x: never): never {
throw new Error("Unexpected variant: " + JSON.stringify(x));
}
function describe(shape: Shape): string {
switch (shape.type) {
case "circle": return `circle r=${shape.radius}`;
case "square": return `square s=${shape.side}`;
default: return assertNever(shape); // fails to compile if narrowing is broken or a variant is unhandled
}
}
An exhaustiveness check via a never-typed default branch turns broken narrowing into an immediate compile error at the exact switch statement affected, rather than a silent fallback to the full union type that only surfaces as a confusing runtime property-access issue later.
Why This Works
Each fix targets a different point where a literal type can get silently widened during a refactor. Verifying the inferred type at construction catches the actual source of the widening instead of its downstream symptom; keeping the discriminant out of the base interface removes the specific refactor pattern that caused the break; explicit literal annotations on factories and inline objects prevent TypeScript's inference from defaulting to the wider type; and an exhaustiveness check converts any future instance of this exact problem into a compile-time error at the point it actually matters, instead of a runtime surprise.
Conclusion
Discriminated union narrowing breaking after a refactor is a type-widening problem, not a logic bug — a literal type that used to make each variant distinguishable quietly became the general string type somewhere in the refactor, usually in a shared base interface or a generic factory function. Confirm the discriminant is still inferred as a literal at construction, keep discriminant fields out of shared base interfaces, use explicit literal annotations or as const when building union members through helpers, and add an exhaustiveness check with never so the next silent break becomes a compile error instead of a runtime mystery.
