Skip to main content

You're building a real app. Not a tutorial. Not a toy project. A real application with real users, real data, and real consequences when things go wrong. The types are fighting you. The patterns that worked in isolation feel fragile at scale. You need techniques that survive contact with production.

This chapter is a collection of battle-tested TypeScript patterns. Each one solves a specific problem you'll encounter in production codebases. Each one is something I've used, broken, fixed, and refined over years of writing TypeScript in production.

Pattern 1: Branded Types​

The Problem​

You have types that are structurally identical but semantically different:

type UserId = number;
type ProductId = number;
type OrderId = number;

function getUser(id: UserId): User { /* ... */ }
function getProduct(id: ProductId): Product { /* ... */ }

const userId: UserId = 1;
const productId: ProductId = 2;

getUser(productId); // No error! Both are just numbers.

TypeScript's structural typing means UserId and ProductId are interchangeable. You can pass a ProductId to getUser() and the compiler won't complain. But at runtime, you'll get the wrong data (or worse, a subtle bug).

The Solution: Branded Types​

type Brand<T, Brand> = T & { __brand: Brand };

type UserId = Brand<number, "UserId">;
type ProductId = Brand<number, "ProductId">;
type OrderId = Brand<number, "OrderId">;

function getUser(id: UserId): User { /* ... */ }
function getProduct(id: ProductId): Product { /* ... */ }

const userId = 1 as UserId;
const productId = 2 as ProductId;

getUser(productId);
// ~~~~~~~~~
// Error: Type 'Brand<number, "ProductId">' is not assignable
// to type 'Brand<number, "UserId">'.

The __brand property is a phantom type — it exists only at compile time. At runtime, UserId is still just a number. But the compiler now distinguishes between different kinds of IDs.

Creating Branded Values​

You need a way to create branded values safely:

function createUserId(id: number): UserId {
if (id <= 0) throw new Error("Invalid user ID");
return id as UserId;
}

function createProductId(id: number): ProductId {
if (id <= 0) throw new Error("Invalid product ID");
return id as ProductId;
}

Or, for IDs coming from an API:

function parseUserId(id: unknown): UserId {
if (typeof id !== "number" || id <= 0) {
throw new Error(`Invalid user ID: ${id}`);
}
return id as UserId;
}

When to Use Branded Types​

  • IDs of different entity types
  • Strings with specific formats (email, URL, phone number)
  • Numbers with specific units (meters, seconds, pixels)
  • Any time two types are structurally identical but semantically different

Pattern 2: Opaque Types (Stronger Branding)​

Branded types prevent accidental mixing, but they don't prevent someone from creating a UserId from any number. Opaque types go further:

declare const OPAQUE: unique symbol;

type Opaque<T, K> = T & { [OPAQUE]: K };

type Email = Opaque<string, "Email">;
type Url = Opaque<string, "Url">;

// The ONLY way to create an Email is through this function
function parseEmail(value: string): Email | null {
if (!value.includes("@")) return null;
return value as Email;
}

// Now you can't accidentally create an Email from a raw string
const email: Email = "alice@example.com";
// ~~~~~
// Error: Type 'string' is not assignable to type 'Email'.

The unique symbol prevents anyone from creating the opaque type without going through your validation function. This is stronger than branding because there's no way to cast to the type without access to the symbol.

Pattern 3: Zod Integration for Runtime Validation​

TypeScript types don't exist at runtime. When you fetch data from an API, read a file, or parse user input, you need runtime validation. Zod is the standard solution:

import { z } from "zod";

// Define the schema (runtime validation)
const UserSchema = z.object({
id: z.number().positive(),
name: z.string().min(1),
email: z.string().email(),
age: z.number().min(0).max(150),
});

// Extract the TypeScript type (compile-time)
type User = z.infer<typeof UserSchema>;

// Validate at runtime
function fetchUser(id: number): Promise<User> {
return fetch(`/api/users/${id}`)
.then(res => res.json())
.then(data => UserSchema.parse(data)); // Throws if invalid
}

The Pattern: Schema → Type → Validate​

  1. Define the schema with Zod (or your validation library of choice)
  2. Extract the type with z.infer — single source of truth
  3. Validate at boundaries — API responses, form inputs, localStorage reads
// API boundary
async function apiCall<T>(url: string, schema: z.ZodSchema<T>): Promise<T> {
const response = await fetch(url);
const data = await response.json();
return schema.parse(data);
}

const user = await apiCall("/api/users/1", UserSchema);
// user is guaranteed to match UserSchema at runtime

Why This Pattern Wins​

  • Single source of truth. The schema IS the type. No duplication.
  • Runtime safety. TypeScript catches errors at compile time. Zod catches them at runtime. Together, they cover both.
  • Better error messages. Zod tells you exactly what was wrong with the data.
  • Automatic type generation. z.infer means you never write the type manually.

Pattern 4: Discriminated Union Reducers​

Redux-style reducers are the perfect use case for discriminated unions:

type State = {
users: User[];
loading: boolean;
error: Error | null;
filter: "all" | "active" | "inactive";
};

type Action =
| { type: "SET_USERS"; users: User[] }
| { type: "ADD_USER"; user: User }
| { type: "REMOVE_USER"; id: number }
| { type: "SET_LOADING"; loading: boolean }
| { type: "SET_ERROR"; error: Error | null }
| { type: "SET_FILTER"; filter: State["filter"] };

function reducer(state: State, action: Action): State {
return match (action) {
{ type: "SET_USERS", users } =>
{ ...state, users, loading: false, error: null },
{ type: "ADD_USER", user } =>
{ ...state, users: [...state.users, user] },
{ type: "REMOVE_USER", id } =>
{ ...state, users: state.users.filter(u => u.id !== id) },
{ type: "SET_LOADING", loading } =>
{ ...state, loading },
{ type: "SET_ERROR", error } =>
{ ...state, error, loading: false },
{ type: "SET_FILTER", filter } =>
{ ...state, filter },
};
}

With pattern matching (Chapter 17), reducers become expressions instead of statements. Each case returns the new state. Exhaustiveness is guaranteed.

Pattern 5: Type-Safe API Clients​

Define your API contract as types, then let TypeScript enforce it:

// Define the API contract
interface ApiEndpoints {
"/users": {
GET: { response: User[] };
POST: { body: CreateUserDto; response: User };
};
"/users/:id": {
GET: { response: User };
PUT: { body: UpdateUserDto; response: User };
DELETE: { response: void };
};
"/products": {
GET: { query: { page?: number; category?: string }; response: Product[] };
};
}

// Type-safe API client
class ApiClient {
async request<
Path extends keyof ApiEndpoints,
Method extends keyof ApiEndpoints[Path]
>(
path: Path,
method: Method,
...args: "body" extends keyof ApiEndpoints[Path][Method]
? [body: ApiEndpoints[Path][Method]["body"]]
: []
): Promise<
"response" extends keyof ApiEndpoints[Path][Method]
? ApiEndpoints[Path][Method]["response"]
: void
> {
const [body] = args;
const response = await fetch(path as string, {
method: method as string,
body: body ? JSON.stringify(body) : undefined,
});
return response.json();
}
}

const client = new ApiClient();

// Fully type-safe
const users = await client.request("/users", "GET");
// Type: User[]

const newUser = await client.request("/users", "POST", {
name: "Alice",
email: "alice@example.com",
});
// Type: User — and the body is type-checked!

const products = await client.request("/products", "GET");
// Error: 'query' is required for GET /products

Pattern 6: The Result Type​

Exceptions are unpredictable. The Result type makes error handling explicit:

type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };

// Wrap a throwing function
async function toResult<T>(promise: Promise<T>): Promise<Result<T>> {
try {
const data = await promise;
return { success: true, data };
} catch (error) {
return { success: false, error: error as Error };
}
}

// Usage
const result = await toResult(fetchUser(1));

if (result.success) {
console.log(result.data.name); // TypeScript knows data exists
} else {
console.error(result.error.message); // TypeScript knows error exists
}

The Result type forces callers to handle both success and failure. You can't forget to check for errors because the type system won't let you access data without first checking success.

Pattern 7: Exhaustive Error Handling​

Combine discriminated unions with the Result type for comprehensive error handling:

type ApiError =
| { kind: "NetworkError"; message: string }
| { kind: "NotFound"; resource: string; id: number }
| { kind: "ValidationError"; fields: Record<string, string> }
| { kind: "Unauthorized" }
| { kind: "ServerError"; status: number };

type ApiResult<T> = Result<T, ApiError>;

function handleError(error: ApiError): string {
return match (error) {
{ kind: "NetworkError", message } =>
`Network error: ${message}. Check your connection.`,
{ kind: "NotFound", resource, id } =>
`${resource} with ID ${id} not found.`,
{ kind: "ValidationError", fields } =>
`Validation failed: ${Object.keys(fields).join(", ")}`,
{ kind: "Unauthorized" } =>
"Please log in again.",
{ kind: "ServerError", status } =>
`Server error (${status}). Please try again later.`,
};
}

Every error variant has a specific shape. Every error variant gets a specific message. The compiler guarantees you handle every variant.

Pattern 8: The Builder Pattern with Type Safety​

Builders that accumulate state and validate at the end:

class QueryBuilder<Filters extends Record<string, unknown> = {}> {
private filters: Partial<Filters> = {};
private sortField: string | null = null;
private sortDirection: "asc" | "desc" = "asc";
private limitCount: number = 10;
private offsetCount: number = 0;

where<K extends string, V>(key: K, value: V): QueryBuilder<Filters & Record<K, V>> {
(this.filters as Record<string, unknown>)[key] = value;
return this as unknown as QueryBuilder<Filters & Record<K, V>>;
}

sort(field: string, direction: "asc" | "desc" = "asc"): this {
this.sortField = field;
this.sortDirection = direction;
return this;
}

limit(count: number): this {
this.limitCount = count;
return this;
}

offset(count: number): this {
this.offsetCount = count;
return this;
}

build(): Query<Filters> {
return {
filters: { ...this.filters } as Filters,
sort: this.sortField ? { field: this.sortField, direction: this.sortDirection } : null,
limit: this.limitCount,
offset: this.offsetCount,
};
}
}

const query = new QueryBuilder()
.where("status", "active")
.where("category", "electronics")
.sort("price", "asc")
.limit(20)
.build();

// query.filters.status: "active" (literal type!)
// query.filters.category: "electronics" (literal type!)

Try This: Production Patterns​

  1. Create branded types for Email, Url, and PhoneNumber. Write parse functions that validate and return the branded type.
  2. Define a Zod schema for a User type. Use z.infer to extract the TypeScript type. Write a function that fetches and validates user data.
  3. Write a Result<T, E> type and a toResult function. Use them to wrap an API call.
  4. Create a discriminated union for API errors. Write an exhaustive error handler.
  5. Build a type-safe query builder that accumulates filters and returns a typed query object.

Time needed: 30 minutes.

What to notice: How branded types prevent ID mixups. How Zod provides runtime validation that complements compile-time types. How the Result type makes error handling explicit. How discriminated unions make error handling exhaustive.

The Revelation​

You now know the patterns that make production TypeScript codebases robust. Branded types for semantic safety. Zod for runtime validation. Discriminated unions for state management. The Result type for explicit error handling.

But there's one more thing. The most important thing. The thing that separates TypeScript developers from TypeScript masters: knowing when NOT to use these patterns.

That's the final chapter.


In the next chapter: the TypeScript mindset — when to use advanced types, when to keep it simple, the code review checklist, and your path forward as a TypeScript master.