TypeScript Control Flow Analysis: A Practical Guide for Everyday Code
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:
ifandswitchstatementsLogical expressions such as
&&and||Runtime checks like
typeof,instanceof, and'property' in objectCustom 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
anyusage and reduce runtime errorsEditor tooling becomes significantly smarter
To leverage CFA effectively:
Use
typeof,instanceof, and'property' in objectDesign discriminated unions with a shared field
Write custom type guards and assertion functions
Apply
as constwhen 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.