Alright, let’s talk about union types. You know that feeling when you’re writing a function and you think, “This could take a string… or maybe a number. Ugh, I’ll just use any and hope for the best.” Stop that. You’re better than any. Union types are your first, best line of defense against that particular brand of laziness. They’re the type system’s way of saying, “It can be this, or that. I’m flexible, but I’m not a pushover.”

The syntax is dead simple: a vertical bar (|) between types. string | number means exactly what it sounds like: “This value can be a string OR a number.” It’s not a new type that’s both; it’s a description of the set of allowed types. Think of it as a logical OR operation for your type annotations.

The Basics: More Than Just a Fancy ‘OR’

Let’s start with the simplest possible example. You’re building a system that accepts an ID, and because of some legacy decisions (we’ll get to those), the ID could be a number or a string.

function printId(id: number | string) {
  console.log(`Your ID is: ${id}`);
}

printId(101); // Works fine.
printId("202ABC"); // Also works.
printId({ id: 101 }); // Nope! Error: Argument of type '{ id: number; }' is not assignable to parameter of type 'string | number'.

Straightforward, right? The power here is that we’ve explicitly widened the input type without resorting to the nuclear option of any. We’ve told the compiler and anyone reading our code exactly what we expect.

The Immediate Consequence: Narrowing

Here’s the first “gotcha.” You can’t just go using methods from string on this id parameter. Why? Because if id is actually a number at runtime, id.toUpperCase() is going to blow up spectacularly.

function printIdInUpperCase(id: string | number) {
  console.log(id.toUpperCase()); // Error! Property 'toUpperCase' does not exist on type 'string | number'.
}

The compiler isn’t being difficult; it’s protecting you from a runtime error. This leads us to the most important concept when working with unions: type narrowing. You have to help the compiler figure out what specific type you’re dealing with at a given point in your code. You do this with JavaScript’s built-in guards.

function printIdSafely(id: string | number) {
  if (typeof id === "string") {
    // In this branch, TypeScript *knows* `id` is a string.
    console.log(id.toUpperCase()); // All good!
  } else {
    // Therefore, in this branch, it must be a number.
    console.log(id.toFixed(2)); // Also good!
  }
}

typeof checks are the most common way to narrow primitives. For more complex objects, you’ll use other techniques like checking for a property’s existence (“discriminated unions,” but that’s a story for another section).

Handling Those ‘Clearly Questionable Choices’

Sometimes, you’ll be forced to work with a union type where the members aren’t so easy to tell apart. Imagine a function that can return a number or a string in a way that isn’t immediately obvious. This is where the designers of your system might have made… let’s call it a “questionable choice.”

What if you need to handle both cases but a simple typeof guard feels clunky? You can use a type predicate, which is a fancy way of writing a helper function that does the narrowing for you.

function isString(value: any): value is string {
  return typeof value === 'string';
}

function handleQuestionableInput(input: string | number) {
  if (isString(input)) {
    return input.length;
  }
  return input.toFixed();
}

It’s a bit more boilerplate, but it makes your intent crystal clear and is reusable. It’s how you maintain sanity when the real world’s chaos infringes on your beautiful type system.

Beyond Primitives: Object Unions

Unions truly shine when you use them with object types. This is where you model the real-world scenarios that are messy and unpredictable.

Let’s say you’re handling a response from an API. On success, you get a user object. On failure, you get an error object. A union type models this perfectly.

type ApiResponse =
  | { status: "success"; data: { user: { name: string } } }
  | { status: "error"; code: number; message: string };

function handleResponse(response: ApiResponse) {
  // The magic of narrowing based on a common property!
  if (response.status === "success") {
    console.log(`User name: ${response.data.user.name}`); // TS knows `data` exists here.
  } else {
    console.error(`Error ${response.code}: ${response.message}`); // TS knows `code` and `message` exist here.
  }
}

This pattern is incredibly powerful and robust. It forces you to handle every possible case the type system knows about. If you add a third status, like status: "pending", to the ApiResponse type, TypeScript will immediately flag every handleResponse function in your codebase as not handling that new case. It’s like having a safety net that automatically expands.

Best Practices and Pitfalls

  1. Don’t Overuse Them: A union string | number is meaningful. A union string | number | boolean | null | undefined | { id: number } is usually a sign that your function is trying to do too much and your interface is poorly defined. Refactor.
  2. Discriminate Clearly: When creating unions of objects, use a common property with literal types (like status, type, or kind) to make narrowing trivial and foolproof. This is your best friend.
  3. Order Doesn’t Matter: string | number is identical to number | string. The compiler normalizes them.
  4. Watch for Optional Properties: An optional property prop?: string is actually a union under the hood: prop: string | undefined. Keep this in mind when narrowing.

Union types are the workhorses of TypeScript’s flexibility. They let you describe the JavaScript you actually write, with all its dynamic edges, without sacrificing the safety and documentation that static types provide. They’re the first tool you should reach for when a value has more than one valid form.