Skip to main content

Command Palette

Search for a command to run...

TypeScript Control Flow Analysis: A Practical Guide for Everyday Code

Published
5 min read
S

Technology Enthusiast and voracious reader with a demonstrated history of working in the computer software industry. Skilled in PHP, JavaScript, NodeJS, Angular, MySQL, MongoDB, Web3, Product Development, Project Management, and Teamwork.

TypeScript’s type system is powerful, but what truly makes it feel intelligent is Control Flow Analysis (CFA). CFA allows TypeScript to follow your program’s execution paths—through if statements, expressions, and function calls—and automatically narrow types as conditions are evaluated.

This capability significantly reduces the need for manual type annotations and helps catch subtle bugs early. This guide walks through the most important CFA patterns commonly used in everyday TypeScript code: conditional narrowing, expressions, discriminated unions, type guards, assertion functions, and assignment-based narrowing.


What Control Flow Analysis Does

Control Flow Analysis typically begins with a union type and progressively narrows it based on program logic.

For example, a value might initially be typed as string | number. After a typeof check, TypeScript can safely treat it as either string or number within the appropriate branch.

CFA works through standard JavaScript control structures, including:

  • if and switch statements

  • Logical expressions such as && and ||

  • Runtime checks like typeof, instanceof, and 'property' in object

  • Custom helper functions designed to narrow types

The result is idiomatic JavaScript code that benefits from increasingly precise type information as execution flows forward.


Narrowing Types with If Statements

Using typeof for Primitive Types

Consider a value that may be either a string or a number:

const input = getUserInput(); // string | number

if (typeof input === "string") {
  input.toUpperCase(); // input: string
} else {
  input.toFixed(2); // input: number
}

The typeof check informs TypeScript which type applies in each branch, enabling correct IntelliSense and compile-time safety.


Using instanceof for Class-Based Objects

For values created from classes:

const input = getUserInput(); // number | number[]

if (input instanceof Array) {
  console.log(input.length); // input: number[]
} else {
  console.log(input.toFixed(2)); // input: number
}

TypeScript narrows the type based on the instanceof result.


Using 'property' in object for Structural Checks

You can distinguish between object shapes by checking for the existence of a property:

const input = getUserInput(); // string | { error: string }

if ("error" in input) {
  console.error(input.error); // input: { error: string }
} else {
  console.log(input); // input: string
}

Using Custom Type Guard Functions

You can define helper functions that explicitly instruct TypeScript how to narrow types:

function isArrayOfNumbers(value: unknown): value is number[] {
  return Array.isArray(value);
}

const input = getUserInput(); // unknown

if (isArrayOfNumbers(input)) {
  // input: number[]
} else {
  // input: unknown
}

The return type value is number[] is what makes this a custom type guard.


Narrowing Within Expressions

Control Flow Analysis also works within expressions, not just explicit if blocks.

const input = getUserInput(); // string | number

const inputLength =
  (typeof input === "string" && input.length) || 0;

Within the left side of the && operator, input is narrowed to string. After the expression resolves, the type returns to its original union. This allows concise, expressive, and type-safe code.


Discriminated Unions

A discriminated union is a union where all members share a common field—typically called a discriminant—such as status or type.

type Responses =
  | { status: 200; data: any }
  | { status: 301; to: string }
  | { status: 400; error: Error };

Because all variants include status, TypeScript can reliably narrow based on its value:

const response = getResponse(); // Responses

switch (response.status) {
  case 200:
    return response.data;
  case 301:
    return redirect(response.to);
  case 400:
    throw response.error;
}

Each case receives a fully narrowed type without the need for type assertions.


Type Guards: Custom Narrowing Logic

Type guards are functions whose return type explicitly describes how a value should be narrowed when the function returns true.

type Response = SuccessResponse | APIErrorResponse;

function isErrorResponse(obj: Response): obj is APIErrorResponse {
  return obj instanceof APIErrorResponse;
}

const response = getResponse(); // Response

if (isErrorResponse(response)) {
  // response: APIErrorResponse
} else {
  // response: SuccessResponse
}

The defining pattern is:

function fn(value: T): value is NarrowedT

Whenever fn(value) evaluates to true, TypeScript narrows value to NarrowedT within that scope.


Assertion Functions: Narrowing or Throwing

Assertion functions take type narrowing a step further. Instead of returning a boolean, they either throw an error or allow execution to continue. Their return type uses the asserts keyword.

function assertSuccessResponse(obj: any): asserts obj is SuccessResponse {
  if (!(obj instanceof SuccessResponse)) {
    throw new Error("Not a success!");
  }
}

const res = getResponse(); // SuccessResponse | ErrorResponse
assertSuccessResponse(res);
// res is now SuccessResponse

If the function does not throw, TypeScript assumes the assertion holds and narrows the type accordingly.


Assignment and as const: Preserving Literal Types

By default, object literals are widened. For example, "Zagreus" becomes string. Using as const preserves literal values.

const data1 = {
  name: "Zagreus",
};
// { name: string }

const data2 = {
  name: "Zagreus",
} as const;
// { readonly name: "Zagreus" }

This is particularly useful when:

  • Building discriminated unions

  • Working with string literal unions

  • Relying on exact values for control flow

CFA also tracks related variables:

const res = getResponse();
const isSuccessResponse = res instanceof SuccessResponse;

if (isSuccessResponse) {
  // res is treated as SuccessResponse here
}

And it updates types when variables are reassigned:

let data: string | number;

data = "Hello"; // data: string
data = 3;       // data: number

Why Control Flow Analysis Matters

Control Flow Analysis is what makes TypeScript feel natural and productive for JavaScript developers:

  • You write standard JavaScript using familiar control structures

  • TypeScript refines types automatically as logic unfolds

  • You avoid unnecessary any usage and reduce runtime errors

  • Editor tooling becomes significantly smarter

To leverage CFA effectively:

  • Use typeof, instanceof, and 'property' in object

  • Design discriminated unions with a shared field

  • Write custom type guards and assertion functions

  • Apply as const when literal precision matters

Mastering these patterns allows TypeScript to work with your code rather than against it—providing safety, clarity, and confidence without sacrificing readability.

More from this blog

V

Voice of Dev

17 posts