Common Union Type Mistakes in TypeScript
Avoid the most frequent union type mistakes in TypeScript: forgetting to narrow, misusing intersections for unions, ignoring the typeof null trap, and skipping exhaustiveness checks.
Union types are powerful, but they come with predictable pitfalls. Most mistakes fall into four categories: accessing without narrowing, using the wrong type structure, forgetting JavaScript quirks, and skipping exhaustiveness checks. Here is how to spot and fix each one.
1. Accessing Without Narrowing
The single most common mistake: trying to use a method that only exists on one member of the union without checking the type first.
function printLength(value: string | number) {
console.log(value.length); // Error
}The length property exists on strings but not on numbers, so the compiler rejects the call before the code ever runs, pointing out exactly which member of the union is missing the property:
Property 'length' does not exist on type 'string | number'.
Property 'length' does not exist on type 'number'.Fix: Narrow before accessing, so each branch only calls methods that are actually safe for that specific member of the union.
function printLength(value: string | number) {
if (typeof value === "string") {
console.log(value.length);
} else {
console.log(value.toString().length);
}
}TypeScript blocks unsafe access at compile time. The fix is always a narrowing check before the method call.
2. Optional Properties Instead of Discriminated Unions
Putting every possible property on one type with optional everything, instead of splitting the variants into their own types:
interface Shape {
kind: "circle" | "square";
radius?: number;
sideLength?: number;
}
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2; // radius is possibly undefined
}
}TypeScript cannot connect the "circle" kind with radius being present, because both properties live on the same type and neither one is required.
Fix: Split into a discriminated union with required fields per variant.
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
sideLength: number;
}
type Shape = Circle | Square;Once each variant has its own required fields, checking kind narrows to the exact type, and the previously unsafe access compiles cleanly:
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2; // Safe: radius is required on Circle
}
return shape.sideLength ** 2;
}3. The typeof null Trap
typeof null returns "object" in JavaScript, a historical quirk from the language's earliest versions. TypeScript accounts for this, but beginners often miss it when checking a union that includes null:
function process(data: string[] | null) {
if (typeof data === "object") {
data.forEach(item => console.log(item)); // data is possibly null
}
}TypeScript narrows the "object" check to a string array or null, not just a string array, because typeof null also returns "object". The null case survives the check.
Fix: Check for null explicitly first, so it is removed before the array method is called.
function process(data: string[] | null) {
if (data !== null) {
data.forEach(item => console.log(item));
}
}4. Confusing Union with Intersection
Writing an ampersand when you mean a pipe, or the other way around. Both look similar but produce very different types:
type Wrong = string & number;
type Correct = string | number;The first type was meant to describe "a string or a number," but the ampersand means the value must satisfy both types at once, so it resolves to never since no value can be both a string and a number. The second type uses the pipe, meaning the value must satisfy at least one type. The mnemonic: the pipe is for "or", the ampersand is for "and".
5. Union of Functions Without Handling Both Signatures
A union of function types is easy to reach for when a callback might handle either a string or a number, but calling it turns out to be harder than it looks:
type Handler = ((x: string) => void) | ((x: number) => void);
function call(handler: Handler) {
handler("hello"); // Error: handler might expect number
}When you have a union of function types, TypeScript requires an argument that works for every signature in the union, not just one of them. The argument type would need to satisfy both string and number at once, which is never, so no real value ever compiles here.
Fix: Use an intersection of function types instead, so the resulting type has both call signatures available on a single function.
type Logger = ((x: string) => void) & ((x: number) => void);
function log(handler: Logger) {
handler("hello"); // OK
handler(42); // OK
}Both signatures belong to the same function now, so calling it with either a string or a number compiles.
6. Missing Exhaustiveness Checking
Adding a variant to a discriminated union and forgetting to update every function that switches on it:
type Status =
| { state: "idle" }
| { state: "loading" }
| { state: "done"; data: string };The render function below only handles the three variants that existed when it was written, with no fallback for anything else:
function render(status: Status): string {
switch (status.state) {
case "idle": return "Idle";
case "loading": return "Loading";
case "done": return status.data;
}
}There is no default branch here, so TypeScript does not complain. Later, someone adds a cached variant to the status union. Nothing breaks at compile time, but that variant silently returns undefined at runtime.
Fix: Add exhaustiveness checking with an assertNever helper in the default branch.
function assertNever(value: never): never {
throw new Error(`Unhandled: ${JSON.stringify(value)}`);
}Calling the helper from the default branch gives every unhandled variant a compile-time error instead of a silent undefined at runtime:
function render(status: Status): string {
switch (status.state) {
case "idle": return "Idle";
case "loading": return "Loading";
case "done": return status.data;
default: return assertNever(status);
}
}Now adding a cached variant produces a compile error in render. This is the single most important habit for working with discriminated unions.
7. Using in on Properties That Exist Everywhere
The in operator checks whether a property exists on a value. If every union member already has that property, checking it does not narrow anything:
type A = { id: string; value: number };
type B = { id: string; label: string };
function process(x: A | B) {
if ("id" in x) {
console.log(x.id); // x is still A | B, both have id
}
}Fix: Check a property that exists on only some members, so the presence check actually splits the union into its individual variants.
function process2(x: A | B) {
if ("value" in x) {
console.log(x.value); // x is A
} else {
console.log(x.label); // x is B
}
}8. Forgetting That Unions Expand
A union of two types accepts values that match either one. This means adding a type to a union loosens the constraint, even if that was not the intent.
type Strict = { name: string; age: number };
type Loose = Strict | { name: string };
const valid: Loose = { name: "Alice" };The value compiles even though it has no age, because it satisfies the second member of the union. Be intentional about when you union types. If you want all fields required, consider an intersection or separate the variants properly instead.
9. Narrowing Too Early (Before Other Checks)
Narrowing removes type information that later checks might still need, especially when typeof null returns the same string as a real object:
function handle(value: string | number | null) {
if (typeof value === "object") {
console.log(value.toString()); // Error: value is possibly null
}
}The "object" branch looks like it should only match null here, since number and string never return "object" from typeof. But null was never eliminated, so the compiler still rejects the call.
Fix: Handle null first, then narrow the remaining primitives with typeof.
function handle(value: string | number | null) {
if (value === null) return;
// value is string | number
if (typeof value === "string") {
console.log(value.toUpperCase());
} else {
console.log(value.toFixed(2));
}
}Most union mistakes follow a pattern: the compiler error points to the problem, and the fix is a narrowing check, a type restructure, or an exhaustiveness guard. Learn to read the error message closely, since it usually tells you exactly which type is causing the issue and why.
For more on the right patterns, see union types and exhaustive checks.
Rune AI
Key Insights
- Always narrow a union before accessing member-specific properties or methods.
- Use discriminated unions with required fields, not a single type with optional everything.
- Remember typeof null === 'object' and add an explicit null check when needed.
- Do not confuse union (|, alternatives) with intersection (&, combinations).
- Add assertNever to every switch on discriminated unions.
Frequently Asked Questions
What is the most common union type mistake?
Why does TypeScript reject my union with both optional properties?
How do I avoid forgetting to handle new union variants?
Conclusion
Union type mistakes follow predictable patterns: accessing without narrowing, wrong type structure, typeof null confusion, and missing exhaustiveness. Each has a clear fix. Write narrow-first code, use discriminated unions with required fields, account for typeof null, and add assertNever to every switch.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.