Control flow#

Control flow in TypeScript is the JavaScript surface (see Control flow) plus narrowing. At every branch the compiler refines each binding’s type based on what the condition proved. Build typed code that exhausts every variant of a union; never is the compiler’s signal for an unhandled case.

Narrowing#

The compiler narrows on every kind of condition.

        flowchart TD
    A(["x: string | number | null"]) --> B{"x == null?"}
    B -->|true| C([return])
    B -->|false| D{"typeof x === 'string'?"}
    D -->|true| E["x: string"]
    D -->|false| F["x: number"]
    
function display(x: string | number | null) {
  if (x == null) return;                 // null and undefined dropped
  if (typeof x === "string") {
    x.toUpperCase();                     // x: string
  } else {
    x.toFixed(2);                        // x: number
  }
}

Narrowing dialects.

Check

Narrows by

typeof x === "string" | "number" |

primitive tag

x === null / x === undefined

removes the literal from the union

x == null

removes both null and undefined

x instanceof C

to C

"key" in x

to union members that have key

x.kind === "circle"

discriminated union, to the matching member

user-defined x is T

to T

Discriminated unions#

A discriminated union pairs each member with a literal tag. switch on the tag narrows automatically.

        flowchart TD
    A([Shape]) --> B{s.kind}
    B -->|circle| C["s: {kind:'circle', r}"]
    B -->|rect| D["s: {kind:'rect', w, h}"]
    C --> E[area = π·r²]
    D --> F[area = w·h]
    
type Shape =
  | {kind: "circle"; r: number}
  | {kind: "rect";   w: number; h: number};

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.r ** 2;
    case "rect":   return s.w * s.h;
  }
}

Go-to structure for return types that can be a success or a failure.

type Result<T, E = Error> =
  | {ok: true;  value: T}
  | {ok: false; error: E};

function parsePort(s: string): Result<number, string> {
  const n = Number(s);
  if (!Number.isInteger(n) || n < 0 || n > 65535) {
    return {ok: false, error: `bad port: ${s}`};
  }
  return {ok: true, value: n};
}

Exhaustive checking with never#

never is the empty type; nothing can be assigned to it. The operator drops a never annotation in the default branch of an exhaustive switch; if a new variant is added later, the compile fails at exactly the wrong line.

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.r ** 2;
    case "rect":   return s.w * s.h;
    default: {
      const _exhaustive: never = s;
      throw new Error(`unhandled: ${JSON.stringify(s)}`);
    }
  }
}

When the operator extends Shape with {kind: "tri"; ...}, _exhaustive: never = s fails to type-check because s is now {kind: "tri"; ...}, not never.

Type guards#

A user-defined type guard is a function whose return type is x is T. Returning true proves the narrowed type to the compiler.

function isString(x: unknown): x is string {
  return typeof x === "string";
}

function trim(x: unknown): string {
  if (isString(x)) return x.trim();      // narrowed to string
  throw new TypeError("expected string");
}

The asserts form throws instead of returning a boolean; control flow past the call has the narrowed type.

function assertString(x: unknown): asserts x is string {
  if (typeof x !== "string") throw new TypeError("not a string");
}

function trim2(x: unknown) {
  assertString(x);
  return x.trim();                       // x: string from here on
}

Conditional return narrowing#

The compiler tracks narrowing across function boundaries via overloaded signatures or generic constraints.

function first<T>(xs: T[]): T | undefined;
function first<T>(xs: readonly [T, ...T[]]): T;
function first<T>(xs: T[]): T | undefined {
  return xs[0];
}

const a = first([1, 2, 3]);              // number | undefined
const b = first([1, 2, 3] as const);     // number  (non-empty tuple)

Iteration over typed collections#

for...of on a typed array carries the element type; Object.entries widens to [string, T] (because keys are not statically known).

const users: User[] = await loadUsers();
for (const u of users) {
  u.name;                                // u: User
}

for (const [k, v] of Object.entries(record)) {
  // k: string, v: ValueType    (key is widened to string)
}

For object iteration with precise keys, use a for...in loop plus a key cast, or write the helper with keyof.

function entries<T extends object>(obj: T): [keyof T, T[keyof T]][] {
  return Object.entries(obj) as [keyof T, T[keyof T]][];
}

References#