Back to articles

TypeScript Type Guards and Narrowing: Writing Type-Safe Conditionals

Master Discriminated Unions, Type Predicates, and Control Flow Analysis

September 15, 2021
Updated March 8, 2026
TypeScript type guards and control flow narrowing visualization
typescript
frontend
web development
tutorial
10 min read
note

Updated March 2026: Originally published in 2021 and updated with modern TypeScript narrowing patterns and a new banner image. I cannot thank Google Gemini’s image creation enough LOL.

Previous in the series: TypeScript Strict Mode: Best Practices for Production Code

So here’s a frustrating moment from experience. You have a value that could be a string or a number. You write a check for typeof value === "string". Then TypeScript should remember that inside that if block, it’s definitely a string.

And it does. But that same behavior works for way more scenarios than most developers realize, I believe. Unions, APIs, discriminated types, custom validation…

Once TypeScript understands your intent, it narrows the type everywhere it matters in that scope. No manual assertions. No unsafe casts. Just pure type safety.

That’s type narrowing. And it’s where TypeScript gets genuinely smart.

Part 1: The Basics of Type Narrowing

typeof Narrowing

The most common type guard. Works with primitives: string, number, boolean, symbol, bigint, object, function, and undefined.

function process(value: string | number) {
  if (typeof value === "string") {
    // value is narrowed to string
    return value.toUpperCase();
  } else {
    // value is narrowed to number
    return value.toFixed(2);
  }
}

// Truthiness checks also narrow
function printLength(str: string | null) {
  if (str) {
    console.log(str.length); // str is narrowed to string
  } else {
    console.log("No string provided");
  }
}

// Even with logical operators
function combine(x: string | number, y: string | boolean) {
  if (typeof x === "string" && typeof y === "string") {
    return x + y; // Both are strings, safe to concatenate
  } else {
    return x.toString() + y.toString(); // Safe because we narrowed
  }
}

instanceof Narrowing

Use for class instances and built-in objects.

class User {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  greet() {
    return `Hello, ${this.name}`;
  }
}

class Admin extends User {
  permissions: string[];
  constructor(name: string, permissions: string[]) {
    super(name);
    this.permissions = permissions;
  }
}

function processAccount(account: User | Admin) {
  if (account instanceof Admin) {
    // account is narrowed to Admin
    console.log(account.permissions);
  } else {
    // account is narrowed to User
    console.log(account.greet());
  }
}

// Works with built-ins too
function handleValue(value: Date | string) {
  if (value instanceof Date) {
    console.log(value.toISOString());
  } else {
    console.log(value.toUpperCase());
  }
}

Part 2: Discriminated Unions (Tagged Unions)

This is where type narrowing becomes powerful. A discriminated union is a pattern you create using utility types and conditional types from Part 1 of this series. It has a shared property (the “discriminator”) that TypeScript uses to narrow the type.

Basic Discriminated Union

interface SuccessResponse {
  type: "success"; // discriminator
  data: unknown;
}

interface ErrorResponse {
  type: "error"; // discriminator
  error: string;
}

interface PendingResponse {
  type: "pending"; // discriminator
  progress: number;
}

type ApiResponse = SuccessResponse | ErrorResponse | PendingResponse;

function handleResponse(response: ApiResponse) {
  switch (response.type) {
    case "success":
      // TypeScript narrows to SuccessResponse
      console.log("Data:", response.data);
      break;
    case "error":
      // TypeScript narrows to ErrorResponse
      console.log("Error:", response.error);
      break;
    case "pending":
      // TypeScript narrows to PendingResponse
      console.log("Progress:", response.progress);
      break;
  }
}

Real-World Example: Result Types

interface Success<T> {
  ok: true;
  value: T;
}

interface Failure {
  ok: false;
  error: Error | string;
}

type Result<T> = Success<T> | Failure;

// Factory functions
function ok<T>(value: T): Result<T> {
  return { ok: true, value };
}

function fail(error: Error | string): Result<never> {
  return { ok: false, error };
}

// Usage with perfect narrowing
const result: Result<User> = fetchUser();

if (result.ok) {
  // result.value is User
  console.log(result.value.name);
} else {
  // result.error is Error | string
  console.log("Failed:", result.error);
}

// Or use a pattern match helper
function match<T, U, V>(
  result: Result<T>,
  onSuccess: (value: T) => U,
  onFailure: (error: Error | string) => V
): U | V {
  return result.ok ? onSuccess(result.value) : onFailure(result.error);
}

const message = match(
  result,
  user => `Hello, ${user.name}`,
  error => `Error: ${error}`
);

Literal Types as Discriminators

type LogLevel = "debug" | "info" | "warn" | "error";

interface DebugLog {
  level: "debug";
  message: string;
}

interface ErrorLog {
  level: "error";
  message: string;
  stacktrace: string;
}

interface InfoLog {
  level: "info";
  message: string;
  timestamp: number;
}

type Log = DebugLog | ErrorLog | InfoLog;

function formatLog(log: Log): string {
  switch (log.level) {
    case "debug":
      return `[DEBUG] ${log.message}`;
    case "error":
      return `[ERROR] ${log.message}\n${log.stacktrace}`;
    case "info":
      return `[INFO] ${new Date(log.timestamp).toISOString()} ${log.message}`;
  }
}

Part 3: Custom Type Guards (Type Predicates)

Sometimes TypeScript’s built-in narrowing isn’t enough. You can write custom guards with is keyword.

Basic Type Predicate

interface User {
  name: string;
  email: string;
}

// Custom type guard
function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "name" in value &&
    "email" in value &&
    typeof (value as any).name === "string" &&
    typeof (value as any).email === "string"
  );
}

// Usage
const data: unknown = { name: "Alice", email: "alice@ex.com" };

if (isUser(data)) {
  // data is narrowed to User
  console.log(data.name, data.email);
}

// Works with arrays too
const mixed: unknown[] = [1, "hello", { name: "Bob" }];
const users: User[] = mixed.filter(isUser); // Typed as User[]

Advanced Type Predicates

// Generic type guard
function isNotNull<T>(value: T | null | undefined): value is T {
  return value !== null && value !== undefined;
}

function process(items: (string | null)[]) {
  const valid: string[] = items.filter(isNotNull); // Works!
}

// Array element guard — validates both the array and each element's type
function isStringArray(value: unknown): value is string[] {
  return Array.isArray(value) && value.every(item => typeof item === "string");
}

function processArray(value: unknown) {
  if (isStringArray(value)) {
    // value is string[] — both array and element types verified
    value.forEach(item => console.log(item.toUpperCase()));
  }
}

// Async type guard (returns Promise<boolean>)
async function isValidUser(id: number): Promise<boolean> {
  const user = await database.findUser(id);
  return user !== null;
}

// Note: Can't use in type predicates directly, but useful for validation
if (await isValidUser(123)) {
  // proceed
}

Real-World: API Validation Guards

interface ApiUser {
  id: number;
  name: string;
  email: string;
  role: "user" | "admin";
}

function isApiUser(value: unknown): value is ApiUser {
  if (!value || typeof value !== "object") return false;
  
  const obj = value as Record<string, unknown>;
  
  return (
    typeof obj.id === "number" &&
    typeof obj.name === "string" &&
    obj.name.length > 0 &&
    typeof obj.email === "string" &&
    obj.email.includes("@") &&
    (obj.role === "user" || obj.role === "admin")
  );
}

// Use in API response handling
async function fetchUser(id: number) {
  const response = await fetch(`/api/users/${id}`);
  const json = await response.json();
  
  if (isApiUser(json)) {
    // json is safely typed as ApiUser
    return json;
  } else {
    throw new Error("Invalid user data from API");
  }
}

Part 4: Control Flow Analysis

TypeScript tracks the type as it flows through your code, not just in branches.

Exhaustiveness Checking

type Status = "pending" | "success" | "error";

// Missing a case - TypeScript complains:
// error TS7030: Not all code paths return a value
function getStatusMessage(status: Status): string {
  switch (status) {
    case "pending":
      return "Loading...";
    case "success":
      return "Done!";
    // "error" case is missing!
  }
}

// Use the never exhaustiveness pattern to future-proof:
function getStatusMessageSafe(status: Status): string {
  switch (status) {
    case "pending":
      return "Loading...";
    case "success":
      return "Done!";
    case "error":
      return "Failed!";
    default:
      const _exhaustive: never = status;
      throw new Error(`Unhandled status: ${_exhaustive}`);
  }
}

// Now if you add a new status type later:
type StatusV2 = "pending" | "success" | "error" | "cancelled"; // new

function getStatusMessageV2(status: StatusV2): string {
  switch (status) {
    case "pending":
      return "Loading...";
    case "success":
      return "Done!";
    case "error":
      return "Failed!";
    // Missing: case "cancelled"
    default:
      // Error! Type '"cancelled"' is not assignable to type 'never'
      // TypeScript forces you to handle 'cancelled'
      const _exhaustive: never = status;
      throw new Error(`Unhandled status: ${_exhaustive}`);
  }
}

Narrowing Through Never

function handleValue(value: string | number | boolean) {
  if (typeof value === "string") {
    // string
    value.toUpperCase();
  } else if (typeof value === "number") {
    // number
    value.toFixed();
  } else {
    // boolean
    value.toString();
  }
}

// With all cases covered, the else branch is typed as never:
function handleValue(value: string | number) {
  if (typeof value === "string") {
    value.toUpperCase();
  } else if (typeof value === "number") {
    value.toFixed();
  } else {
    // This compiles fine because value is already 'never' here
    // The else branch is dead code since all cases are covered
    const _never: never = value;
  }
}

Part 5: Advanced Narrowing Techniques

Assertion Functions

function assertIsUser(value: unknown): asserts value is User {
  if (!isUser(value)) {
    throw new Error("Not a user");
  }
}

const data: unknown = { name: "Alice", email: "alice@ex.com" };
assertIsUser(data);
console.log(data.name); // TypeScript knows it's User here

Conditional Narrowing

function process<T extends string | number>(value: T) {
  if (typeof value === "string") {
    // T is narrowed to string
    value.toUpperCase();
  } else {
    // T is narrowed to number
    value.toFixed();
  }
}

Optional Chaining & Nullish Coalescing

These operators are designed to work seamlessly with strict mode’s strictNullChecks. When enabled, they help you write safe code when dealing with potentially null or undefined values.

interface User {
  name: string;
  address?: {
    street?: string;
  };
}

const user: User = { name: "Alice" };

// Optional chaining narrows
const street = user.address?.street; // string | undefined

// Nullish coalescing provides a default
const defaultStreet = user.address?.street ?? "Unknown";

// Combined: safe navigation with defaults
const displayStreet = user?.address?.street?.toUpperCase() ?? "N/A";

Part 6: Patterns for Production Code

The Result Pattern

tip

.. from the trenches.

I’ve, sort of, standardized on this Result pattern for all async operations. It has become as automatic as error handling. You write it once, and every async function becomes predictable. Success or failure, it is always typed. No surprise exceptions at runtime.

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

async function getUserWithResult(id: number): Promise<Result<User>> {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      return { ok: false, error: `HTTP ${response.status}` };
    }
    const data = await response.json();
    if (isUser(data)) {
      return { ok: true, value: data };
    }
    return { ok: false, error: "Invalid user format" };
  } catch (err) {
    return { ok: false, error: err instanceof Error ? err.message : "Unknown error" };
  }
}

// Usage
const result = await getUserWithResult(123);
if (result.ok) {
  console.log("User:", result.value.name);
} else {
  console.error("Failed:", result.error);
}

The Maybe Pattern

type Maybe<T> = T | null | undefined;

function isSome<T>(value: Maybe<T>): value is T {
  return value !== null && value !== undefined;
}

function isNone<T>(value: Maybe<T>): value is null | undefined {
  return value === null || value === undefined;
}

// Usage
const values: Maybe<string>[] = ["hello", null, "world", undefined];
const validValues: string[] = values.filter(isSome);

Common Narrowing Patterns

// Pattern 1: Check before using
const value: string | null = getValue();
if (value) {
  console.log(value.length);
}

// Pattern 2: Early return
function processUser(user: User | null) {
  if (!user) return;
  console.log(user.name);
}

// Pattern 3: Logical AND
const user: User | null = getUser();
user && console.log(user.name);

// Pattern 4: Truthiness with double negative
if (!!user) {
  console.log(user.name);
}

Wrapping up

  1. Type narrowing is automatic - TypeScript infers based on your checks
  2. Discriminated unions are powerful - Use them for APIs and complex types
  3. Write custom type guards with is for complex validation
  4. Use exhaustiveness checking to ensure all cases are handled
  5. Combine patterns - discriminated unions + type guards + control flow analysis = dramatically more robust code

The magic here is that you write the check once, and TypeScript remembers it for the entire scope. No manual casts, no as escapes, no unsafe territory. Once you internalize how control flow analysis works, you’ll start writing narrowing checks naturally. Not because you have to, but because they make your code clearer.

Use discriminated unions for APIs and state machines. Use type guards for validation and runtime safety. Combine them. Chain them. Build layers. The more patterns you layer together, the harder you make it to ship bugs.

I’ll probably wrap up this series in the next part about Declaration Merging and Module Augmentation. Some so-called advanced techniques for extending types from libraries and making your code more flexible without breaking encapsulation


Previous in the series: TypeScript Strict Mode: Best Practices for Production Code

Next in the series: TypeScript Declaration Merging and Module Augmentation

See ya there! 👋

Continue Reading

Discover more insights and stories that you might be interested in.