Skip to main content

TS Ep 9: Type Narrowing & Control Flow Analysis

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
typescript - This article is part of a series.
Part 9: This Article
Type Narrowing is the process of transforming a broad type (like string | number | Date) into a narrower, more specific type as your code executes. TypeScript’s Control Flow Analyzer automatically tracks runtime checks across if, else, switch, return, and throw statements.

1. What is Control Flow Analysis (CFA)?
#

In TypeScript, type checking is not static line-by-line validation. The compiler employs Control Flow Analysis (CFA)—it constructs a flow graph of your code, tracking variable assignments, branch conditions, and early returns.

As execution moves down a function branch, TypeScript continuously refines the variable’s type based on logic guards:


flowchart TD
    Start["Variable x: string | number"] --> Guard{"typeof x === 'string'"}
    Guard -->|True| BranchString["Inferred Type: string\nCall .toUpperCase()"]
    Guard -->|False| BranchNumber["Inferred Type: number\nCall .toFixed()"]

2. Guard 1: typeof Guards
#

The typeof operator checks JavaScript primitive types at runtime.

Valid typeof return strings recognized by TypeScript’s type checker:

  • "string"
  • "number"
  • "bigint"
  • "boolean"
  • "symbol"
  • "undefined"
  • "object"
  • "function"
function formatInput(input: string | number | boolean): string {
  if (typeof input === "string") {
    // Inferred: string
    return input.trim().toUpperCase();
  }
  
  if (typeof input === "number") {
    // Inferred: number
    return `$${input.toFixed(2)}`;
  }

  // Inferred: boolean
  return input ? "ENABLED" : "DISABLED";
}

The typeof null === "object" Trap
#

In JavaScript, typeof null evaluates to "object" due to a historical bug in the original 1995 JS engine implementation.

Because of this, using typeof x === "object" to check for objects will accidentally allow null through:

function processObject(data: { name: string } | null) {
  if (typeof data === "object") {
    // ❌ DANGER: 'data' is inferred as '{ name: string } | null'!
    // console.log(data.name); // Error: 'data' is possibly 'null'.
  }

  // 🟢 CORRECT: Always check for non-null explicitly!
  if (typeof data === "object" && data !== null) {
    // Inferred: { name: string }
    console.log(data.name); // Safe!
  }
}

3. Guard 2: instanceof Guards
#

The instanceof operator tests whether an object’s prototype chain contains the .prototype property of a constructor function or Class.

It is used to narrow instances of classes (e.g., Date, Error, RegExp, Array, or custom classes):

function parseDateInput(input: string | Date | Error): string {
  if (input instanceof Date) {
    // Inferred: Date
    return input.toISOString();
  }

  if (input instanceof Error) {
    // Inferred: Error
    return `[ERROR]: ${input.message}`;
  }

  // Inferred: string
  return input.toLowerCase();
}
Warning

instanceof can fail across cross-realm boundaries (such as window.frames or iframe contexts), because Date in frame A has a different prototype reference than Date in frame B.


4. Guard 3: The in Operator Guard
#

The in operator checks if a specific property key exists in an object or its prototype chain. It is ideal for distinguishing between different object interfaces:

interface AdminAccount {
  id: string;
  adminPermissions: string[];
}

interface UserAccount {
  id: string;
  email: string;
}

function processAccount(account: AdminAccount | UserAccount) {
  if ("adminPermissions" in account) {
    // Inferred: AdminAccount
    console.log(`Admin with ${account.adminPermissions.length} permissions`);
  } else {
    // Inferred: UserAccount
    console.log(`User email: ${account.email}`);
  }
}

5. Guard 4: Equality Narrowing (===, !==)
#

TypeScript uses literal equality checks (===, !==, ==, !=) to narrow types, including primitive literal values and discriminant keys:

function compareValues(a: string | number, b: string | boolean) {
  if (a === b) {
    // Because 'a' equals 'b', 'a' and 'b' MUST both be 'string'!
    console.log(a.toUpperCase()); 
    console.log(b.toUpperCase()); 
  } else {
    console.log(a); // Inferred: string | number
    console.log(b); // Inferred: string | boolean
  }
}

Checking for null or undefined (== null)
#

Using loose equality val == null checks for both null and undefined simultaneously:

function handleValue(val: string | null | undefined) {
  if (val == null) {
    // Inferred: null | undefined
    return "Default Value";
  }

  // Inferred: string
  return val.toUpperCase();
}

6. Truthiness Narrowing & Falsy Pitfalls
#

TypeScript narrows types inside if (val) condition blocks by stripping null, undefined, 0, "", NaN, and false from the type union:

function printString(str: string | null) {
  if (str) {
    // Inferred: string
    console.log(str.toUpperCase());
  }
}

The Falsy Value Bug
#

Be extremely cautious when performing truthiness narrowing on string or number types, as empty strings "" and the number 0 evaluate to false at runtime:

function printCount(count: number | null | undefined) {
  // ❌ BUG: If count is 0, (0) evaluates to false, skipping this block!
  if (count) {
    console.log(`Count is: ${count}`);
  }
}

// 🟢 CORRECT: Explicitly check for null and undefined!
function printCountCorrect(count: number | null | undefined) {
  if (count !== null && count !== undefined) {
    console.log(`Count is: ${count}`);
  }
}

printCountCorrect(0); // Outputs: "Count is: 0"

Summary & Next Steps
#

In this episode:

  • We unpacked Control Flow Analysis (CFA) and how TS tracks code execution branches.
  • We analyzed typeof guards and avoided the typeof null === "object" trap.
  • We used instanceof for class instances and in for object property existence checks.
  • We demonstrated equality narrowing (===) and truthiness guard pitfalls (0 and "").

In Episode 10: Custom Type Guards (is predicate), we will learn how to write reusable boolean functions that teach TypeScript how to narrow complex types!

typescript - This article is part of a series.
Part 9: This Article