| |

TypeScript 13 ๐Ÿ”ท Control Flow Analysis and Narrowing

TypeScript doesn’t just check types โ€” it tracks how types change as control flows through your code. That’s control flow analysis. When you write if (typeof x === 'string'), TypeScript knows that inside the block, x is a string. When you check if (user !== null), it knows user isn’t null afterward. This narrowing is what makes unions usable, optional properties ergonomic, and type guards powerful. Without it, every union would require manual assertions; with it, the compiler follows your logic and updates the type for you.

Key point: Narrowing is automatic. You write ordinary JavaScript conditions โ€” typeof, in, instanceof, ===, truthiness โ€” and TypeScript narrows the type inside the corresponding branch. The compiler tracks every assignment, every branch, every return. By the time you use a value, TypeScript knows the most specific type it could possibly have at that point. That’s the payoff of a full type system integrated with control flow.


What control flow analysis is

Control flow analysis is the compiler’s process of tracking a variable’s type as execution moves through the program. At every point, TypeScript knows the narrowest type the variable could have, given all the conditions that led there.

function format(value: string | number): string {
  // here, value: string | number

  if (typeof value === 'string') {
    // here, value: string
    return value.toUpperCase();
  }

  // here, value: number (string eliminated)
  return value.toFixed(2);
}

The type of value changes as control flow moves:

LocationType
Startstring | number
Inside if (typeof === 'string')string
After the ifnumber

TypeScript tracks this automatically. No annotation, no assertion.

What the compiler tracks:

  • Assignments โ€” each assignment narrows to the assigned type
  • Conditions โ€” each branch narrows based on the check
  • Returns โ€” early returns remove cases from later code
  • Loops โ€” re-narrows each iteration
  • Functions โ€” return types flow back to call sites
  • Discriminants โ€” literal properties narrow whole unions

Why this matters: Unions would be nearly unusable without narrowing. A string | number would force manual assertion everywhere. Narrowing makes unions ergonomic โ€” you write a check, and the compiler understands.

Why narrowing is the key feature: TypeScript isn’t just a checker โ€” it’s an analyzer. It reasons about when a value has which type. That’s what lets you write if (x !== null) x.foo() instead of (x as NonNull).foo(). The compiler derives the narrowed type from the runtime check, so the code is both safe and idiomatic JavaScript.


The narrowing toolkit

TypeScript narrows via a specific set of checks. Each has its own rules.

CheckNarrows
typeof x === 'string'Primitive types
x instanceof CClass instances
'prop' in xProperty presence
x === 'literal'Literal types
x !== nullNull removal
x != nullNull and undefined removal
if (x)Truthy values
Array.isArray(x)Array
x.kind === 'a'Discriminated union branch
isString(x)Custom predicate

Each tool has a use case. Narrowing is often about picking the right one.


typeof narrowing

typeof narrows among the primitive types.

function format(value: string | number | boolean): string {
  if (typeof value === 'string') return value.toUpperCase();
  if (typeof value === 'number') return value.toFixed(2);
  return value ? 'yes' : 'no';    // value is boolean here
}

typeof returns a string that maps to TypeScript’s primitive types:

typeof resultTypeScript type
'string'string
'number'number
'boolean'boolean
'bigint'bigint
'symbol'symbol
'undefined'undefined
'object'object | null
'function'Function

typeof null is 'object' โ€” a well-known JavaScript gotcha. TypeScript accounts for this: typeof x === 'object' narrows to object | null, so you must handle null separately.

function process(value: string | object | null): void {
  if (typeof value === 'object') {
    // value is object | null โ€” not just object
    if (value !== null) {
      // value is object
      Object.keys(value);
    }
  }
}

typeof and undefined:

function greet(name: string | undefined): string {
  if (typeof name === 'undefined') return 'Hello, stranger';
  return `Hello, ${name}`;        // name is string
}

Both typeof x === 'undefined' and x === undefined narrow. typeof is a safer check because it works even if the variable isn’t declared (though under strict, undeclared variables are errors).

typeof doesn’t narrow custom types:

interface User { name: string; }
function f(x: User | string): void {
  if (typeof x === 'string') {
    // x is string
  } else {
    // x is User
  }
}

typeof only distinguishes primitives. An interface like User returns 'object', which narrows to object โ€” not to User. For custom types, use in, instanceof, or predicates.

Why typeof narrows but not custom types: JavaScript’s typeof only returns a small set of strings โ€” it can’t distinguish between two object types. TypeScript follows JavaScript here: typeof x === 'object' narrows to object, not to a specific interface. Custom narrowing needs a runtime check that distinguishes the types โ€” usually in or a predicate.


instanceof narrowing

instanceof narrows to a class instance.

class ApiError extends Error {
  statusCode: number;
  constructor(msg: string, code: number) {
    super(msg);
    this.statusCode = code;
  }
}

function handle(error: Error | ApiError | string): string {
  if (error instanceof ApiError) {
    return `API error ${error.statusCode}: ${error.message}`;
  }
  if (error instanceof Error) {
    return error.message;
  }
  return error;                   // error is string
}

instanceof uses JavaScript’s prototype chain check โ€” it works for classes but not for interfaces or type aliases (which don’t exist at runtime).

Why interfaces can’t be used with instanceof:

interface User { name: string; }
const x: unknown = { name: 'Alice' };
x instanceof User;                // โŒ User is a type, not a value

Interfaces are erased at compile time. instanceof needs a runtime value โ€” a constructor function.

Error handling is the classic use case:

try {
  await fetchData();
} catch (err) {
  // Under strict, err is unknown
  if (err instanceof Error) {
    console.error(err.message);
  } else if (typeof err === 'string') {
    console.error(err);
  } else {
    console.error('Unknown error');
  }
}

The catch variable is unknown under strict (useUnknownInCatchVariables). instanceof Error is the standard way to narrow it.

instanceof only checks class hierarchies:

class Animal {}
class Dog extends Animal {}

const a: Animal = new Dog();
a instanceof Dog;                 // true โ€” Dog extends Animal
a instanceof Animal;              // true

For unrelated classes, instanceof distinguishes them.

Why instanceof beats typeof for classes: typeof gives 'object' for every class instance โ€” it can’t tell a Dog from an Animal. instanceof walks the prototype chain and identifies the exact class. Use typeof for primitives, instanceof for class instances, and predicates for interfaces.


in narrowing

The in operator narrows by checking whether a property exists.

interface Dog {
  bark: () => void;
  name: string;
}

interface Cat {
  meow: () => void;
  name: string;
}

function speak(pet: Dog | Cat): void {
  if ('bark' in pet) {
    pet.bark();                   // pet is Dog
  } else {
    pet.meow();                   // pet is Cat
  }
}

'bark' in pet tells TypeScript that pet has a bark property โ€” which only Dog has.

in narrows to types that have the property:

type A = { a: number };
type B = { b: string };
type C = { a: number; b: string };

function f(x: A | B | C): void {
  if ('a' in x) {
    // x is A | C โ€” only these have a
  }
  if ('b' in x) {
    // x is B | C โ€” only these have b
  }
}

Multiple types may have the property โ€” in narrows to the union of those.

in with optional properties:

interface User {
  id: number;
  nickname?: string;
}

function greet(u: User): string {
  if ('nickname' in u) {
    // Without exactOptionalPropertyTypes: nickname is string
    // With exactOptionalPropertyTypes: nickname is string | undefined
    return `Hi, ${u.nickname ?? 'user'}`;
  }
  return `Hi, user #${u.id}`;
}

in checks presence. An optional property that’s present with undefined still passes the check, so you may need ?? anyway.

in doesn’t run the property’s value:

if ('bark' in pet) {
  pet.bark();                     // โœ… bark is a function
}

The check is on the key’s existence, not its type. If bark were a number, TypeScript would still narrow but call pet.bark() would fail โ€” the narrowed type would say bark exists, but its type is whatever the union said.

When in is the tool: Discriminating between object shapes that have distinct properties. Common in discriminated unions, message handling, and event dispatch.

Why in matters for structural types: Interfaces are structural, not nominal โ€” two interfaces with different property names are distinguishable only by those names. in is the runtime check that reads those names. Combined with discriminated unions, in handles most object-shape narrowing without needing classes.


Literal and discriminant narrowing

Comparing a value to a literal narrows the type to that literal.

type Status = 'idle' | 'loading' | 'ready';

function label(s: Status): string {
  if (s === 'idle') {
    // s is 'idle'
    return 'Waiting';
  }
  if (s === 'loading') {
    // s is 'loading'
    return 'Loading...';
  }
  // s is 'ready'
  return 'Ready';
}

Narrowing on literal unions works with ===, !==, switch, and || chains.

Discriminated unions extend this to objects:

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
  | { kind: 'rect'; width: number; height: number };

function area(s: Shape): number {
  switch (s.kind) {
    case 'circle': return Math.PI * s.radius ** 2;
    case 'square': return s.side ** 2;
    case 'rect': return s.width * s.height;
  }
}

Narrowing s.kind narrows the whole object. In the circle case, TypeScript knows s has radius. In square, it knows s has side. That’s the discriminated union โ€” narrowing one field narrows the entire shape.

Why this requires literal types: kind: string wouldn’t narrow. kind: 'circle' matches only one branch. That’s why literal types and narrowing are co-dependent.

Narrowing with === on literal objects:

type Command =
  | { type: 'add'; value: number }
  | { type: 'clear' };

function execute(c: Command): void {
  if (c.type === 'add') {
    console.log(c.value);         // value is number
  } else {
    // c is { type: 'clear' }
  }
}

Narrowing with !==:

if (s.kind !== 'circle') {
  // s is 'square' | 'rect'
}

Negative narrowing works too.

Why literals and narrowing are a pair: A literal type has exactly one value. A check like === 'circle' either matches or doesn’t โ€” no ambiguity. That’s why discriminated unions narrow so cleanly: the discriminant is a literal, so === picks exactly one branch. Without literal types, no narrowing; without narrowing, no discriminated unions.


Truthiness narrowing

A truthy check narrows out falsy values.

function greet(name: string | null | undefined): string {
  if (name) {
    // name is string โ€” but also non-empty
    return `Hello, ${name}`;
  }
  return 'Hello, stranger';
}

Truthiness removes: false, 0, '', null, undefined, NaN.

What truthiness narrows:

BeforeAfter if (x)
string | nullstring (non-empty)
number | undefinednumber (non-zero)
User | nullUser
booleantrue
stringstring (non-empty โ€” but type stays string)

Numbers and empty strings are risky:

function format(count: number | undefined): string {
  if (count) {
    // count is number โ€” but non-zero
    return `${count} items`;
  }
  return 'none';
}
// format(0) โ†’ 'none' โ€” but 0 is a valid count!

The truthy check catches 0. If 0 is valid, use !== undefined instead:

if (count !== undefined) {
  return `${count} items`;
}

0 and '' are falsy but often valid. Truthiness is convenient for null/undefined but dangerous when 0 or '' are legitimate values.

Truthiness on objects:

interface User { name: string; }

function greet(user: User | null): string {
  if (user) {
    return `Hello, ${user.name}`;  // user is User
  }
  return 'Hello, stranger';
}

Objects are always truthy, so the check narrows out null and undefined cleanly.

Truthiness with ??:

const display = name ?? 'Anonymous';

?? is not narrowing โ€” it’s an operator that provides a default. But it’s often what you want after narrowing decisions.

Why truthiness is both useful and dangerous: It’s the shortest way to check for null/undefined. But it also eliminates 0, '', false, and NaN โ€” values that are often valid. The rule: use truthiness when the falsy cases are genuinely “missing,” and explicit comparisons (!== undefined) when 0 or '' are valid values.


Equality narrowing

=== and !== narrow based on the compared values.

Against null and undefined:

function f(x: string | null | undefined): string {
  if (x == null) {
    // x is null | undefined
    return 'missing';
  }
  // x is string
  return x;
}

x == null matches both null and undefined โ€” the only case where == is idiomatic. It narrows out both.

x === null narrows only null:

function g(x: string | null | undefined): string {
  if (x === null) return 'null';
  // x is string | undefined
  if (x === undefined) return 'undefined';
  return x;                       // string
}

Against literals:

if (status === 'ready') { /* status is 'ready' */ }

Between variables:

function f(a: string | number, b: string): void {
  if (a === b) {
    // a is string โ€” matches b's type
  }
}

TypeScript narrows a to string because it equals a string.

switch with === semantics:

switch (status) {
  case 'idle': return 0;
  case 'ready': return 1;
  // TypeScript narrows each case
}

switch uses === for case matching, so narrowing works the same as if chains.

Why == null is the one exception: == null matches both null and undefined โ€” no other values. It’s a shorthand for x === null || x === undefined, and it’s safe because no other value is == null. Everywhere else, use === to avoid type coercion.


Array and in narrowing for objects

Array.isArray:

function firstOrLength(x: string | string[]): number | string {
  if (Array.isArray(x)) {
    // x is string[]
    return x.length;
  }
  // x is string
  return x;
}

Array.isArray narrows to array types.

in for property presence:

type Response =
  | { data: string }
  | { error: string };

function handle(r: Response): void {
  if ('data' in r) {
    console.log(r.data);
  } else {
    console.log(r.error);
  }
}

Combining in and typeof:

type Payload =
  | { kind: 'text'; text: string }
  | { kind: 'count'; count: number };

function render(p: Payload): string {
  if ('text' in p && typeof p.text === 'string') {
    return p.text;
  }
  return String(p.count);
}

Multiple checks compound โ€” each narrows further.

in with optional properties and exactOptionalPropertyTypes:

interface User {
  id: number;
  nickname?: string;
}

// Without exactOptionalPropertyTypes
if ('nickname' in user) {
  // user.nickname is string
}

// With exactOptionalPropertyTypes
if ('nickname' in user) {
  // user.nickname is string | undefined
}

The strict flag changes what in proves.

Why Array.isArray and in are standard: They’re the JS-native checks that TypeScript recognizes. Array.isArray narrows to array types (which are otherwise indistinguishable from object); in distinguishes object shapes by property name. Both are runtime checks โ€” they actually run โ€” so the narrowed types are verified, not just trusted.


Narrowing with never and exhaustiveness

When all cases of a union are handled, the remaining type becomes never.

type Status = 'idle' | 'loading' | 'ready' | 'error';

function assertNever(x: never): never {
  throw new Error(`Unhandled: ${x}`);
}

function message(s: Status): string {
  switch (s) {
    case 'idle': return 'Waiting';
    case 'loading': return 'Loading...';
    case 'ready': return 'Ready';
    case 'error': return 'Failed';
    default: return assertNever(s);
  }
}

After all four cases, s is never โ€” the type with no values. If you add a new status and forget to handle it, s won’t be never in default, and assertNever(s) fails to compile.

Why never is a signal: never means “no possible value.” TypeScript uses it to prove exhaustiveness. If the remaining type after all cases is never, every case is covered. If it’s something else, a case is missing.

The default clause pattern:

default: {
  const _exhaustive: never = s;
  throw new Error('Unreachable');
}

Assigning s to a never variable is a common pattern. If s isn’t never, TypeScript errors.

When exhaustiveness matters:

  • State machines โ€” every state must be handled
  • Event handlers โ€” every event must have a case
  • Reducers โ€” every action must be reduced
  • Parsers โ€” every token type must be processed

Exhaustiveness is how you make the compiler enforce “handle all cases” as a rule, not a hope.

Why never is the empty type: In set theory, the empty set has no members. never is TypeScript’s empty set โ€” no value belongs to it. When narrowing eliminates every case, the remaining type is never. That’s not a bug โ€” it’s a proof that the code handles everything. assertNever makes that proof explicit.


Narrowing across function boundaries

Narrowing happens within a single scope. It doesn’t cross function boundaries unless you use a predicate or a const variable.

Assignments inside a callback reset narrowing:

function process(user: User | null): void {
  if (user) {
    // user is User here

    setTimeout(() => {
      // โŒ user is User | null again โ€” the compiler can't know
      // whether the callback runs before or after user changes
      user.name;
    }, 100);
  }
}

The compiler can’t know when the callback runs, so it reverts to the declared type.

Const narrowing persists:

function process(user: User | null): void {
  if (user) {
    const u = user;               // u is User โ€” a const binding
    setTimeout(() => {
      u.name;                     // โœ… u is User โ€” captured as const
    }, 100);
  }
}

Assigning to a const captures the narrowed type. The compiler trusts the const never changes.

Type predicates cross boundaries:

function isUser(x: unknown): x is User {
  return typeof x === 'object' && x !== null && 'name' in x;
}

function process(x: unknown): void {
  if (isUser(x)) {
    setTimeout(() => {
      x.name;                     // โœ… x is User โ€” predicate narrowed it
    }, 100);
  }
}

Predicates assert the type from that point on, even inside callbacks.

Why this matters: Callbacks run later. The compiler can’t know whether a variable was reassigned between the check and the callback. Assign to a const or use a predicate to carry the narrowing forward.

Why const narrowing works: A const can’t be reassigned. If TypeScript narrows it to User inside an if, it stays User forever โ€” including inside a closure. This is why capturing narrowed values in const is a common pattern. It’s the compiler’s proof that the value can’t change.


User-defined type predicates

A type predicate is a function whose return type is value is T. It narrows when the function returns true.

function isString(value: unknown): value is string {
  return typeof value === 'string';
}

function handle(value: unknown): void {
  if (isString(value)) {
    value.toUpperCase();          // value is string
  }
}

The value is string annotation tells TypeScript: “If this returns true, value is a string.”

Predicates compose:

function isNonEmptyString(x: unknown): x is string {
  return typeof x === 'string' && x.length > 0;
}

function isNumber(x: unknown): x is number {
  return typeof x === 'number' && !Number.isNaN(x);
}

Predicates on unions:

type Result = { ok: true; value: string } | { ok: false; error: string };

function isSuccess(r: Result): r is { ok: true; value: string } {
  return r.ok;
}

Predicates are trusted โ€” don’t lie:

function isUser(x: unknown): x is User {
  return true;                    // โŒ lies โ€” compiles, breaks callers
}

The predicate is an assertion. TypeScript trusts you. If the function returns true for values that aren’t User, the narrowed types will be wrong.

When to use predicates:

  • Validating external data (JSON, API responses, user input)
  • Narrowing across function boundaries
  • Reusable checks
  • Complex conditions the compiler can’t track

Predicates vs assertions: Predicates are runtime-checked. as is not. Prefer predicates whenever a runtime check is possible.

Why predicates are the safest narrowing tool: They run at runtime. The compiler trusts the annotation, but the check itself happens for real. Unlike as, which is pure trust, a predicate says “if this check passes at runtime, then the type is T.” That’s the strongest narrowing you can write, and it’s the right choice at every boundary.


asserts โ€” assertion functions

An assertion function narrows by throwing when a condition fails.

function assertIsString(x: unknown): asserts x is string {
  if (typeof x !== 'string') {
    throw new Error('Not a string');
  }
}

function handle(x: unknown): void {
  assertIsString(x);
  x.toUpperCase();                // x is string
}

After the call, x is narrowed. If the value wasn’t a string, the function threw โ€” so the code past the call is unreachable.

asserts condition without a type:

function assert(condition: unknown, msg?: string): asserts condition {
  if (!condition) throw new Error(msg ?? 'Assertion failed');
}

function f(x: unknown): void {
  assert(typeof x === 'string');
  // x is narrowed to string from here
}

asserts condition narrows based on the condition expression passed in.

Assertion functions must be declared with function syntax:

// โœ… works
function assertIsString(x: unknown): asserts x is string { ... }

// โŒ arrow function can't have an `asserts` return type
const assertIsString = (x: unknown): asserts x is string => { ... };

The arrow form fails because asserts requires an explicit return type annotation that arrow functions can’t provide in the same way.

asserts vs predicates:

Featureis Tasserts x is T
Formif (fn(x))fn(x) โ€” throws
Control flowBranchesLinear
Narrows ontrue returnSuccessful return
ThrowsNoYes

When to use asserts: When you want linear narrowing without if blocks. Common in validation utilities.

Why asserts exists: Sometimes an if block isn’t the right shape โ€” you want to validate and continue, not branch. asserts gives you that: call the function, and if it doesn’t throw, the value is narrowed. The compiler understands that the code after the call only runs when the assertion passed. This is the type-safe version of Node’s assert โ€” the same idea, checked at compile time.


A full example

A small message handler with narrowing across all the tools.

// ============================================
// TYPES
// ============================================

type Message =
  | { kind: 'text'; text: string }
  | { kind: 'image'; url: string; alt?: string }
  | { kind: 'file'; name: string; size: number };

// ============================================
// PREDICATES
// ============================================

function isText(
  m: Message
): m is { kind: 'text'; text: string } {
  return m.kind === 'text';
}

function isImage(
  m: Message
): m is { kind: 'image'; url: string; alt?: string } {
  return m.kind === 'image';
}

// ============================================
// ASSERTION
// ============================================

function assertNever(x: never): never {
  throw new Error(`Unhandled: ${JSON.stringify(x)}`);
}

// ============================================
// DISPATCH โ€” NARROWING
// ============================================

function render(m: Message): string {
  switch (m.kind) {
    case 'text':
      return m.text;

    case 'image':
      return `[image: ${m.url}]${m.alt ? ` โ€” ${m.alt}` : ''}`;

    case 'file':
      return `[file: ${m.name} (${m.size} bytes)]`;

    default:
      return assertNever(m);
  }
}

// ============================================
// GUARDS IN FUNCTIONS
// ============================================

function textOrNull(m: Message): string | null {
  if (isText(m)) return m.text;
  if (isImage(m)) return m.alt ?? null;
  return null;
}

// ============================================
// ARRAY FILTERING WITH PREDICATES
// ============================================

const messages: Message[] = [
  { kind: 'text', text: 'hello' },
  { kind: 'image', url: '/a.png', alt: 'A' },
  { kind: 'file', name: 'doc.pdf', size: 1024 },
  { kind: 'text', text: 'world' }
];

const texts = messages.filter(isText);
// texts: { kind: 'text'; text: string }[]

// ============================================
// USAGE
// ============================================

for (const m of messages) {
  console.log(render(m));
}

console.log(texts.map(t => t.text).join(', '));

Every narrowing tool is used: switch on a discriminant, if with a predicate, filtering with a predicate that narrows the array, exhaustive default with assertNever.

Why this pattern is idiomatic: Discriminated unions plus narrowing give you complete, exhaustive handlers. Adding a new message kind triggers compile errors in render. The isText predicate composes with .filter() to produce a typed subset. This is how real dispatch code is written โ€” safe, checked, exhaustive.


Complete Example Session

# ============================================
# PART 1: PRIMITIVE NARROWING
# ============================================

cat > primitives.ts << 'EOF'
function format(v: string | number | boolean): string {
  if (typeof v === 'string') return v.toUpperCase();
  if (typeof v === 'number') return v.toFixed(2);
  return v ? 'yes' : 'no';
}

console.log(format('hi'), format(3.14), format(true));
EOF

npx tsc --noEmit primitives.ts
# (no errors)

# ============================================
# PART 2: INSTANCEOF
# ============================================

cat > instanceof.ts << 'EOF'
class HttpError extends Error {
  constructor(msg: string, public status: number) { super(msg); }
}

function handle(err: unknown): string {
  if (err instanceof HttpError) return `${err.status}: ${err.message}`;
  if (err instanceof Error) return err.message;
  if (typeof err === 'string') return err;
  return 'Unknown error';
}

console.log(handle(new HttpError('Not found', 404)));
console.log(handle(new Error('Oops')));
console.log(handle('plain'));
EOF

npx tsc --noEmit instanceof.ts
# (no errors)

# ============================================
# PART 3: IN
# ============================================

cat > in.ts << 'EOF'
type Response = { data: string } | { error: string };

function handle(r: Response): string {
  if ('data' in r) return r.data;
  return r.error;
}

console.log(handle({ data: 'ok' }));
console.log(handle({ error: 'bad' }));
EOF

npx tsc --noEmit in.ts
# (no errors)

# ============================================
# PART 4: DISCRIMINATED UNION
# ============================================

cat > discriminated.ts << 'EOF'
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number };

function area(s: Shape): number {
  switch (s.kind) {
    case 'circle': return Math.PI * s.radius ** 2;
    case 'square': return s.side ** 2;
  }
}

console.log(area({ kind: 'circle', radius: 1 }));
console.log(area({ kind: 'square', side: 2 }));
EOF

npx tsc --noEmit discriminated.ts
# (no errors)

# ============================================
# PART 5: EXHAUSTIVENESS
# ============================================

cat > exhaustive.ts << 'EOF'
type Status = 'idle' | 'loading' | 'ready' | 'error';

function assertNever(x: never): never {
  throw new Error(`Unhandled: ${x}`);
}

function message(s: Status): string {
  switch (s) {
    case 'idle': return 'Waiting';
    case 'loading': return 'Loading';
    case 'ready': return 'Ready';
    case 'error': return 'Failed';
    default: return assertNever(s);
  }
}

console.log(message('idle'), message('ready'));
EOF

npx tsc --noEmit exhaustive.ts
# (no errors)

# ============================================
# PART 6: PREDICATES AND ASSERTIONS
# ============================================

cat > predicates.ts << 'EOF'
interface User { id: number; name: string; }

function isUser(x: unknown): x is User {
  return typeof x === 'object' && x !== null
    && typeof (x as User).id === 'number'
    && typeof (x as User).name === 'string';
}

function assertUser(x: unknown): asserts x is User {
  if (!isUser(x)) throw new Error('Not a user');
}

function handle(x: unknown): void {
  assertUser(x);
  console.log(x.name);          // x is User
}

const maybe: unknown = { id: 1, name: 'Alice' };
if (isUser(maybe)) console.log('user:', maybe.name);
handle({ id: 2, name: 'Bob' });
EOF

npx tsc --noEmit predicates.ts
# (no errors)

# ============================================
# PART 7: COMPILE AND RUN
# ============================================

npx tsc primitives.ts instanceof.ts in.ts discriminated.ts exhaustive.ts predicates.ts
node primitives.js
# [ HI 3.14 yes ]

node instanceof.js
# [ 404: Not found ]
# [ Oops ]
# [ plain ]

node in.js
# [ ok ]
# [ bad ]

node discriminated.js
# [ 3.141592653589793 ]
# [ 4 ]

node exhaustive.js
# [ Waiting Ready ]

node predicates.js
# [ user: Alice ]
# [ Bob ]

Quick Reference

Narrowing Tools

CheckNarrows
typeof x === 'string'Primitive
x instanceof CClass instance
'p' in xObject shape
x === literalLiteral type
x !== nullRemoves null
x != nullRemoves null and undefined
if (x)Truthy
Array.isArray(x)Array
x.kind === 'a'Discriminated union
isT(x)Custom predicate
assertIsT(x)Assertion function

typeof Results

ResultType
'string'string
'number'number
'boolean'boolean
'bigint'bigint
'symbol'symbol
'undefined'undefined
'object'object | null
'function'Function

Truthiness Removes

ValueFalsy?
falseโœ…
0โœ…
-0โœ…
0nโœ…
''โœ…
nullโœ…
undefinedโœ…
NaNโœ…
{}โŒ
[]โŒ
'0'โŒ

Equality Narrowing

CheckNarrows
x === nullOnly null
x === undefinedOnly undefined
x == nullBoth null and undefined
x === 'a'Literal 'a'
x === otherType of other

in Narrowing Rules

SituationResult
'p' in x where only A has px is A
Multiple types have pUnion of those
Optional propertyPresent but maybe undefined
With exactOptionalPropertyTypes| undefined preserved

Predicates vs Assertions

x is Tasserts x is T
Usageif (fn(x))fn(x) โ€” throws
Control flowBranchesLinear
Runtime checkโœ… (yours)โœ… (yours)
Narrows ontrue returnSuccessful return

never and Exhaustiveness

After handlingRemaining type
All cases of unionnever
Some casesRemaining union
default with neverCompile error if not exhaustive
assertNeverRuntime throw if unreachable

Narrowing Across Boundaries

CaseNarrowing persists?
Same scopeโœ…
Inside callbackโŒ
Captured in constโœ…
Via predicateโœ…
After awaitโš ๏ธ may reset
After assignmentโŒ reset

Array.isArray

BeforeAfter
unknownunknown[]
string | string[]string[]
objectany[]

Best Practices

โœ… Do This:

// Narrow with typeof for primitives
if (typeof x === 'string') { x.toUpperCase(); }              // โœ…

// Use instanceof for class errors
if (err instanceof Error) { err.message; }                   // โœ…

// Use in for object shapes
if ('data' in response) { response.data; }                   // โœ…

// Switch on discriminants for unions
switch (msg.kind) { case 'text': return msg.text; }          // โœ…

// Exhaustive checks with assertNever
default: return assertNever(msg);                            // โœ…

// Use predicates at boundaries
if (isUser(data)) { data.name; }                             // โœ…

// Capture narrowing in const for callbacks
if (user) { const u = user; setTimeout(() => u.name); }      // โœ…

// Use `== null` to check both null and undefined
if (x == null) return;                                       // โœ…

// Prefer `!== undefined` when 0 or '' are valid
if (count !== undefined) { /* ... */ }                       // โœ…

// Use asserts for linear validation
assertIsString(input);                                       // โœ…

โŒ Don’t Do This:

// Don't use truthiness for nullable numbers
if (count) { }  // 0 skipped                                  // โš ๏ธ  use !== undefined

// Don't use truthiness for strings
if (name) { }  // '' skipped                                  // โš ๏ธ  use !== undefined

// Don't rely on narrowing inside callbacks
if (user) { setTimeout(() => user.name); }  // โŒ                // โŒ capture as const

// Don't use `as` where narrowing works
const s = (x as string).toUpperCase();                       // โš ๏ธ  narrow instead

// Don't write predicates that lie
function isUser(x: unknown): x is User { return true; }      // โŒ

// Don't forget to handle null with typeof object
if (typeof x === 'object') { /* x is object | null */ }      // โš ๏ธ  check null

// Don't skip exhaustiveness checks
default: break;  // silently misses cases                     // โš ๏ธ  assertNever

// Don't use `instanceof` on interfaces
x instanceof User;  // User isn't a value                      // โŒ

// Don't compare with `==` except against null
if (x == 0) { }  // coerces                                   // โš ๏ธ  use ===

Common Pitfalls

PitfallProblemSolution
Truthiness on 0 / ''Treated as missingUse !== undefined
typeof x === 'object'Includes nullCheck x !== null
Narrowing in callbacksResetsCapture as const
instanceof on interfacesNo runtime valueUse predicates
Missing exhaustivenessSilent skipassertNever default
as instead of narrowingUnsafeNarrow with checks
Lying predicateRuntime crashesWrite correct checks
Forgetting defaultNon-exhaustiveAdd assertNever
== except nullType coercionUse ===
Optional props with inundefined possibleUse ?? or check

Real-World Examples

1. Primitive narrowing

if (typeof x === 'string') { x.toUpperCase(); }

2. Number narrowing

if (typeof x === 'number') { x.toFixed(2); }

3. Error narrowing

if (err instanceof Error) { err.message; }

4. Custom error

if (err instanceof HttpError) { err.status; }

5. in narrowing

if ('data' in response) { response.data; }

6. Discriminated union

switch (msg.kind) { case 'text': return msg.text; }

7. Null check

if (user !== null) { user.name; }

8. Both null and undefined

if (x == null) return;

9. Truthiness for objects

if (user) { user.name; }

10. Non-zero number

if (count !== undefined) { /* 0 is valid */ }

11. Array narrowing

if (Array.isArray(x)) { x.length; }

12. Type predicate

function isUser(x: unknown): x is User { /* ... */ }

13. Assertion function

function assertUser(x: unknown): asserts x is User { }

14. Exhaustive switch

default: return assertNever(msg);

15. Filter with predicate

const texts = messages.filter(isText);

16. Capture narrowing in const

if (user) { const u = user; fn(() => u.name); }

17. Narrowing optional property

if (u.nickname !== undefined) { u.nickname.length; }

18. Switch on literal union

switch (status) { case 'ready': return true; }

19. Narrowing with ||

if (x === 'a' || x === 'b') { /* x is 'a' | 'b' */ }

20. Compound narrowing

if ('name' in obj && typeof obj.name === 'string') { }

Visual: Narrowing Flow

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  function format(v: string | number) {       โ”‚
โ”‚                                              โ”‚
โ”‚    // v: string | number                     โ”‚
โ”‚                                              โ”‚
โ”‚    if (typeof v === 'string') {              โ”‚
โ”‚      // v: string                            โ”‚
โ”‚      v.toUpperCase();                        โ”‚
โ”‚    }                                         โ”‚
โ”‚                                              โ”‚
โ”‚    // v: number (string eliminated)          โ”‚
โ”‚    v.toFixed(2);                             โ”‚
โ”‚                                              โ”‚
โ”‚  }                                           โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Visual: Discriminated Union Narrowing

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  type Shape =                                โ”‚
โ”‚    | { kind: 'circle'; radius: number }      โ”‚
โ”‚    | { kind: 'square'; side: number };       โ”‚
โ”‚                                              โ”‚
โ”‚  switch (s.kind) {                           โ”‚
โ”‚    case 'circle':                            โ”‚
โ”‚      // s: { kind: 'circle'; radius: number }โ”‚
โ”‚      return Math.PI * s.radius ** 2;         โ”‚
โ”‚                                              โ”‚
โ”‚    case 'square':                            โ”‚
โ”‚      // s: { kind: 'square'; side: number }  โ”‚
โ”‚      return s.side ** 2;                     โ”‚
โ”‚  }                                           โ”‚
โ”‚                                              โ”‚
โ”‚  Narrowing one field narrows the whole union โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Visual: typeof Results

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  typeof x === 'string'  โ†’  string            โ”‚
โ”‚  typeof x === 'number'  โ†’  number            โ”‚
โ”‚  typeof x === 'boolean' โ†’  boolean           โ”‚
โ”‚  typeof x === 'bigint'  โ†’  bigint            โ”‚
โ”‚  typeof x === 'symbol'  โ†’  symbol            โ”‚
โ”‚  typeof x === 'undefined' โ†’ undefined        โ”‚
โ”‚  typeof x === 'function' โ†’  Function         โ”‚
โ”‚  typeof x === 'object'  โ†’  object | null     โ”‚
โ”‚                                              โ”‚
โ”‚  โš ๏ธ  typeof null === 'object'                 โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Visual: Narrowing Across Boundaries

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Same scope โ€” narrows                        โ”‚
โ”‚                                              โ”‚
โ”‚  if (user) {                                 โ”‚
โ”‚    user.name;      โœ…                        โ”‚
โ”‚  }                                           โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Inside callback โ€” resets                    โ”‚
โ”‚                                              โ”‚
โ”‚  if (user) {                                 โ”‚
โ”‚    setTimeout(() => {                        โ”‚
โ”‚      user.name;   โŒ user is User | null     โ”‚
โ”‚    });                                       โ”‚
โ”‚  }                                           โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Captured as const โ€” persists                โ”‚
โ”‚                                              โ”‚
โ”‚  if (user) {                                 โ”‚
โ”‚    const u = user;                           โ”‚
โ”‚    setTimeout(() => {                        โ”‚
โ”‚      u.name;      โœ… u is User               โ”‚
โ”‚    });                                       โ”‚
โ”‚  }                                           โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Via predicate โ€” persists                    โ”‚
โ”‚                                              โ”‚
โ”‚  if (isUser(x)) {                            โ”‚
โ”‚    setTimeout(() => {                        โ”‚
โ”‚      x.name;      โœ… x is User               โ”‚
โ”‚    });                                       โ”‚
โ”‚  }                                           โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Visual: never and Exhaustiveness

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  type Status = 'a' | 'b' | 'c';              โ”‚
โ”‚                                              โ”‚
โ”‚  switch (s) {                                โ”‚
โ”‚    case 'a': return 1;                       โ”‚
โ”‚    case 'b': return 2;                       โ”‚
โ”‚    case 'c': return 3;                       โ”‚
โ”‚    default:                                  โ”‚
โ”‚      // s is never                           โ”‚
โ”‚      return assertNever(s);                  โ”‚
โ”‚  }                                           โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Add 'd' to Status:                          โ”‚
โ”‚                                              โ”‚
โ”‚  switch (s) {                                โ”‚
โ”‚    case 'a': ... case 'b': ... case 'c': ... โ”‚
โ”‚    default:                                  โ”‚
โ”‚      // s is 'd' โ€” not never                 โ”‚
โ”‚      return assertNever(s);                  โ”‚
โ”‚      // โŒ Argument of type 'string'         โ”‚
โ”‚      //    not assignable to 'never'         โ”‚
โ”‚  }                                           โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Visual: Predicate vs Assertion

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Predicate โ€” branching                       โ”‚
โ”‚                                              โ”‚
โ”‚  if (isUser(x)) {                            โ”‚
โ”‚    x.name;    // x is User                   โ”‚
โ”‚  } else {                                    โ”‚
โ”‚    // x is not User                          โ”‚
โ”‚  }                                           โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Assertion โ€” linear                          โ”‚
โ”‚                                              โ”‚
โ”‚  assertUser(x);                              โ”‚
โ”‚  x.name;    // x is User (or throw earlier)  โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Visual: Truthiness Trap

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  function f(count: number | undefined) {     โ”‚
โ”‚    if (count) {                              โ”‚
โ”‚      // runs only if count is non-zero       โ”‚
โ”‚      // skips 0 โ€” often a valid value        โ”‚
โ”‚    }                                         โ”‚
โ”‚  }                                           โ”‚
โ”‚                                              โ”‚
โ”‚  f(0)  โ†’ falls through, treats 0 as missing  โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  function f(count: number | undefined) {     โ”‚
โ”‚    if (count !== undefined) {                โ”‚
โ”‚      // runs for 0 too                       โ”‚
โ”‚    }                                         โ”‚
โ”‚  }                                           โ”‚
โ”‚                                              โ”‚
โ”‚  f(0)  โ†’ enters block                        โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Visual: Narrowing Checklist

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Which tool?                                 โ”‚
โ”‚                                              โ”‚
โ”‚  Primitive type?        โ†’ typeof             โ”‚
โ”‚  Class instance?        โ†’ instanceof         โ”‚
โ”‚  Object shape?          โ†’ in                 โ”‚
โ”‚  Literal union?         โ†’ ===                โ”‚
โ”‚  Discriminated union?   โ†’ switch on kind     โ”‚
โ”‚  Null/undefined?        โ†’ !== null / == null โ”‚
โ”‚  Array?                 โ†’ Array.isArray      โ”‚
โ”‚  Custom type?           โ†’ predicate          โ”‚
โ”‚  Post-check narrowing?  โ†’ asserts            โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Summary

ConceptMeaning
Control flow analysisCompiler tracks types as code runs
NarrowingType becomes more specific in a branch
typeofNarrows primitives
instanceofNarrows class instances
inNarrows object shapes
===Narrows literals and discriminants
TruthinessNarrows out falsy values
!== nullRemoves null
== nullRemoves null and undefined
Array.isArrayNarrows to array
Predicatex is T โ€” runtime check
Assertion functionasserts x is T โ€” throws
neverRemaining type after exhaustive cases
assertNeverHelper that proves exhaustiveness

Key takeaways:

  • Narrowing is automatic โ€” write ordinary JS conditions, and TypeScript updates the type
  • typeof narrows primitives; instanceof narrows classes; in narrows object shapes
  • Discriminated unions narrow via literal discriminants โ€” switch (x.kind)
  • Truthiness removes all falsy values โ€” dangerous for 0 and ''
  • x == null is the one idiomatic == โ€” matches both null and undefined
  • Array.isArray narrows to array types
  • never means exhaustive โ€” pair assertNever with a default branch
  • Narrowing doesn’t cross callbacks โ€” capture in const or use predicates
  • Type predicates (x is T) are runtime-checked and safe
  • Assertion functions (asserts x is T) narrow linearly by throwing
  • Prefer !== undefined over truthiness when 0 or '' are valid
  • Prefer predicates over as at boundaries โ€” they’re checked

Remember: TypeScript doesn’t just check types โ€” it tracks them through your code. Every if, switch, typeof, in, and predicate updates the compiler’s view of a value. That’s what makes unions ergonomic, optional fields safe, and exhaustive switches possible. Write ordinary JavaScript conditions; trust TypeScript to narrow. When narrowing can’t reach โ€” across callbacks, at API boundaries โ€” use predicates and assertion functions. The compiler does the rest.


Stop using slow, ad-bloated tool sites! ๐Ÿคฎ

๐Ÿ”Ž Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
โœ… Finance (Mortgage, Interest, Inflation)
โœ… Tech (Base64, JSON, Dev Suite, IP)
โœ… Health (BMI, BMR, TDEE)
โœ… Productivity (Timer, Workspace, QR)

โšก๏ธ Fast & Private
๐Ÿ”’ No data leaves your device
๐Ÿ’Ž 100% Free

๐Ÿ”— Use it now: https://tools.kandz.me
๐Ÿ”– Bookmark itโ€”youโ€™ll need it later!