Alright, let’s get into the real magic. You’ve probably written code where a variable could be one type or another, and you needed to figure out which one it was to use it safely. This is type narrowing, and TypeScript’s control flow analysis is the brilliant, silent partner that makes it all work. It’s the part of the compiler that tracks the type of a variable as it moves through your if statements, ternaries, loops, and other logic. It’s not psychic; it follows the breadcrumbs you leave in your code.

Think of it like this: I start with a variable x that’s string | number. It’s a Schrödinger’s cat of types. The moment I put it in a box—like an if (typeof x === 'string') statement—I’ve performed an observation. Inside that if block, TypeScript knows the cat is alive (or that x is a string, in this slightly morbid metaphor). It has narrowed the type from the wider union to a specific, safe member.

How typeof and instanceof Guard the Gates

The most straightforward way to narrow types is with good ol’ typeof and instanceof checks. TypeScript understands these JavaScript operators and uses them to adjust the type within a block.

function printId(id: string | number) {
  if (typeof id === 'string') {
    // In here, TypeScript knows `id` is a string.
    console.log(id.toUpperCase()); // Safe to call string methods.
  } else {
    // And in here, it *must* be a number.
    console.log(id.toFixed(2)); // Safe to call number methods.
  }
}

The beauty here is in the else. TypeScript’s control flow analysis understands that if id isn’t a string, and the only other option in the union is a number, then in the else block it must be a number. It’s deductive reasoning.

instanceof works the same way for classes:

class ApiResponse { /* ... */ }
class ApiError { /* ... */ }

async function handleResponse(response: ApiResponse | ApiError) {
  if (response instanceof ApiResponse) {
    // Now we can safely access ApiResponse properties.
    return response.data;
  } else {
    // TypeScript has narrowed this down to ApiError.
    throw new Error(response.message);
  }
}

The Power of Truthiness and Equality Narrowing

Sometimes you don’t need to know the exact type, just that a value exists. This is where truthiness narrowing shines. It’s incredibly useful for dealing with values that might be null or undefined.

function getLength(s: string | null | undefined) {
  // If `s` is null or undefined, this condition is false.
  if (s) {
    // In here, s cannot be null or undefined. It's been narrowed to 'string'.
    return s.length;
  }
  return 0;
}

But be careful! This is a classic pitfall. An empty string '' is also falsy. So in the example above, if s is an empty string, the if block won’t run, and the function will return 0. This might be what you want, but it might not. Always be precise.

Equality narrowing is just as clever. TypeScript can use ===, !==, ==, and != checks to narrow types.

function doSomething(x: string | number, y: string | boolean) {
  if (x === y) {
    // If x and y are equal, their types must be compatible.
    // The only compatible type in both unions is 'string'.
    // So both x and y are narrowed to string here.
    console.log(x.toUpperCase(), y.toUpperCase());
  }
}

The in Operator: Checking for a Property’s Existence

When dealing with object types, typeof and instanceof might not cut it. Enter the in operator. It checks for the existence of a property, and TypeScript uses this to narrow between types that have different sets of properties.

interface Cat {
  meow(): void;
}
interface Dog {
  bark(): void;
}

function makeNoise(pet: Cat | Dog) {
  if ('meow' in pet) {
    // TypeScript now knows we're dealing with a Cat.
    pet.meow();
  } else {
    // Therefore, this must be a Dog.
    pet.bark();
  }
}

A word of caution: the in operator checks for the existence of a property, not its type. If your Dog interface also had a optional meow?: () => void property, this narrowing would be unsafe and TypeScript would (rightfully) yell at you. Your types need to be designed for this kind of discrimination.

User-Defined Type Guards: When You Need to Get Fancy

Sometimes, the built-in checks aren’t enough. What if you need to check the structure of a complex object? You write a function that returns a type predicate. This is a fancy term for a function that returns a boolean but also tells TypeScript what that boolean means for the type of its argument.

interface User {
  username: string;
  id: number;
}
interface Admin {
  username: string;
  id: number;
  permissions: string[];
}

// This is a user-defined type guard.
// The `pet is Admin` is the type predicate.
function isAdmin(user: User | Admin): user is Admin {
  return (user as Admin).permissions !== undefined;
}

function greetUser(user: User | Admin) {
  if (isAdmin(user)) {
    // The type guard tells TypeScript that if this returns true,
    // `user` is an Admin.
    console.log(`Admin ${user.username} with powers: ${user.permissions.join(', ')}`);
  } else {
    // So here, it must be a regular User.
    console.log(`Hello, ${user.username}`);
  }
}

This is your most powerful tool. Use it to encapsulate complex validation logic. The key is that you, the developer, are taking responsibility for the type safety within that guard function. Don’t lie to the compiler; it trusts you. If your isAdmin function just returns Math.random() > 0.5, you’ve broken the contract and your code will explode at runtime. The compiler did its job; you didn’t do yours.

The One Big Gotcha: Aliasing and Mutations

Here’s the one that trips up everyone eventually. Control flow analysis is brilliant, but it’s not omniscient. It tracks the type of a variable, not the value that variable holds. If you create an alias for a variable and then narrow the original, the alias doesn’t get the memo.

function example(str: string | null) {
  if (str !== null) {
    // str is narrowed to 'string' here.
    let alias = str; // alias is also a string... for now.

    str = null; // 😈 I just mutated the original variable!

    // TypeScript still thinks `alias` is a string.
    console.log(alias.toUpperCase()); // 💥 Runtime error! alias is null.
  }
}

You mutated str after the narrowing occurred. TypeScript’s analysis is a snapshot based on the checks you performed; it can’t predict the future or track every possible mutation. The lesson? Avoid mutating narrowed variables. Prefer const declarations and treat your narrowed variables as immutable within their scope. It’s safer and makes the compiler’s job easier.