Alright, let’s get our hands dirty with narrowing union types. This isn’t just some academic exercise; it’s the fundamental way you, the developer, prove to the TypeScript compiler that a value of a fuzzy type like string | number is, at this specific moment, definitely a string. You’re giving the compiler a logical proof, and it will reward you with access to the full API of that specific type. If you don’t do this, TypeScript will only let you use methods common to all members of the union, which is often… nothing useful.

Think of it like a bouncer at an exclusive club. The union type string | number is the list. The bouncer (TypeScript) won’t let you use toUpperCase() because not everyone on the list (number) knows how to do that. You have to show your ID—you have to narrow the type—to get in.

The Workhorse: typeof Type Guards

The most straightforward tool in your toolbox is typeof. It’s a JavaScript operator that returns a string indicating the type of the unevaluated operand, and TypeScript understands it perfectly in conditional blocks.

function printId(id: string | number) {
  if (typeof id === "string") {
    // In this branch, id is narrowed to string
    console.log(id.toUpperCase()); // All good!
  } else {
    // In this branch, id is narrowed to number
    console.log(id.toFixed(2)); // Also good!
  }
}

This seems obvious, but the key insight is that this narrowing is flow-aware. The type is narrowed from the point of the conditional check all the way down through the subsequent block. And it works for the else block too, because if it’s not a string, and the union is only string | number, it must be a number. TypeScript is brilliantly logical that way.

Crucial Pitfall: typeof null is 'object'. This is a legendary JavaScript blunder that TypeScript is forced to inherit. So if null is a member of your union, you must handle it separately. Always.

function maybeGetString(): string | null { ... }

const result = maybeGetString();
if (typeof result === 'object') {
  // result is narrowed to... string | null ?!
  // Wait, what? This is why `typeof` is useless for null.
  console.log(result.toLowerCase()); // Error: Object is possibly 'null'.
}

// The correct way:
if (result !== null) {
  // Now result is narrowed to string. We've excluded null.
  console.log(result.toLowerCase()); // Works.
}

Taming the Object Shape: in Operator Guards

When your union consists of object types, typeof is useless—it just returns 'object' for all of them. This is where the in operator shines. It checks for the existence of a property, and TypeScript uses this to narrow the type.

type Cat = { meow: () => void; lives: number };
type Dog = { bark: () => void; breed: string };

function makeNoise(animal: Cat | Dog) {
  if ('meow' in animal) {
    // TypeScript knows: if it has a 'meow' property, it must be a Cat.
    animal.meow(); // Safe.
    // animal.bark(); // Error: Property 'bark' does not exist on type 'Cat'.
  } else {
    // Therefore, it must be a Dog.
    animal.bark(); // Safe.
  }
}

It’s elegantly simple. The in operator is your way of asking an object “What team are you on?” based on its unique properties.

The Power User’s Choice: User-Defined Type Guards

Sometimes, a simple typeof or in check isn’t enough. What if your check is more complex? What if you need to check the structure of nested data? You write a function that returns a type predicate.

A type predicate is a return type annotation of the form argumentName is Type. It’s a boolean-returning function where returning true means “I promise the argument is of this specific type.”

interface User {
  name: string;
  email: string;
}

interface Admin {
  name: string;
  adminLevel: number;
}

function isAdmin(account: User | Admin): account is Admin {
  // We check for a unique property of Admin
  return (account as Admin).adminLevel !== undefined;
  // Even better, use 'in'
  // return 'adminLevel' in account;
}

function getAdminDetail(account: User | Admin) {
  if (isAdmin(account)) {
    // account is narrowed to Admin because isAdmin returned true
    return account.adminLevel; // Safe access.
  }
  // account is narrowed to User
  return account.email;
}

This is incredibly powerful. It allows you to encapsulate complex validation logic (e.g., checking the schema of a JSON object from an API) into a reusable function that TypeScript fully understands for narrowing.

The Sledgehammer: Discriminated Unions

This is the designer’s masterpiece. When you control the object types in your union, you can create a “discriminated union” (or “tagged union”). This is a pattern where each member of the union has a common, literal-type property (like type: 'admin') that allows TypeScript to narrow the type with absolute certainty in a switch or if statement. It’s the most robust and foolproof way to narrow.

// Each type has a 'kind' property with a literal string value.
type Success = { kind: 'success'; data: string };
type Error = { kind: 'error'; message: string };
type Loading = { kind: 'loading'; progress: number };

type ApiState = Success | Error | Loading;

function handleState(state: ApiState) {
  // Check the discriminant property ('kind')
  switch (state.kind) {
    case 'success':
      // state is narrowed to Success
      console.log(state.data);
      break;
    case 'error':
      // state is narrowed to Error
      console.error(state.message);
      break;
    case 'loading':
      // state is narrowed to Loading
      console.log(`Loading... ${state.progress}%`);
      break;
  }
}

This is bulletproof. There are no edge cases. The discriminant property acts as a unique tag, making the narrowing exhaustive and completely type-safe. If you add a new member to the ApiState union, the TypeScript compiler will actually error in the handleState function until you handle the new case in the switch statement. It’s like getting a free unit test from your type system. It’s genuinely fantastic. Use this pattern whenever you can.