Contents

Chapter 1

Advanced TypeScript Type Patterns

A practical guide to the type-level patterns used throughout this library. Each section explains when to use the pattern, how it works, and provides copy-paste examples.

Table of Contents

1. Branded Types (Nominal Typing)

2. Discriminated Unions

3. Type Guards & Narrowing

4. Generic Constraints

5. Conditional Types

6. Template Literal Types

7. Mapped Types

8. Utility Type Recipes

9. Common Pitfalls

10. Decision Tree: Which Pattern to Use


Branded Types

Problem: TypeScript uses structural typing, so string is string regardless of what it represents. You can accidentally pass a userId where an orderId is expected.

Solution: Add a phantom property that exists only in the type system.

typescript
// Define the brand
declare const __brand: unique symbol;
type Brand<Base, Tag extends string> = Base & { readonly [__brand]: Tag };

// Create domain-specific types
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

// This prevents mixing them up:
function getUser(id: UserId): void { /* ... */ }

const orderId = "ord_123" as OrderId;
getUser(orderId); // Compile error!

const userId = "usr_456" as UserId;
getUser(userId); // Works!

When to use: Domain IDs, validated strings (emails, URLs), constrained numbers (percentages, positive integers).

When NOT to use: Simple strings that don't represent distinct concepts. Don't over-brand — it adds ceremony without benefit if you're just passing generic strings around.

See: src/branded.ts for ready-to-use branded types and validators.


Discriminated Unions

Problem: You have multiple related types and need to handle each case differently.

Solution: Add a literal type or _tag property that TypeScript can use for narrowing.

typescript
// The _tag field is the discriminant
type Shape =
  | { _tag: "circle"; radius: number }
  | { _tag: "rectangle"; width: number; height: number }
  | { _tag: "triangle"; base: number; height: number };

function area(shape: Shape): number {
  switch (shape._tag) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height; // TypeScript knows width/height exist
    case "triangle":
      return (shape.base * shape.height) / 2;
  }
}

Exhaustiveness checking: TypeScript ensures you handle all cases:

typescript
function describe(shape: Shape): string {
  switch (shape._tag) {
    case "circle":
      return "round";
    case "rectangle":
      return "boxy";
    // Forgot "triangle"!
    default:
      // This line causes a compile error because `shape` is `Triangle`, not `never`
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

See: src/result.ts uses this pattern for Result (Ok | Err) and Option (Some | None).


Type Guards & Narrowing

Problem: You receive unknown data (API response, user input) and need to safely access typed properties.

Solution: Write functions with value is Type return types.

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

// Structural guard (validates object shape)
interface User {
  id: number;
  name: string;
  email: string;
}

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    typeof (value as Record<string, unknown>).id === "number" &&
    typeof (value as Record<string, unknown>).name === "string" &&
    typeof (value as Record<string, unknown>).email === "string"
  );
}

// Usage: TypeScript narrows the type inside the if-block
function processInput(data: unknown) {
  if (isUser(data)) {
    console.log(data.name); // TypeScript knows data is User
  }
}

Composable guards (from src/guards.ts):

typescript
import { hasShape, isString, isNumber, isArrayOf } from "./src/guards";

// Build complex guards from simple ones
const isUser = (v: unknown): v is User =>
  hasShape(v, {
    id: isNumber,
    name: isString,
    email: isString,
  });

// Guard for arrays of a specific type
const isUserList = (v: unknown): v is User[] => isArrayOf(v, isUser);

See: src/guards.ts for a complete set of primitive, structural, and composable guards.


Generic Constraints

Problem: You want a function to work with many types, but not *all* types — only those that meet certain criteria.

Solution: Use extends to constrain type parameters.

typescript
// Only accepts objects with an `id` property
function getById<T extends { id: string }>(items: T[], id: string): T | undefined {
  return items.find((item) => item.id === id);
}

// Only accepts string keys (not symbols or numbers)
function pick<T extends Record<string, unknown>, K extends keyof T>(
  obj: T,
  ...keys: K[]
): Pick<T, K> {
  // ...
}

// Constraining to specific shapes
function merge<
  T extends Record<string, unknown>,
  U extends Record<string, unknown>,
>(a: T, b: U): T & U {
  return { ...a, ...b };
}

Key insight: extends in generics means "must be assignable to", not "must inherit from". Think of it as a constraint, not inheritance.


Conditional Types

Problem: A type should change based on the input type.

Solution: Use T extends X ? Y : Z syntax.

typescript
// Extract the element type from an array, or keep the type as-is
type Unpack<T> = T extends (infer U)[] ? U : T;

type A = Unpack<string[]>; // string
type B = Unpack<number>;   // number

// Remove null/undefined from a type
type NonNullable<T> = T extends null | undefined ? never : T;

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

type C = ReturnOf<() => string>; // string
type D = ReturnOf<(x: number) => boolean>; // boolean

The infer keyword lets you "capture" a type variable inside the conditional:

typescript
// Extract the resolved type from a Promise
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;

type E = Awaited<Promise<Promise<string>>>; // string (recursively unwraps)

Template Literal Types

Problem: You want to type-check string patterns at compile time.

Solution: Use TypeScript's template literal type syntax.

typescript
// Event name pattern
type EventName = `${string}:${string}`;

function on(event: EventName, handler: Function): void { /* ... */ }

on("user:login", handler);  // OK
on("click", handler);       // Error: doesn't match pattern

// Generate types from unions
type Color = "red" | "blue" | "green";
type Shade = "light" | "dark";
type ColorVariant = `${Shade}-${Color}`;
// "light-red" | "light-blue" | "light-green" | "dark-red" | "dark-blue" | "dark-green"

// CSS-like units
type CSSLength = `${number}${"px" | "em" | "rem" | "%"}`;
const valid: CSSLength = "16px";    // OK
const invalid: CSSLength = "16pt";  // Error

Mapped Types

Problem: You want to create a new type by transforming every property of an existing type.

Solution: Use { [K in keyof T]: ... } syntax.

typescript
// Make all properties optional
type Partial<T> = { [K in keyof T]?: T[K] };

// Make all properties readonly
type Readonly<T> = { readonly [K in keyof T]: T[K] };

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

// Transform property types
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

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

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

Key modifier: Use + and - to add or remove modifiers:

typescript
// Remove readonly from all properties
type Mutable<T> = { -readonly [K in keyof T]: T[K] };

// Remove optional from all properties
type Required<T> = { [K in keyof T]-?: T[K] };

Utility Type Recipes

DeepPartial — Make all nested properties optional

typescript
type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T;

DeepReadonly — Prevent mutation at any depth

typescript
type DeepReadonly<T> = T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

RequireAtLeastOne — At least one property must be set

typescript
type RequireAtLeastOne<T> = {
  [K in keyof T]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<keyof T, K>>>;
}[keyof T];

// Usage
interface Filters {
  name?: string;
  email?: string;
  role?: string;
}

type ValidFilter = RequireAtLeastOne<Filters>;
// Must provide at least one of name, email, or role

StrictOmit — Omit that errors on invalid keys

typescript
// Built-in Omit doesn't error if you omit a key that doesn't exist
type StrictOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

type User = { name: string; email: string; age: number };
type A = StrictOmit<User, "name">;     // OK
type B = StrictOmit<User, "invalid">;  // Error! 'invalid' not in keyof User

PathsOf — All valid dot-notation paths through an object

typescript
type PathsOf<T, Prefix extends string = ""> = T extends Record<string, unknown>
  ? {
      [K in keyof T & string]: T[K] extends Record<string, unknown>
        ? PathsOf<T[K], `${Prefix}${K}.`>
        : `${Prefix}${K}`;
    }[keyof T & string]
  : never;

interface Config {
  server: { host: string; port: number };
  database: { url: string };
}

type ConfigPaths = PathsOf<Config>;
// "server.host" | "server.port" | "database.url"

Common Pitfalls

Pitfall 1: Object.keys() returns string[], not (keyof T)[]

typescript
const obj = { a: 1, b: 2, c: 3 };

// WRONG: TypeScript infers key as string
Object.keys(obj).forEach((key) => {
  obj[key]; // Error: no index signature
});

// CORRECT: Cast the keys
(Object.keys(obj) as (keyof typeof obj)[]).forEach((key) => {
  obj[key]; // Works!
});

// BETTER: Use Object.entries which gives you both key and value
Object.entries(obj).forEach(([key, value]) => {
  console.log(key, value); // key is string, value is number
});

Pitfall 2: Type widening in arrays

typescript
// WRONG: TypeScript infers string[], not the literal union
const statuses = ["active", "inactive", "pending"];
// Type: string[]

// CORRECT: Use as const
const statuses = ["active", "inactive", "pending"] as const;
// Type: readonly ["active", "inactive", "pending"]
// Element type: "active" | "inactive" | "pending"

Pitfall 3: Excess property checking only works on object literals

typescript
interface Config { host: string; port: number }

// Object literal — excess properties caught
const config: Config = { host: "localhost", port: 3000, extra: true }; // Error!

// Variable assignment — excess properties NOT caught
const raw = { host: "localhost", port: 3000, extra: true };
const config: Config = raw; // No error! TypeScript allows this.

Pitfall 4: unknown vs any

typescript
// any: disables all type checking (dangerous)
const x: any = fetchData();
x.nonexistent.method(); // No error, crashes at runtime

// unknown: forces you to check before using (safe)
const y: unknown = fetchData();
y.nonexistent; // Error! Must narrow first

if (typeof y === "object" && y !== null && "name" in y) {
  y.name; // OK, TypeScript narrowed the type
}

Decision Tree

Use this to pick the right pattern for your situation:

Is the problem about...

├── Preventing mix-ups between same-shaped types?
│   └── Use BRANDED TYPES (src/branded.ts)
│
├── Handling multiple related variants?
│   └── Use DISCRIMINATED UNIONS (Result, Option, or custom)
│
├── Validating data from external sources?
│   ├── At compile time? → Use GENERIC CONSTRAINTS
│   └── At runtime? → Use TYPE GUARDS (src/guards.ts)
│
├── Making errors explicit in function signatures?
│   └── Use RESULT TYPE (src/result.ts)
│
├── Representing optional values?
│   ├── Simple null check? → Just use T | null
│   └── Need chaining/mapping? → Use OPTION TYPE (src/result.ts)
│
├── Transforming object types?
│   ├── All properties? → Use MAPPED TYPES
│   └── Based on conditions? → Use CONDITIONAL TYPES
│
├── Type-safe string patterns?
│   └── Use TEMPLATE LITERAL TYPES
│
└── Complex object construction?
    └── Use BUILDER PATTERN (src/patterns/index.ts)

Further Reading


Part of Frontend Developer Pro

TypeScript Utility Library v1.0.0 — Free Preview