TypeScript's control flow analysis is doing more work than most people give it credit for. It's not just matching typeof x === "string" against a type — it's tracking assignments, early returns, and branches through your actual control flow to figure out, at every point in a function, exactly what a variable could be. Understanding how far that tracking goes, and where it stops, saves you from both unnecessary type assertions and narrowing bugs that only show up at runtime.

The guards you already use

typeof and instanceof are the two most common narrowing guards, and they work because TypeScript special-cases them in its analysis:

typescript
function formatId(id: string | number) {
  if (typeof id === "string") {
    return id.padStart(8, "0"); // id is string here
  }
  return id.toFixed(0); // id is number here
}

instanceof does the same for classes:

typescript
function handle(error: Error | HttpError) {
  if (error instanceof HttpError) {
    console.log(error.statusCode); // narrowed to HttpError
  }
}

Discriminated unions narrow the whole object

Once a type is a union of object shapes with a shared literal tag, checking that one field narrows every field on the object:

typescript
type Result =
  | { status: "success"; data: string[] }
  | { status: "error"; message: string };

function render(result: Result) {
  if (result.status === "error") {
    return result.message; // data isn't even a valid property here
  }
  return result.data.join(", ");
}

This is worth designing types around deliberately — it's the reason discriminated unions beat a flat object with optional fields for anything state-shaped.

Writing your own guards

When the built-in guards don't cover a check, a type predicate function lets you teach the compiler your own narrowing logic:

typescript
interface Admin { role: "admin"; permissions: string[] }
interface Member { role: "member" }

function isAdmin(user: Admin | Member): user is Admin {
  return user.role === "admin";
}

function grant(user: Admin | Member) {
  if (isAdmin(user)) {
    user.permissions.push("read"); // narrowed via the predicate
  }
}

Assertion functions do the same thing but for the "throw if not this type" pattern, which is common in validation code:

typescript
function assertIsAdmin(user: Admin | Member): asserts user is Admin {
  if (user.role !== "admin") throw new Error("not an admin");
}

function grantAdmin(user: Admin | Member) {
  assertIsAdmin(user);
  user.permissions.push("read"); // narrowed after the assertion, no if needed
}

The in operator works as a lighter-weight guard when you just need to check a property exists, without a full type predicate function:

typescript
function speak(pet: { bark(): void } | { meow(): void }) {
  if ("bark" in pet) {
    pet.bark();
  } else {
    pet.meow();
  }
}

Where narrowing quietly disappears

Narrowing is tied to control flow, which means it doesn't survive a closure boundary. TypeScript can't prove a captured variable wasn't reassigned by the time an async callback runs:

typescript
function process(value: string | null) {
  if (value === null) return;
  setTimeout(() => {
    value.trim(); // error — narrowing didn't carry into the callback
  }, 0);
}

The fix is usually to copy the narrowed value into a new const before the closure captures it, since a const can't be reassigned and TypeScript trusts that.

The other common surprise is .filter(Boolean), which looks like it should narrow (T | null | undefined)[] down to T[] but doesn't, because Boolean isn't typed as a predicate:

typescript
const values: (string | null)[] = ["a", null, "b"];
const filtered = values.filter(Boolean); // still (string | null)[]

const narrowed = values.filter((v): v is string => v !== null); // string[]

Small difference, but it's the kind of thing that silently reintroduces a null check three call sites downstream.