C.W.K.
Stream
Lesson 05 of 05 · published

Control Flow Analysis: The Compiler Reads Your Code

~10 min · narrowing, control-flow-analysis, inference

Level 0Curious
0 XP0/93 lessons0/23 achievements
0/100 XP to next level100 XP to go0% complete
"Every narrowing operator we've seen is one ingredient. Control flow analysis is the chef."

What CFA is

Control flow analysis (CFA) is the compiler's process of reading your code line by line and tracking what each variable's type is at every point. It's not a separate feature — it's the engine that makes all the narrowing operators we've covered work together.

The compiler walks every branch of every if, every case in every switch, every early return, every assignment, every throw. After each operation, it updates its model of what each variable's type is. By the time control reaches a line, the compiler knows the narrowest possible type for each variable based on everything that came before.

Early returns narrow the rest of the function

This is the cleanest CFA pattern. Check for the bad case, return early, and the rest of the function sees the narrowed type.

function process(x: User | null) {
  if (x === null) return;
  // x: User from here to the end of the function
  console.log(x.name);     // safe — x is non-null
}

The compiler is reading the early return and inferring: "after this line, x can't be null." The rest of the function is automatically scoped under that constraint.

Assignments and reassignments

CFA tracks assignments too. let x: string | null = getMaybe(); followed by if (x === null) x = 'default'. After the if, x is string — the reassignment removed null from the possibilities at that point.

One subtlety: this works for let, not const (because const can't be reassigned). And it works only inside the function — the variable's *declared* type stays whatever you wrote; the narrowed type is just what the compiler's tracking at each point.

Function calls reset narrowing (sometimes)

CFA assumes function calls might mutate things. If you narrow a variable, call a function, and then try to use the narrow type, the compiler may have widened it again. The reason: between your narrowing and your use, the function call could have reassigned. For local variables (where you can see all writes), this is conservative. For object properties, it's the right call.

CFA is what makes TypeScript feel intuitive instead of bureaucratic. You write checks the way a human reads code, and the compiler reads them the same way. The narrowing operators are just the vocabulary; CFA is the grammar that strings them together.

Code

Three forms of CFA — early return, assignment, throw·typescript
// Early return narrows the rest of the function.
function process(x: User | null) {
  if (x === null) return;
  // x: User
  console.log(x.name);
}

// CFA tracks each assignment.
function normalize(x: string | null): string {
  if (x === null) {
    x = 'default';   // narrows the rest of the function
  }
  return x;          // x: string
}

// Throw also narrows (the throw branch never returns).
function must(x: User | null): User {
  if (x === null) {
    throw new Error('missing user');
  }
  return x;          // x: User
}
When CFA resets — function calls and properties·typescript
// Function calls can reset narrowing for properties.
interface Box { value: string | null }

function maybeReassign(b: Box) {
  b.value = null;     // function may do this — compiler can't be sure
}

function process(b: Box) {
  if (b.value !== null) {
    // b.value: string
    maybeReassign(b);
    // b.value is widened back to string | null — function call may have mutated it
    // b.value.toUpperCase();   // ❌ now possibly null
  }
}

// For local variables (where the compiler can see all writes), narrowing
// is preserved across function calls. For object properties (where the
// function could reassign), the narrowing is reset.

External links

Exercise

Write a function handle(x: string | number | null) that early-returns 'invalid' if x is null, then early-returns the formatted version if x is a number, then defaults to the string version. Confirm that the compiler narrows x correctly at each line by hovering.
Hint
After if (x === null) return 'invalid', x is string | number. After if (typeof x === 'number') return ..., x is string. The compiler is reading the early returns and tracking what's left.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.