Skip to main content

Advanced Patterns

Union Types & Narrowing

A union type means a value can be one of several types. Narrowing is how you tell TypeScript which specific type you're dealing with:

function formatValue(value: string | number): string {
  // TypeScript doesn't know the type yet — you can't call .toFixed() or .toUpperCase()

  if (typeof value === "string") {
    // Narrowed to string
    return value.toUpperCase();
  }

  // Narrowed to number (the only remaining option)
  return value.toFixed(2);
}

formatValue("hello"); // "HELLO"
formatValue(3.14);    // "3.14"

Discriminated Unions

A discriminated union uses a shared literal property (the discriminant) to distinguish between variants. This is one of the most powerful patterns in TypeScript:

interface LoadingState {
  status: "loading";
}

interface SuccessState {
  status: "success";
  data: string[];
}

interface ErrorState {
  status: "error";
  message: string;
}

type RequestState = LoadingState | SuccessState | ErrorState;

function renderState(state: RequestState): string {
  switch (state.status) {
    case "loading":
      return "Loading...";
    case "success":
      // TypeScript knows state.data exists here
      return `Got ${state.data.length} items`;
    case "error":
      // TypeScript knows state.message exists here
      return `Error: ${state.message}`;
  }
}

This pattern replaces a lot of if/else chains and keeps your code type-safe at every branch.

Custom Type Guards

A type guard is a function that tells TypeScript what type a value is:

interface Fish {
  swim: () => void;
}

interface Bird {
  fly: () => void;
}

// Type predicate: `pet is Fish` narrows the type in the calling scope
function isFish(pet: Fish | Bird): pet is Fish {
  return "swim" in pet;
}

function move(pet: Fish | Bird) {
  if (isFish(pet)) {
    pet.swim(); // TypeScript knows this is Fish
  } else {
    pet.fly(); // TypeScript knows this is Bird
  }
}

The in Operator for Narrowing

interface Admin {
  role: "admin";
  permissions: string[];
}

interface Guest {
  role: "guest";
  expiresAt: Date;
}

type AppUser = Admin | Guest;

function describe(user: AppUser): string {
  if ("permissions" in user) {
    // Narrowed to Admin
    return `Admin with ${user.permissions.length} permissions`;
  }
  // Narrowed to Guest
  return `Guest access until ${user.expiresAt.toLocaleDateString()}`;
}

Exhaustive Checks with never

The never type ensures you handle every variant of a union. If you add a new variant later, TypeScript will tell you about every switch you forgot to update:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
    default:
      // If you miss a case, this line will have a compile error
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

Conditional Types

Conditional types choose a type based on a condition, similar to a ternary expression:

// Basic syntax: T extends U ? X : Y
type IsString<T> = T extends string ? "yes" : "no";

type A = IsString<string>;  // "yes"
type B = IsString<number>;  // "no"

// Practical example: extract the return type of a function
type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;

type FnReturn = ReturnOf<()=> string>;       // string
type FnReturn2 = ReturnOf<(x: number)=> boolean>; // boolean

Template Literal Types

Build string types from other types:

type EventName = "click" | "focus" | "blur";
type Handler = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"

// Practical: CSS property helper
type CSSUnit = "px" | "rem" | "em" | "%";
type CSSValue = `${number}${CSSUnit}`;

const width: CSSValue = "100px";   // OK
const height: CSSValue = "2.5rem"; // OK
// const bad: CSSValue = "100";    // Error

Mapped Types

Create new types by transforming properties of existing types:

// Make every property nullable
type Nullable<T> = {
  [K in keyof T]: T[K] | null;
};

interface User {
  name: string;
  age: number;
}

type NullableUser = Nullable<User>;
// { name: string | null; age: number | null }

// Make every property a getter function
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number }