Skip to main content
TypeScript · lesson 22 of 25
TypeScript intermediate Lesson 22 of 25

Narrowing and Control Flow Analysis

Truthiness traps, narrowing lost and kept across closures, inferred filter predicates, NoInfer, and why as and ! compile cleanly but crash at runtime.

By SkillByExample · Published

typescript narrowing control-flow

Examples run on TypeScript 7.0.2 and Node v24.18.1, each as a standalone file in a folder whose package.json has "type": "module". npx tsc --noEmit --module nodenext file.ts type-checks a file; npx tsx file.ts runs one.

typeof, instanceof, in, discriminated unions and user-defined type guards (value is Type) are the basic narrowing tools — Error Handling in TypeScript covers all of them with worked examples, including assertion functions and the isUser/isUserLying pattern for a type guard that lies. This lesson assumes those and is about what happens around a correct check — when a narrowing that should hold stops telling the truth.

Truthiness Is Not the Same as a Null Check

if (value) checks truthiness, not presence. For a number | undefined, that quietly merges a real 0 with a missing value.

function resolvePort(configured: number | undefined): number {
  return configured || 3000;
}

console.log(resolvePort(8080));
console.log(resolvePort(0));
console.log(resolvePort(undefined));

function resolvePortSafely(configured: number | undefined): number {
  return configured !== undefined ? configured : 3000;
}

console.log(resolvePortSafely(8080));
console.log(resolvePortSafely(0));
console.log(resolvePortSafely(undefined));
$ npx tsc --noEmit --module nodenext truthiness.ts
$ npx tsx truthiness.ts
8080
3000
3000
8080
0
3000

resolvePort(0) returns 3000 — a configured port of 0 is treated exactly like no configuration at all, because 0 is falsy. tsc has no opinion about this: both functions type-check cleanly, since configured || 3000 and configured !== undefined ? configured : 3000 are both valid ways to turn number | undefined into number. Only resolvePortSafely, which checks for undefined specifically, gets the second call right. The same gap bites "" for strings and NaN for numbers — anything with a falsy value that’s still meaningful data.

Narrowing Lost Inside a Callback

A let narrowed by a check can lose that narrowing inside a function created in the same scope, if the variable is reassigned later.

function logWhenReady(value: string | number) {
  let current = value;

  if (typeof current === "string") {
    setTimeout(() => {
      console.log(current.toUpperCase());
    }, 0);
  }

  current = 99;
}

logWhenReady("hello");
$ npx tsc --noEmit --module nodenext closure-narrow.ts
closure-narrow.ts(6,27): error TS2339: Property 'toUpperCase' does not exist on type 'string | number'.
  Property 'toUpperCase' does not exist on type 'number'.

Inside the if, current is narrowed to string for ordinary code — but the setTimeout callback might not run until after current = 99 has executed, so the compiler widens it back to string | number for anything referenced inside that callback. It’s being conservative about when the callback actually runs, not about what’s true at the moment it’s created.

TypeScript 5.4 added one exception: if every assignment to the variable happens before the narrowing check — so nothing can reassign it afterward, on any path — the callback keeps the narrowed type.

function logWhenReady(value: string | number) {
  let current = value;
  current = typeof value === "string" ? value.trim() : value;

  if (typeof current === "string") {
    setTimeout(() => {
      console.log(current.toUpperCase());
    }, 0);
  }
}

logWhenReady("hello");
logWhenReady(42);
$ npx tsc --noEmit --module nodenext closure-narrow-2.ts
$ npx tsx closure-narrow-2.ts
HELLO

The only assignment to current is the line right after its declaration, before the if. Since there’s no later assignment anywhere in the function, the compiler can prove the callback will always see the narrowed value, and current.toUpperCase() compiles without the cast or local copy earlier versions of TypeScript needed.

Filtering Out undefined Infers the Narrower Array Type

Since TypeScript 5.5, a callback passed to .filter that the compiler can prove is a type guard gets one inferred automatically — you don’t have to write (x): x is string => ... yourself.

const raw: (string | undefined)[] = ["a", undefined, "b", undefined, "c"];

const filtered = raw.filter((x) => x !== undefined);

console.log(filtered);
console.log(filtered.map((s) => s.toUpperCase()));
$ npx tsc --noEmit --module nodenext filter-predicate.ts
$ npx tsx filter-predicate.ts
[ 'a', 'b', 'c' ]
[ 'A', 'B', 'C' ]

filtered is string[], not (string | undefined)[] — .map((s) => s.toUpperCase()) needs no s! or extra check. Before 5.5, .filter((x) => x !== undefined) still removed the undefined values at runtime, but the type stayed (string | undefined)[], because the compiler had no way to connect an arbitrary boolean expression to a type guard without an explicit x is string annotation. It now recognizes this specific shape of check.

NoInfer: Keeping One Argument Out of Inference

A type parameter is normally inferred from every argument whose type mentions it — which can let an unrelated argument silently join the inferred type instead of being checked against it.

function createStreetLight<C extends string>(colors: C[], defaultColor?: C): C {
  return defaultColor ?? colors[0];
}

const light = createStreetLight(["red", "yellow", "green"], "blue");
console.log(light);
$ npx tsc --noEmit --module nodenext noinfer-bug.ts
$ npx tsx noinfer-bug.ts
blue

That compiles with no error and prints blue — a color that was never one of the street light’s colors. C was inferred as "red" | "yellow" | "green" | "blue", widened to include defaultColor’s own argument, so "blue" happily satisfies C. Wrapping just the defaultColor parameter’s type in NoInfer keeps C tied to colors alone:

function createStreetLight<C extends string>(colors: C[], defaultColor?: NoInfer<C>): C {
  return defaultColor ?? colors[0];
}

const light = createStreetLight(["red", "yellow", "green"], "blue");
console.log(light);
$ npx tsc --noEmit --module nodenext noinfer-fixed.ts
noinfer-fixed.ts(5,61): error TS2345: Argument of type '"blue"' is not assignable to parameter of type '"green" | "red" | "yellow" | undefined'.

With C inferred only from colors, defaultColor is now checked against "red" | "yellow" | "green" instead of contributing to it, and the invalid default is caught. Passing "yellow" instead compiles and runs:

function createStreetLight<C extends string>(colors: C[], defaultColor?: NoInfer<C>): C {
  return defaultColor ?? colors[0];
}

const light = createStreetLight(["red", "yellow", "green"], "yellow");
console.log(light);
$ npx tsc --noEmit --module nodenext noinfer-fixed.ts
$ npx tsx noinfer-fixed.ts
yellow

as and ! Compile Cleanly, Then Crash

as and the non-null assertion ! both tell the compiler to trust you instead of checking. Trusting a wrong claim produces no compile error — the failure shows up later, at runtime, somewhere that may not even mention the assertion.

interface User {
  name: string;
  profile: { bio: string };
}

function loadUser(raw: unknown): User {
  return raw as User;
}

const user = loadUser({ name: "Ada" });
console.log(user.name);
console.log(user.profile.bio);
$ npx tsc --noEmit --module nodenext assertions-crash.ts
$ npx tsx assertions-crash.ts
Ada
C:\tmp\ts-gap\assertions-crash.ts:12
console.log(user.profile.bio);
                         ^

TypeError: Cannot read properties of undefined (reading 'bio')
    at <anonymous> (C:\tmp\ts-gap\assertions-crash.ts:12:26)
    at ModuleJob.run (node:internal/modules/esm/module_job:439:25)
    at async node:internal/modules/esm/loader:643:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v24.18.1

loadUser({ name: "Ada" }) has no profile at all, but as User erased that mismatch at compile time. user.name happens to work, by coincidence, and user.profile.bio throws three lines later — far from where the false promise was made. The non-null assertion ! is the same shape of risk, applied to one value instead of a whole object:

function findUser(id: number, users: Map<number, { name: string }>): string {
  const user = users.get(id)!;
  return user.name;
}

const users = new Map([[1, { name: "Ada" }]]);

console.log(findUser(1, users));
console.log(findUser(2, users));
$ npx tsc --noEmit --module nodenext non-null-crash.ts
$ npx tsx non-null-crash.ts
Ada
C:\tmp\ts-gap\non-null-crash.ts:3
  return user.name;
              ^

TypeError: Cannot read properties of undefined (reading 'name')
    at findUser (C:\tmp\ts-gap\non-null-crash.ts:3:15)
    at <anonymous> (C:\tmp\ts-gap\non-null-crash.ts:9:13)
    at ModuleJob.run (node:internal/modules/esm/module_job:439:25)
    at async node:internal/modules/esm/loader:643:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v24.18.1

users.get(id)! asserts the lookup never returns undefined. It does, for id === 2, and ! doesn’t check — it just changes the type tsc believes, from { name: string } | undefined to { name: string }. An actual narrowing check (if (user === undefined)), or user?.name, would have caught this the same way the two earlier sections caught theirs: by asking the compiler to prove it, not promising it by hand.

Practice

1. greeting below falls back to "stranger" with ||, so a deliberately empty name is treated as no name. Write greetingSafe, which only falls back when the name is undefined, and compare both on "".

Solution
function greeting(label: string | undefined): string {
  return `Hello, ${label || "stranger"}`;
}

console.log(greeting("Ada"));
console.log(greeting(""));
console.log(greeting(undefined));

function greetingSafe(label: string | undefined): string {
  return `Hello, ${label !== undefined ? label : "stranger"}`;
}

console.log(greetingSafe("Ada"));
console.log(greetingSafe(""));
console.log(greetingSafe(undefined));
$ npx tsc --noEmit --module nodenext practice-1.ts
$ npx tsx practice-1.ts
Hello, Ada
Hello, stranger
Hello, stranger
Hello, Ada
Hello, 
Hello, stranger

greeting("") and greeting(undefined) print the same thing — truthiness can’t tell them apart. greetingSafe("") prints Hello, with nothing after the comma, which is correct: an explicitly empty name was provided.

2. raw below mixes null and undefined. Filter it down to a string[] with a single loose-inequality check (x != null), then try the stricter x !== null instead and see what’s still missing.

Solution
const raw: (string | null | undefined)[] = ["a", null, "b", undefined, "c"];

const filtered = raw.filter((x) => x != null);

console.log(filtered);
console.log(filtered.map((s) => s.toUpperCase()));
$ npx tsc --noEmit --module nodenext practice-2.ts
$ npx tsx practice-2.ts
[ 'a', 'b', 'c' ]
[ 'A', 'B', 'C' ]

x != null is loose inequality, which is true only when x is neither null nor undefined — the compiler recognizes that exact check as a type guard for both, the same way it recognized x !== undefined in the lesson above, and infers filtered: string[]. Checking only null leaves undefined on the table:

const raw: (string | null | undefined)[] = ["a", null, "b", undefined, "c"];

const stillLoose = raw.filter((x) => x !== null);
console.log(stillLoose.map((s) => s.toUpperCase()));
$ npx tsc --noEmit --module nodenext practice-2b.ts
practice-2b.ts(4,35): error TS18048: 's' is possibly 'undefined'.

stillLoose is typed (string | undefined)[] — x !== null only proved x isn’t null, so undefined survives into the result type exactly as it would survive at runtime if the input ever had one past the three literal values checked here.

3. Fix logWhenReady below so the setTimeout callback can call current.toUpperCase() without an error, given it must still double a numeric input somewhere in the function.

function logWhenReady(value: string | number) {
  let current = value;

  if (typeof current === "string") {
    setTimeout(() => {
      console.log(current.toUpperCase());
    }, 0);
  }

  if (typeof current === "number") {
    current = current * 2;
  }
}
$ npx tsc --noEmit --module nodenext practice-3.ts
practice-3.ts(6,27): error TS2339: Property 'toUpperCase' does not exist on type 'string | number'.
  Property 'toUpperCase' does not exist on type 'number'.
Solution
function logWhenReady(value: string | number) {
  let current = typeof value === "number" ? value * 2 : value;

  if (typeof current === "string") {
    setTimeout(() => {
      console.log(current.toUpperCase());
    }, 0);
  }
}

logWhenReady("hi");
logWhenReady(7);
$ npx tsc --noEmit --module nodenext practice-3.ts
$ npx tsx practice-3.ts
HI

The broken version assigns current a second time after the narrowing check, inside a separate if — a path the compiler must assume could still run before the setTimeout callback does, so it widens current back to string | number inside the callback. Folding the doubling into the initial assignment, with a ternary, moves that assignment before the narrowing check and the closure, with nothing left to reassign current afterward — which is what 5.4’s rule actually requires: no assignment reachable after the closure is created, not a specific count of assignments overall. logWhenReady(7) now prints nothing, since 14 is a number and never reaches the setTimeout branch at all.

4. Replace the ! in findUser with optional chaining and a fallback string, so findUser(2, users) returns a message instead of throwing.

Solution
function findUser(id: number, users: Map<number, { name: string }>): string {
  const user = users.get(id);
  return user?.name ?? "unknown user";
}

const users = new Map([[1, { name: "Ada" }]]);

console.log(findUser(1, users));
console.log(findUser(2, users));
$ npx tsc --noEmit --module nodenext practice-4.ts
$ npx tsx practice-4.ts
Ada
unknown user

user?.name only reads .name when user isn’t undefined, and ?? "unknown user" supplies a value for the other case — both branches handled by the type system instead of by a promise.

Frequently Asked Questions

What is control flow analysis in TypeScript?
It is how the compiler tracks a variable's type through every branch, assignment and function boundary in the code, narrowing a union to a smaller type wherever a check proves part of it impossible. typeof, instanceof, in, and discriminated union checks all feed it; so do reassignments, which can widen a type back out.
Why does if (count) behave differently from if (count !== null)?
if (count) checks truthiness, so it treats 0, empty string, NaN and false the same as null or undefined — all of them take the else branch. if (count !== null) checks only for null, so a legitimate zero or empty string still takes the true branch. The two are equivalent only when the type can never be falsy except via null or undefined.
Why is narrowing sometimes lost inside a callback?
A callback created inside a narrowed branch might run after the enclosing function returns, by which point a let variable could have been reassigned. Before TypeScript 5.4, the compiler always assumed that risk and widened the type back out inside the callback. Since 5.4, if every assignment to the variable happens before the callback is created, the compiler can prove the risk doesn't exist and keeps the narrowed type.
Is a type assertion (as) checked at runtime?
No. as only tells the compiler to trust your claim about a value's type; it compiles to nothing and performs no check. If the claim is wrong, the code still type-checks, and the mistake surfaces later as an ordinary runtime error, such as reading a property off undefined.
What does NoInfer do?
NoInfer<T> marks a position where a generic type parameter should not be inferred from, without removing the normal type check at that position. It is used when a function has one argument that should establish a generic type and another that should only be validated against it, such as a list of allowed values and a default pulled from that list.