Skip to main content
CodeOath
← All posts

TypeScript63 min total · 21 parts

TypeScript Fundamentals: Types, Interfaces, Generics, and Why It Catches Bugs Before Runtime

Part 6 of 21 · ~3 min

Union Types and Narrowing

The queue has to deal with applicant ids from two different sources. One is CodeOath's own auto-incrementing number. The other belongs to the ATS vendor, arrives as a string like "ats-8841", and there's no changing that — it's their id, not ours. A function that has to accept either needs to say so in its type:

function formatApplicantRef(ref: string | number): string {
  if (typeof ref === "string") {
    return ref.toUpperCase(); // inside this branch, ref can only be a string
  }
  return `INT-${ref.toFixed(0)}`; // and down here, only a number
}

Notice what the if actually bought you: inside each branch, TypeScript stops treating ref as "string or number" and commits to whichever one the check just proved. That's narrowing, and it isn't optional or best-effort. Try calling .toFixed() in the string branch, or .toUpperCase() in the number branch, and the compiler refuses outright, because from where it's standing, that branch genuinely cannot hold the other type.

typeof is the most common narrowing check, but it's one of several:

// instanceof — narrows by which class actually constructed the value
class ScoringError extends Error {}

function handleScoringFailure(err: ScoringError | string) {
  if (err instanceof ScoringError) {
    console.log(err.message); // ScoringError here
  } else {
    console.log(err.toUpperCase()); // string here
  }
}

// "in" — narrows by whether a specific key exists on the object
type ContactInfo = { email: string } | { phone: string };

function primaryContact(contact: ContactInfo): string {
  if ("email" in contact) return contact.email; // { email: string } here
  return contact.phone; // { phone: string } here
}

// plain truthiness — filters out null/undefined/empty without a dedicated check
function summarizeNote(note: string | null): string {
  if (note) return note.slice(0, 80); // string here — null can't be truthy
  return "(no note)";
}

There's a sharper version of narrowing worth knowing before it costs you a debugging session, because the first time you hit it, it looks like a compiler bug and isn't one:

function scheduleFollowUp(ref: string | number) {
  if (typeof ref === "string") {
    setTimeout(() => {
      ref.toUpperCase(); // fine
    }, 0);
  }
}

function scheduleFollowUp2(ref: string | number) {
  if (typeof ref === "string") {
    setTimeout(() => {
      ref.toUpperCase(); // Error: Property 'toUpperCase' does not exist on type 'string | number'
    }, 0);
  }
  ref = 42; // this single line, anywhere in the function, is the entire difference
}

Both functions narrow ref the same way, inside an identical if. Only one of the two nested callbacks compiles. The difference is what TypeScript can prove about the rest of the function: in scheduleFollowUp, ref is never reassigned anywhere, so the narrowing is safe to carry into the callback no matter when it eventually runs. In scheduleFollowUp2, there's a ref = 42 sitting later in the same function — and TypeScript has no way to know, from inside the callback, whether that line already ran by the time the timer fires. So it assumes the worst and widens ref back to string | number inside the callback, even on this specific call where the reassignment happens to be unreachable. It's the same fact closures make true at runtime — a nested function reads the live variable, not a frozen copy — except here the compiler is reasoning about it statically, before any of it actually runs, and it only trusts the narrowing where it can prove nothing else in the function could have moved the value out from under it first.