In the previous tutorial, we learned about tRPC for end-to-end type safety. Now let’s learn about advanced patterns — techniques that experienced TypeScript developers use to write safer, more maintainable code.
By the end of this tutorial, you will know discriminated unions for state machines, branded types for preventing ID mix-ups, the builder pattern for type-safe object construction, and type-safe event systems.
Supporting Types Used in This Tutorial
The examples below use a few shared types. Here they are for reference:
// Basic types used across examples
type User = { id: string; name: string; email: string };
type CartItem = { productId: string; name: string; price: number; quantity: number };
type Address = { street: string; city: string; country: string; zip: string };
type PaymentMethod = { type: "card" | "paypal"; token: string };
type Order = { id: string; items: CartItem[]; total: number; status: string };
You would normally define these in separate files. Here they are all in one place so each example is easy to follow.
Discriminated Unions for State Machines
A discriminated union is a union type where each member has a shared property (the discriminant) with a literal type:
// assume: type User = { id: string; name: string; email: string }
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; message: string };
The status property is the discriminant. TypeScript uses it to narrow the type:
function render(state: RequestState): string {
switch (state.status) {
case "idle":
return "Click to load";
case "loading":
return "Loading...";
case "success":
return state.data.map(u => u.name).join(", ");
// TypeScript knows state.data exists here
case "error":
return `Error: ${state.message}`;
// TypeScript knows state.message exists here
}
}
State Machine Example
Model a shopping cart checkout flow:
// assume: types CartItem, Address, PaymentMethod defined above
// assume: function generateOrderId(): string — returns a unique ID string
type CheckoutState =
| { step: "cart"; items: CartItem[] }
| { step: "shipping"; items: CartItem[]; address: Address }
| { step: "payment"; items: CartItem[]; address: Address; paymentMethod: PaymentMethod }
| { step: "confirmation"; orderId: string }
| { step: "error"; message: string };
type CheckoutAction =
| { type: "SET_ADDRESS"; address: Address }
| { type: "SET_PAYMENT"; paymentMethod: PaymentMethod }
| { type: "CONFIRM" }
| { type: "RESET" };
function checkoutReducer(state: CheckoutState, action: CheckoutAction): CheckoutState {
switch (action.type) {
case "SET_ADDRESS":
if (state.step !== "cart") return state;
return { step: "shipping", items: state.items, address: action.address };
case "SET_PAYMENT":
if (state.step !== "shipping") return state;
return {
step: "payment",
items: state.items,
address: state.address,
paymentMethod: action.paymentMethod,
};
case "CONFIRM":
if (state.step !== "payment") return state;
return { step: "confirmation", orderId: generateOrderId() };
case "RESET":
return { step: "cart", items: [] };
}
}
Each state transition is type-safe. You cannot access address in the “cart” step because TypeScript knows it does not exist there.
Exhaustive Checks with never
The never type ensures you handle all cases in a switch:
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 forget a case, this line shows a compile error
const _exhaustive: never = shape;
return _exhaustive;
}
}
}
If you add a new shape (like "pentagon") to the union but forget to handle it in the switch, TypeScript shows an error because "pentagon" is not assignable to never.
Branded Types
Branded types prevent you from mixing up values that have the same underlying type:
// Without branded types — dangerous
type UserId = string;
type OrderId = string;
// assume: type User = { id: string; name: string; email: string }
// assume: type Order = { id: string; items: CartItem[]; total: number; status: string }
function getUser(id: UserId): User { /* ... */ }
function getOrder(id: OrderId): Order { /* ... */ }
const userId: UserId = "user_123";
const orderId: OrderId = "order_456";
// This compiles! But it's a bug — we swapped the IDs
getUser(orderId); // No error, but wrong!
getOrder(userId); // No error, but wrong!
Creating Branded Types
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type ProductId = Brand<string, "ProductId">;
Factory Functions
You need functions to create branded values:
// assume: type User, type Order defined as before
// assume: function getUser(id: UserId): User
// assume: function getOrder(id: OrderId): Order
function createUserId(id: string): UserId {
return id as UserId;
}
function createOrderId(id: string): OrderId {
return id as OrderId;
}
const userId = createUserId("user_123");
const orderId = createOrderId("order_456");
getUser(userId); // OK
getUser(orderId); // Error! OrderId is not assignable to UserId
Real-World Use: Currency
type USD = Brand<number, "USD">;
type EUR = Brand<number, "EUR">;
function usd(amount: number): USD {
return amount as USD;
}
function eur(amount: number): EUR {
return amount as EUR;
}
function addUSD(a: USD, b: USD): USD {
return (a + b) as USD;
}
const price = usd(10);
const tax = usd(2);
const total = addUSD(price, tax); // OK
const euroPrice = eur(10);
// addUSD(price, euroPrice); // Error! Cannot mix USD and EUR
The Builder Pattern
The builder pattern creates objects step by step, with TypeScript tracking which properties have been set:
interface QueryConfig {
table: string;
columns: string[];
where?: string;
orderBy?: string;
limit?: number;
}
class QueryBuilder {
private config: Partial<QueryConfig> = {};
from(table: string): this {
this.config.table = table;
return this;
}
select(...columns: string[]): this {
this.config.columns = columns;
return this;
}
where(condition: string): this {
this.config.where = condition;
return this;
}
orderBy(column: string): this {
this.config.orderBy = column;
return this;
}
limit(count: number): this {
this.config.limit = count;
return this;
}
build(): QueryConfig {
if (!this.config.table) throw new Error("Table is required");
if (!this.config.columns) throw new Error("Columns are required");
return this.config as QueryConfig;
}
}
// Usage
const query = new QueryBuilder()
.from("users")
.select("id", "name", "email")
.where("age > 18")
.orderBy("name")
.limit(10)
.build();
Type-Safe Builder with Generics
For compile-time checking of required fields:
type BuilderState = {
hasTable: boolean;
hasColumns: boolean;
};
class TypeSafeBuilder<State extends BuilderState = { hasTable: false; hasColumns: false }> {
private config: Partial<QueryConfig> = {};
from(table: string): TypeSafeBuilder<State & { hasTable: true }> {
this.config.table = table;
return this as any;
}
select(...columns: string[]): TypeSafeBuilder<State & { hasColumns: true }> {
this.config.columns = columns;
return this as any;
}
where(condition: string): this {
this.config.where = condition;
return this;
}
// build() is only available when both table and columns are set
build(
this: TypeSafeBuilder<{ hasTable: true; hasColumns: true }>
): QueryConfig {
return this.config as QueryConfig;
}
}
// Works — both table and columns are set
const query = new TypeSafeBuilder()
.from("users")
.select("id", "name")
.build(); // OK
// Error — missing select()
// new TypeSafeBuilder().from("users").build(); // Compile error
Type-Safe Event System
Build an event emitter where event names and payload types are linked:
interface EventMap {
userCreated: { id: string; name: string };
userDeleted: { id: string };
orderPlaced: { orderId: string; total: number };
}
class TypedEventEmitter<Events extends Record<string, unknown>> {
private listeners = new Map<string, Set<Function>>();
on<K extends keyof Events>(
event: K,
listener: (payload: Events[K]) => void
): void {
if (!this.listeners.has(event as string)) {
this.listeners.set(event as string, new Set());
}
this.listeners.get(event as string)!.add(listener);
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
const listeners = this.listeners.get(event as string);
if (listeners) {
for (const listener of listeners) {
listener(payload);
}
}
}
off<K extends keyof Events>(
event: K,
listener: (payload: Events[K]) => void
): void {
this.listeners.get(event as string)?.delete(listener);
}
}
Using the Typed Event Emitter
const emitter = new TypedEventEmitter<EventMap>();
emitter.on("userCreated", (payload) => {
// payload is typed: { id: string; name: string }
console.log(`User created: ${payload.name}`);
});
emitter.emit("userCreated", { id: "1", name: "Alex" }); // OK
// emitter.emit("userCreated", { id: "1" }); // Error: missing name
// emitter.emit("unknown", {}); // Error: "unknown" is not a valid event
Readonly Data Structures
TypeScript provides readonly versions of built-in collection types:
// ReadonlyArray — cannot push, pop, or modify
const numbers: ReadonlyArray<number> = [1, 2, 3];
// numbers.push(4); // Error
// ReadonlyMap — cannot set or delete
// assume: type User = { id: string; name: string; email: string }
const users: ReadonlyMap<string, User> = new Map([
["1", { id: "1", name: "Alex", email: "alex@example.com" }],
]);
// users.set("2", ...); // Error
// ReadonlySet — cannot add or delete
const tags: ReadonlySet<string> = new Set(["typescript", "react"]);
// tags.add("node"); // Error
Deep Readonly
type DeepReadonly<T> = T extends Function
? T
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
interface Config {
server: {
host: string;
port: number;
};
features: string[];
}
const config: DeepReadonly<Config> = {
server: { host: "localhost", port: 3000 },
features: ["auth", "api"],
};
// config.server.port = 8080; // Error: readonly
// config.features.push("admin"); // Error: readonly
The satisfies Operator
satisfies validates a type without widening it:
type Color = "red" | "green" | "blue";
type ColorMap = Record<Color, string | number[]>;
// Without satisfies — type is widened
const colors: ColorMap = {
red: "#ff0000",
green: [0, 255, 0],
blue: "#0000ff",
};
colors.red.toUpperCase(); // Error: might be number[]
// With satisfies — keeps the literal types
const colors2 = {
red: "#ff0000",
green: [0, 255, 0],
blue: "#0000ff",
} satisfies ColorMap;
colors2.red.toUpperCase(); // OK: TypeScript knows it's a string
colors2.green.map(n => n); // OK: TypeScript knows it's number[]
satisfies checks that the value matches the type but preserves the specific type of each property.
What’s Next?
In the next tutorial, we will learn about TypeScript configuration — every important option in tsconfig.json explained.