Skip to content

Type Guards & Narrowing#

Type guards allow you to narrow down types within conditional blocks, making your code safer and more precise.

typeof Type Guards#

The most basic type guard uses JavaScript's typeof operator:

function processValue(value: string | number): string {
  if (typeof value === "string") {
    // TypeScript knows value is string here
    return value.toUpperCase();
  } else {
    // TypeScript knows value is number here
    return value.toFixed(2);
  }
}

// Multiple type checks
function formatValue(value: string | number | boolean): string {
  if (typeof value === "string") {
    return `"${value}"`;
  } else if (typeof value === "number") {
    return value.toString();
  } else {
    return value ? "true" : "false";
  }
}

instanceof Type Guards#

Check if an object is an instance of a class:

class Dog {
  bark(): void {
    console.log("Woof!");
  }
}

class Cat {
  meow(): void {
    console.log("Meow!");
  }
}

function makeSound(animal: Dog | Cat): void {
  if (animal instanceof Dog) {
    animal.bark(); // TypeScript knows it's a Dog
  } else {
    animal.meow(); // TypeScript knows it's a Cat
  }
}

// With Error handling
function handleError(error: unknown): string {
  if (error instanceof Error) {
    return error.message;
  }
  return String(error);
}

in Operator Narrowing#

Check if a property exists in an object:

interface Bird {
  fly(): void;
  layEggs(): void;
}

interface Fish {
  swim(): void;
  layEggs(): void;
}

function move(animal: Bird | Fish): void {
  if ("fly" in animal) {
    animal.fly(); // TypeScript knows it's a Bird
  } else {
    animal.swim(); // TypeScript knows it's a Fish
  }
}

// Multiple checks
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; size: number };
type Rectangle = { kind: "rectangle"; width: number; height: number };

type Shape = Circle | Square | Rectangle;

function getArea(shape: Shape): number {
  if ("radius" in shape) {
    return Math.PI * shape.radius ** 2;
  } else if ("size" in shape) {
    return shape.size ** 2;
  } else {
    return shape.width * shape.height;
  }
}

Equality Narrowing#

Use strict equality to narrow types:

function processInput(input: string | null | undefined): string {
  if (input === null) {
    return "Input is null";
  }
  if (input === undefined) {
    return "Input is undefined";
  }
  // TypeScript knows input is string here
  return input.toUpperCase();
}

// Narrowing with specific values
type Status = "pending" | "approved" | "rejected";

function getStatusMessage(status: Status): string {
  if (status === "pending") {
    return "Waiting for approval";
  } else if (status === "approved") {
    return "Request approved";
  } else {
    return "Request rejected";
  }
}

Truthiness Narrowing#

TypeScript narrows types based on truthiness checks:

function printLength(str: string | null | undefined): void {
  if (str) {
    // str is string here (not null or undefined)
    console.log(str.length);
  } else {
    console.log("No string provided");
  }
}

// Be careful with falsy values!
function processNumber(num: number | null): void {
  if (num) {
    console.log(num * 2);
  } else {
    // This includes 0, which might not be intended!
    console.log("No number");
  }
}

// Better: explicit null check
function processNumberSafe(num: number | null): void {
  if (num !== null) {
    console.log(num * 2); // Works with 0 too
  } else {
    console.log("No number");
  }
}

Type Predicates (Custom Type Guards)#

Create your own type guard functions:

// Basic type predicate
function isString(value: unknown): value is string {
  return typeof value === "string";
}

function processValue(value: unknown): void {
  if (isString(value)) {
    console.log(value.toUpperCase()); // TypeScript knows it's a string
  }
}

// Object type guards
interface User {
  id: number;
  name: string;
  email: string;
}

function isUser(obj: any): obj is User {
  return (
    obj &&
    typeof obj === "object" &&
    typeof obj.id === "number" &&
    typeof obj.name === "string" &&
    typeof obj.email === "string"
  );
}

function greetUser(input: unknown): string {
  if (isUser(input)) {
    return `Hello, ${input.name}!`; // TypeScript knows it's a User
  }
  return "Hello, stranger!";
}

// Array type guard
function isStringArray(value: unknown): value is string[] {
  return Array.isArray(value) && value.every(item => typeof item === "string");
}

function processArray(arr: unknown): void {
  if (isStringArray(arr)) {
    arr.forEach(str => console.log(str.toUpperCase()));
  }
}

Discriminated Unions#

Use a common property to discriminate between types:

// Tagged union pattern
type Success = {
  type: "success";
  data: any;
};

type Error = {
  type: "error";
  message: string;
};

type Result = Success | Error;

function handleResult(result: Result): void {
  if (result.type === "success") {
    console.log("Data:", result.data); // TypeScript knows it's Success
  } else {
    console.error("Error:", result.message); // TypeScript knows it's Error
  }
}

// More complex discriminated union
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; size: number }
  | { kind: "rectangle"; width: number; height: number };

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.size ** 2;
    case "rectangle":
      return shape.width * shape.height;
  }
}

// Exhaustiveness checking
type Action =
  | { type: "INCREMENT" }
  | { type: "DECREMENT" }
  | { type: "RESET"; value: number };

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case "INCREMENT":
      return state + 1;
    case "DECREMENT":
      return state - 1;
    case "RESET":
      return action.value;
    default:
      // This will error if we add a new action type and forget to handle it
      const exhaustiveCheck: never = action;
      return exhaustiveCheck;
  }
}

Control Flow Analysis#

TypeScript tracks types through control flow:

function processUser(user: User | null): void {
  if (!user) {
    return; // Early return
  }

  // TypeScript knows user is not null here
  console.log(user.name);
  console.log(user.email);
}

// With multiple conditions
function validateUser(user: User | null | undefined): boolean {
  if (user === null) {
    console.log("User is null");
    return false;
  }

  if (user === undefined) {
    console.log("User is undefined");
    return false;
  }

  // user is User here
  return user.email.includes("@");
}

// Assignment narrowing
function getLength(value: string | null): number {
  let result: string;

  if (value === null) {
    result = "default";
  } else {
    result = value; // value is narrowed to string
  }

  return result.length; // result is always string
}

Assertion Functions#

Functions that throw when a condition isn't met:

function assert(condition: any, message?: string): asserts condition {
  if (!condition) {
    throw new Error(message || "Assertion failed");
  }
}

function processValue(value: string | null): void {
  assert(value !== null, "Value must not be null");

  // TypeScript knows value is string here
  console.log(value.toUpperCase());
}

// Type assertion
function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== "string") {
    throw new Error("Value must be a string");
  }
}

function processInput(input: unknown): void {
  assertIsString(input);

  // TypeScript knows input is string here
  console.log(input.toUpperCase());
}

// Object assertion
function assertIsUser(obj: any): asserts obj is User {
  if (!obj || typeof obj !== "object") {
    throw new Error("Not an object");
  }
  if (typeof obj.id !== "number") {
    throw new Error("Missing id");
  }
  if (typeof obj.name !== "string") {
    throw new Error("Missing name");
  }
}

Nullish Coalescing and Optional Chaining#

Modern JavaScript features that work well with type narrowing:

interface User {
  id: number;
  name: string;
  address?: {
    street: string;
    city: string;
  };
}

// Optional chaining
function getCity(user: User | null): string | undefined {
  return user?.address?.city;
}

// Nullish coalescing
function getDisplayName(user: User | null): string {
  return user?.name ?? "Guest";
}

// Combined
function formatAddress(user: User | null): string {
  const city = user?.address?.city ?? "Unknown City";
  const street = user?.address?.street ?? "Unknown Street";
  return `${street}, ${city}`;
}

// Non-null assertion (use sparingly!)
function getUserName(user: User | null): string {
  // Only use ! when you're absolutely certain
  return user!.name; // Asserts user is not null
}

Practical Examples#

API Response Handler#

type ApiSuccess<T> = {
  success: true;
  data: T;
};

type ApiError = {
  success: false;
  error: string;
  code: number;
};

type ApiResponse<T> = ApiSuccess<T> | ApiError;

function isApiSuccess<T>(response: ApiResponse<T>): response is ApiSuccess<T> {
  return response.success === true;
}

async function fetchUser(id: string): Promise<User | null> {
  const response: ApiResponse<User> = await fetch(`/api/users/${id}`)
    .then(r => r.json());

  if (isApiSuccess(response)) {
    return response.data; // Type: User
  } else {
    console.error(`Error ${response.code}: ${response.error}`);
    return null;
  }
}

Form Validation#

interface FormField<T> {
  value: T;
  error: string | null;
}

function isValidEmail(email: string): boolean {
  return email.includes("@") && email.includes(".");
}

function validateField<T>(
  field: FormField<T>,
  validator: (value: T) => boolean,
  errorMessage: string
): FormField<T> {
  if (validator(field.value)) {
    return { ...field, error: null };
  }
  return { ...field, error: errorMessage };
}

// Usage
const emailField: FormField<string> = {
  value: "user@example.com",
  error: null
};

const validatedEmail = validateField(
  emailField,
  isValidEmail,
  "Invalid email address"
);

if (validatedEmail.error === null) {
  // Email is valid
  console.log(`Valid email: ${validatedEmail.value}`);
}

Event Handler Type Guard#

function isMouseEvent(event: Event): event is MouseEvent {
  return event instanceof MouseEvent;
}

function isKeyboardEvent(event: Event): event is KeyboardEvent {
  return event instanceof KeyboardEvent;
}

function handleEvent(event: Event): void {
  if (isMouseEvent(event)) {
    console.log(`Mouse: ${event.clientX}, ${event.clientY}`);
  } else if (isKeyboardEvent(event)) {
    console.log(`Key pressed: ${event.key}`);
  } else {
    console.log("Other event");
  }
}

document.addEventListener("click", handleEvent);
document.addEventListener("keydown", handleEvent);

Safe JSON Parsing#

function isObject(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function hasProperty<K extends string>(
  obj: unknown,
  key: K
): obj is Record<K, unknown> {
  return isObject(obj) && key in obj;
}

function parseUser(json: string): User | null {
  try {
    const data: unknown = JSON.parse(json);

    if (!isObject(data)) {
      return null;
    }

    if (!hasProperty(data, "id") || typeof data.id !== "number") {
      return null;
    }

    if (!hasProperty(data, "name") || typeof data.name !== "string") {
      return null;
    }

    if (!hasProperty(data, "email") || typeof data.email !== "string") {
      return null;
    }

    return data as User;
  } catch {
    return null;
  }
}

State Machine#

type IdleState = { status: "idle" };
type LoadingState = { status: "loading" };
type SuccessState<T> = { status: "success"; data: T };
type ErrorState = { status: "error"; error: string };

type AsyncState<T> = IdleState | LoadingState | SuccessState<T> | ErrorState;

function isSuccessState<T>(state: AsyncState<T>): state is SuccessState<T> {
  return state.status === "success";
}

function isErrorState<T>(state: AsyncState<T>): state is ErrorState {
  return state.status === "error";
}

function renderState<T>(state: AsyncState<T>): string {
  switch (state.status) {
    case "idle":
      return "Ready";
    case "loading":
      return "Loading...";
    case "success":
      return `Data: ${JSON.stringify(state.data)}`;
    case "error":
      return `Error: ${state.error}`;
  }
}

Best Practices#

  1. Use type predicates for reusable type guards

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

  2. Prefer discriminated unions for complex types

    type Result = 
      | { success: true; data: any }
      | { success: false; error: string };
    

  3. Use exhaustiveness checking in switches

    default:
      const exhaustive: never = action;
      throw new Error(`Unhandled action: ${exhaustive}`);
    

  4. Validate unknown data at runtime

    function parseData(json: string): Data | null {
      const data: unknown = JSON.parse(json);
      if (isValidData(data)) {
        return data;
      }
      return null;
    }
    

  5. Avoid excessive type assertions

    // ❌ Unsafe
    const user = data as User;
    
    // ✅ Better - validate first
    if (isUser(data)) {
      const user = data;
    }
    

Next Steps#