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.

7 min read

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.

typescripttypescript
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:

texttext
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.

typescripttypescript
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:

typescripttypescript
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.

typescripttypescript
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:

typescripttypescript
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:

typescripttypescript
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.

typescripttypescript
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:

typescripttypescript
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:

typescripttypescript
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.

typescripttypescript
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:

typescripttypescript
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:

typescripttypescript
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.

typescripttypescript
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:

typescripttypescript
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:

typescripttypescript
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.

typescripttypescript
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.

typescripttypescript
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:

typescripttypescript
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.

typescripttypescript
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

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.
RunePowered by Rune AI

Frequently Asked Questions

What is the most common union type mistake?

Accessing a member-specific property or method without narrowing first. If a union is string | number, you cannot call .toUpperCase() without first checking typeof === 'string'. TypeScript blocks this at compile time.

Why does TypeScript reject my union with both optional properties?

A single type with optional properties does not narrow when you check a discriminant. You need a discriminated union: separate types with required properties and a literal discriminant field like kind: 'circle'.

How do I avoid forgetting to handle new union variants?

Use exhaustiveness checking. Add a default case with assertNever to every switch on a discriminated union. When you add a variant, TypeScript produces a compile error until you add the new case.

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.