5.5 Generic Patterns in Practice
Why Real-World Patterns Matter
Once you have a solid grasp of generic fundamentals, it is time to apply them to the design patterns that appear most frequently in production code. Good generic patterns maximize code reuse while preserving type safety. In this section you will see why each pattern is needed and how to implement it with generics through working code.
Generic Repository Pattern
The Repository pattern encapsulates data-access logic. Applying generics means you never have to repeat CRUD boilerplate for each entity.
// Base entity interface
interface Entity {
id: number;
}
// Generic Repository interface
interface Repository<T extends Entity> {
findById(id: number): Promise<T | null>;
findAll(): Promise<T[]>;
findBy(predicate: Partial<T>): Promise<T[]>;
create(data: Omit<T, "id">): Promise<T>;
update(id: number, data: Partial<Omit<T, "id">>): Promise<T | null>;
delete(id: number): Promise<boolean>;
}
// In-memory implementation (for testing)
class InMemoryRepository<T extends Entity> implements Repository<T> {
private store: Map<number, T> = new Map();
private nextId = 1;
async findById(id: number): Promise<T | null> {
return this.store.get(id) ?? null;
}
async findAll(): Promise<T[]> {
return Array.from(this.store.values());
}
async findBy(predicate: Partial<T>): Promise<T[]> {
const entries = Array.from(this.store.values());
return entries.filter((item) => {
return (Object.keys(predicate) as Array<keyof T>).every(
(key) => item[key] === predicate[key]
);
});
}
async create(data: Omit<T, "id">): Promise<T> {
const id = this.nextId++;
const entity = { ...data, id } as T;
this.store.set(id, entity);
return entity;
}
async update(id: number, data: Partial<Omit<T, "id">>): Promise<T | null> {
const existing = this.store.get(id);
if (!existing) return null;
const updated = { ...existing, ...data };
this.store.set(id, updated);
return updated;
}
async delete(id: number): Promise<boolean> {
return this.store.delete(id);
}
}
// Entity definitions
interface User extends Entity {
name: string;
email: string;
role: "admin" | "user";
}
interface Product extends Entity {
name: string;
price: number;
stock: number;
category: string;
}
// Specialized repositories: shared logic + domain-specific methods
class UserRepository extends InMemoryRepository<User> {
async findByEmail(email: string): Promise<User | null> {
const results = await this.findBy({ email } as Partial<User>);
return results[0] ?? null;
}
async findAdmins(): Promise<User[]> {
return this.findBy({ role: "admin" } as Partial<User>);
}
}
class ProductRepository extends InMemoryRepository<Product> {
async findByCategory(category: string): Promise<Product[]> {
return this.findBy({ category } as Partial<Product>);
}
async findInStock(): Promise<Product[]> {
const all = await this.findAll();
return all.filter((p) => p.stock > 0);
}
async decreaseStock(id: number, quantity: number): Promise<Product | null> {
const product = await this.findById(id);
if (!product || product.stock < quantity) return null;
return this.update(id, { stock: product.stock - quantity });
}
}
// Usage
const userRepo = new UserRepository();
const productRepo = new ProductRepository();
(async () => {
const alice = await userRepo.create({ name: "Alice", email: "alice@example.com", role: "admin" });
const bob = await userRepo.create({ name: "Bob", email: "bob@example.com", role: "user" });
const found = await userRepo.findByEmail("alice@example.com");
console.log(found?.name); // "Alice"
const admins = await userRepo.findAdmins();
console.log(admins.length); // 1
})();
Builder Pattern
The Builder pattern assembles complex objects step by step. Using generics with the this return type preserves type safety in method chains and works correctly in subclasses.
// Query builder example
interface QueryConfig<T> {
table: string;
conditions: Array<{ field: keyof T; operator: string; value: unknown }>;
orderBy: Array<{ field: keyof T; direction: "asc" | "desc" }>;
limitValue?: number;
offsetValue?: number;
selectedFields?: Array<keyof T>;
}
class QueryBuilder<T extends object> {
protected config: QueryConfig<T>;
constructor(table: string) {
this.config = {
table,
conditions: [],
orderBy: [],
};
}
// Returns this — subclasses keep their own type in chains
where<K extends keyof T>(
field: K,
operator: "=" | "!=" | ">" | "<" | ">=" | "<=",
value: T[K]
): this {
this.config.conditions.push({ field, operator, value });
return this;
}
orderByField(field: keyof T, direction: "asc" | "desc" = "asc"): this {
this.config.orderBy.push({ field, direction });
return this;
}
limit(n: number): this {
this.config.limitValue = n;
return this;
}
offset(n: number): this {
this.config.offsetValue = n;
return this;
}
select(...fields: Array<keyof T>): this {
this.config.selectedFields = fields;
return this;
}
build(): string {
const fields = this.config.selectedFields
? this.config.selectedFields.join(", ")
: "*";
let query = `SELECT ${fields} FROM ${this.config.table}`;
if (this.config.conditions.length > 0) {
const where = this.config.conditions
.map((c) => `${String(c.field)} ${c.operator} '${c.value}'`)
.join(" AND ");
query += ` WHERE ${where}`;
}
if (this.config.orderBy.length > 0) {
const order = this.config.orderBy
.map((o) => `${String(o.field)} ${o.direction.toUpperCase()}`)
.join(", ");
query += ` ORDER BY ${order}`;
}
if (this.config.limitValue !== undefined) {
query += ` LIMIT ${this.config.limitValue}`;
}
if (this.config.offsetValue !== undefined) {
query += ` OFFSET ${this.config.offsetValue}`;
}
return query;
}
}
// Subclass: chaining preserved because of the this return type
class UserQueryBuilder extends QueryBuilder<User> {
activeOnly(): this {
return this.where("role", "=", "user" as User["role"]);
}
adminOnly(): this {
return this.where("role", "=", "admin" as User["role"]);
}
}
const query = new UserQueryBuilder("users")
.adminOnly()
.orderByField("name", "asc")
.limit(10)
.select("id", "name", "email")
.build();
// SELECT id, name, email FROM users WHERE role = 'admin' ORDER BY name ASC LIMIT 10
console.log(query);
Immutable Builder Pattern
// Immutable Builder: every step returns a new instance
class ImmutableBuilder<T extends Record<string, unknown>> {
private constructor(private readonly data: Partial<T> = {}) {}
static create<T extends Record<string, unknown>>(): ImmutableBuilder<T> {
return new ImmutableBuilder<T>();
}
set<K extends keyof T>(key: K, value: T[K]): ImmutableBuilder<T> {
return new ImmutableBuilder<T>({ ...this.data, [key]: value });
}
build(): T {
return this.data as T;
}
toPartial(): Partial<T> {
return { ...this.data };
}
}
interface EmailConfig {
from: string;
to: string[];
subject: string;
body: string;
cc?: string[];
bcc?: string[];
replyTo?: string;
}
const email = ImmutableBuilder.create<EmailConfig>()
.set("from", "sender@example.com")
.set("to", ["recipient@example.com"])
.set("subject", "Test Email")
.set("body", "Hello!")
.build();
console.log(email.subject); // "Test Email"
API Response Wrappers
Result<T, E>
A type-safe Result type that clearly separates success from failure.
// Result type — similar to Rust's Result<T, E>
type Result<T, E = Error> =
| { success: true; data: T; error: null }
| { success: false; data: null; error: E };
// Helper functions
function ok<T>(data: T): Result<T, never> {
return { success: true, data, error: null };
}
function err<E>(error: E): Result<never, E> {
return { success: false, data: null, error };
}
// Usage
async function fetchUser(id: number): Promise<Result<User, string>> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
return err(`HTTP ${response.status}: ${response.statusText}`);
}
const user = await response.json() as User;
return ok(user);
} catch (e) {
return err(`Network error: ${String(e)}`);
}
}
// Type-safe handling
const result = await fetchUser(1);
if (result.success) {
console.log(result.data.name); // User type confirmed
} else {
console.error(result.error); // string type confirmed
}
Paginated<T>
// Pagination wrapper
interface Paginated<T> {
data: T[];
pagination: {
page: number;
pageSize: number;
totalItems: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};
}
function createPaginated<T>(
data: T[],
page: number,
pageSize: number,
totalItems: number
): Paginated<T> {
const totalPages = Math.ceil(totalItems / pageSize);
return {
data,
pagination: {
page,
pageSize,
totalItems,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1,
},
};
}
async function fetchUsers(page = 1, pageSize = 20): Promise<Paginated<User>> {
const response = await fetch(`/api/users?page=${page}&size=${pageSize}`);
const json = await response.json();
return createPaginated<User>(json.users, page, pageSize, json.total);
}
ApiResponse<T>
// Unified API response type
interface ApiResponse<T> {
success: boolean;
data: T | null;
error: { code: string; message: string } | null;
meta: {
requestId: string;
timestamp: string;
version: string;
};
}
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async get<T>(path: string): Promise<ApiResponse<T>> {
const response = await fetch(`${this.baseUrl}${path}`);
const json = await response.json();
return json as ApiResponse<T>;
}
async post<TRequest, TResponse>(
path: string,
body: TRequest
): Promise<ApiResponse<TResponse>> {
const response = await fetch(`${this.baseUrl}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return response.json() as Promise<ApiResponse<TResponse>>;
}
}
const apiClient = new ApiClient("https://api.example.com");
const userResponse = await apiClient.get<User>("/users/1");
if (userResponse.success && userResponse.data) {
console.log(userResponse.data.name); // type-safe
}
Generic Hook Pattern (React)
import { useState, useEffect, useCallback } from "react";
// useFetch<T>: generic data-fetching hook
interface FetchState<T> {
data: T | null;
loading: boolean;
error: string | null;
refetch: () => void;
}
function useFetch<T>(
url: string,
options?: RequestInit
): FetchState<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [version, setVersion] = useState(0);
const refetch = useCallback(() => setVersion((v) => v + 1), []);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch(url, options)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
})
.then((result) => {
if (!cancelled) {
setData(result);
setLoading(false);
}
})
.catch((e: Error) => {
if (!cancelled) {
setError(e.message);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [url, version]);
return { data, loading, error, refetch };
}
// useLocalStorage<T>: generic local storage hook
function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
setStoredValue((prev) => {
const newValue = value instanceof Function ? value(prev) : value;
window.localStorage.setItem(key, JSON.stringify(newValue));
return newValue;
});
},
[key]
);
const removeValue = useCallback(() => {
window.localStorage.removeItem(key);
setStoredValue(initialValue);
}, [key, initialValue]);
return [storedValue, setValue, removeValue];
}
// Usage inside a React component
function UserProfile() {
// T is inferred as User automatically
const { data: user, loading, error } = useFetch<User>("/api/me");
const [theme, setTheme] = useLocalStorage<"light" | "dark">(
"theme",
"light"
);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return null;
return (
<div>
<h1>{user.name}</h1>
<button onClick={() => setTheme((t) => t === "light" ? "dark" : "light")}>
Current theme: {theme}
</button>
</div>
);
}
Generic Event Emitter
// Event map type: event name → payload type
type EventMap = Record<string, unknown>;
class EventEmitter<Events extends EventMap> {
private listeners: {
[K in keyof Events]?: Array<(payload: Events[K]) => void>;
} = {};
on<K extends keyof Events>(
event: K,
listener: (payload: Events[K]) => void
): () => void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(listener);
// Return an unsubscribe function
return () => this.off(event, listener);
}
once<K extends keyof Events>(
event: K,
listener: (payload: Events[K]) => void
): () => void {
const wrapper = (payload: Events[K]) => {
listener(payload);
this.off(event, wrapper);
};
return this.on(event, wrapper);
}
off<K extends keyof Events>(
event: K,
listener: (payload: Events[K]) => void
): void {
const list = this.listeners[event];
if (list) {
this.listeners[event] = list.filter((l) => l !== listener) as typeof list;
}
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
const list = this.listeners[event];
if (list) {
list.forEach((listener) => listener(payload));
}
}
removeAllListeners<K extends keyof Events>(event?: K): void {
if (event) {
delete this.listeners[event];
} else {
this.listeners = {};
}
}
}
// Usage: define the event map
interface AppEvents {
userLogin: { userId: number; username: string; timestamp: Date };
userLogout: { userId: number };
postCreated: { postId: number; title: string; authorId: number };
error: { code: string; message: string; details?: unknown };
}
const emitter = new EventEmitter<AppEvents>();
// Type-safe subscriptions
const unsubscribe = emitter.on("userLogin", ({ userId, username }) => {
console.log(`${username} (${userId}) logged in.`);
});
emitter.on("error", ({ code, message }) => {
console.error(`[${code}] ${message}`);
});
// Type-safe publishing
emitter.emit("userLogin", {
userId: 1,
username: "Alice",
timestamp: new Date(),
});
emitter.emit("error", { code: "AUTH_FAIL", message: "Authentication failed" });
// Wrong events produce compile errors
// emitter.emit("nonExistent", {}); // Error
// emitter.emit("userLogin", { wrong: "data" }); // Error
// Unsubscribe
unsubscribe();
Practical Example: Type-Safe State Machine
// State machine definition
type StateMachineConfig<
State extends string,
Event extends string
> = {
initial: State;
states: {
[S in State]: {
on?: Partial<Record<Event, State>>;
entry?: () => void;
exit?: () => void;
};
};
};
class StateMachine<State extends string, Event extends string> {
private currentState: State;
private config: StateMachineConfig<State, Event>;
private listeners: Array<(state: State, event: Event) => void> = [];
constructor(config: StateMachineConfig<State, Event>) {
this.config = config;
this.currentState = config.initial;
config.states[config.initial].entry?.();
}
getState(): State {
return this.currentState;
}
send(event: Event): boolean {
const stateConfig = this.config.states[this.currentState];
const nextState = stateConfig.on?.[event];
if (!nextState) return false;
stateConfig.exit?.();
this.currentState = nextState;
this.config.states[nextState].entry?.();
this.listeners.forEach((l) => l(nextState, event));
return true;
}
can(event: Event): boolean {
return !!this.config.states[this.currentState].on?.[event];
}
onTransition(listener: (state: State, event: Event) => void): () => void {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter((l) => l !== listener);
};
}
}
// Traffic light state machine
type TrafficState = "red" | "yellow" | "green";
type TrafficEvent = "timer" | "emergency";
const trafficLight = new StateMachine<TrafficState, TrafficEvent>({
initial: "red",
states: {
red: {
on: { timer: "green" },
entry: () => console.log("Red: Stop"),
},
yellow: {
on: { timer: "red" },
entry: () => console.log("Yellow: Prepare"),
},
green: {
on: { timer: "yellow", emergency: "red" },
entry: () => console.log("Green: Go"),
},
},
});
trafficLight.onTransition((state) => console.log(`State changed → ${state}`));
trafficLight.send("timer"); // Red → Green
trafficLight.send("emergency"); // Green → Red
console.log(trafficLight.can("timer")); // true
Generic Form Handling
// Field configuration
type FieldConfig<T> = {
[K in keyof T]: {
label: string;
type: "text" | "email" | "password" | "number" | "select";
required?: boolean;
validate?: (value: T[K]) => string | null;
options?: T[K] extends string ? string[] : never;
};
};
interface FormState<T> {
values: T;
errors: Partial<Record<keyof T, string>>;
touched: Partial<Record<keyof T, boolean>>;
isValid: boolean;
isDirty: boolean;
}
class GenericForm<T extends Record<string, unknown>> {
private state: FormState<T>;
private config: FieldConfig<T>;
private changeListeners: Array<(state: FormState<T>) => void> = [];
constructor(initialValues: T, config: FieldConfig<T>) {
this.config = config;
this.state = {
values: { ...initialValues },
errors: {},
touched: {},
isValid: true,
isDirty: false,
};
}
setValue<K extends keyof T>(field: K, value: T[K]): void {
this.state = {
...this.state,
values: { ...this.state.values, [field]: value },
touched: { ...this.state.touched, [field]: true },
isDirty: true,
};
this.validate();
this.notifyListeners();
}
private validate(): void {
const errors: Partial<Record<keyof T, string>> = {};
let isValid = true;
(Object.keys(this.config) as Array<keyof T>).forEach((key) => {
const fieldConfig = this.config[key];
const value = this.state.values[key];
if (fieldConfig.required && !value) {
errors[key] = `${fieldConfig.label} is required.`;
isValid = false;
} else if (fieldConfig.validate) {
const error = fieldConfig.validate(value);
if (error) {
errors[key] = error;
isValid = false;
}
}
});
this.state = { ...this.state, errors, isValid };
}
getState(): Readonly<FormState<T>> {
return this.state;
}
reset(initialValues: T): void {
this.state = {
values: { ...initialValues },
errors: {},
touched: {},
isValid: true,
isDirty: false,
};
this.notifyListeners();
}
onChange(listener: (state: FormState<T>) => void): () => void {
this.changeListeners.push(listener);
return () => {
this.changeListeners = this.changeListeners.filter((l) => l !== listener);
};
}
private notifyListeners(): void {
this.changeListeners.forEach((l) => l(this.state));
}
}
// Usage
interface LoginFormValues {
email: string;
password: string;
}
const loginForm = new GenericForm<LoginFormValues>(
{ email: "", password: "" },
{
email: {
label: "Email",
type: "email",
required: true,
validate: (value) => {
if (!value.includes("@")) return "Please enter a valid email address.";
return null;
},
},
password: {
label: "Password",
type: "password",
required: true,
validate: (value) => {
if (value.length < 8) return "Password must be at least 8 characters.";
return null;
},
},
}
);
loginForm.onChange(({ isValid, errors }) => {
console.log("Valid:", isValid, "Errors:", errors);
});
loginForm.setValue("email", "invalid-email");
// Valid: false, Errors: { email: "Please enter a valid email address." }
loginForm.setValue("email", "user@example.com");
loginForm.setValue("password", "secret123");
// Valid: true, Errors: {}
Pro Tips
Generic Class vs Generic Method
// Generic class: type is fixed for the lifetime of the instance
class TypedStorage<T> {
constructor(private key: string) {}
save(data: T): void {
localStorage.setItem(this.key, JSON.stringify(data));
}
load(): T | null {
const item = localStorage.getItem(this.key);
return item ? (JSON.parse(item) as T) : null;
}
}
// T is locked in when the instance is created
const userStorage = new TypedStorage<User>("current-user");
userStorage.save({ id: 1, name: "Alice", email: "a@b.com", role: "user" });
// Generic method: different types on each call
class FlexibleStorage {
save<T>(key: string, data: T): void {
localStorage.setItem(key, JSON.stringify(data));
}
load<T>(key: string): T | null {
const item = localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : null;
}
}
// Different types per call
const storage = new FlexibleStorage();
storage.save<User>("user", { id: 1, name: "Alice", email: "a@b.com", role: "user" });
storage.save<string[]>("tags", ["ts", "generic"]);
// Selection guide:
// - Type decided at instantiation → generic class
// - Type varies per method call → generic method
// - Multiple types in one instance → generic method
Type Parameter Propagation Strategy
// Poor propagation: type parameters everywhere unnecessarily
class OverEngineered<T, U, V, W> {
constructor(
private converter: (input: T) => U,
private formatter: (value: U) => V,
private renderer: (formatted: V) => W
) {}
process(input: T): W {
return this.renderer(this.formatter(this.converter(input)));
}
}
// Better propagation: expose only the types the caller needs
class Pipeline<TInput, TOutput> {
private steps: Array<(value: unknown) => unknown> = [];
static create<T>(): Pipeline<T, T> {
return new Pipeline<T, T>();
}
pipe<TNext>(fn: (value: TOutput) => TNext): Pipeline<TInput, TNext> {
const next = new Pipeline<TInput, TNext>();
next.steps = [...this.steps, fn as (v: unknown) => unknown];
return next;
}
run(input: TInput): TOutput {
return this.steps.reduce(
(value, step) => step(value),
input as unknown
) as TOutput;
}
}
// Usage: types flow naturally along the pipeline
const processor = Pipeline.create<string>()
.pipe((s) => s.trim()) // string → string
.pipe((s) => s.split(",")) // string → string[]
.pipe((arr) => arr.map(Number)); // string[] → number[]
const result = processor.run("1, 2, 3, 4, 5");
// number[] = [1, 2, 3, 4, 5]
Generic Middleware Pattern
// Express-style middleware types
type Middleware<TContext extends object> = (
ctx: TContext,
next: () => Promise<void>
) => Promise<void>;
class MiddlewareChain<TContext extends object> {
private middlewares: Middleware<TContext>[] = [];
use(middleware: Middleware<TContext>): this {
this.middlewares.push(middleware);
return this;
}
async execute(ctx: TContext): Promise<void> {
const dispatch = async (index: number): Promise<void> => {
if (index >= this.middlewares.length) return;
await this.middlewares[index](ctx, () => dispatch(index + 1));
};
await dispatch(0);
}
}
// Usage
interface RequestContext {
path: string;
method: string;
userId?: number;
startTime?: number;
responseTime?: number;
}
const chain = new MiddlewareChain<RequestContext>()
.use(async (ctx, next) => {
ctx.startTime = Date.now();
await next();
ctx.responseTime = Date.now() - ctx.startTime!;
console.log(`${ctx.method} ${ctx.path}: ${ctx.responseTime}ms`);
})
.use(async (ctx, next) => {
console.log(`Auth check: ${ctx.path}`);
ctx.userId = 1; // In production, validate a token here
await next();
})
.use(async (ctx) => {
console.log(`Handler: user ${ctx.userId} → ${ctx.path}`);
});
await chain.execute({ path: "/users", method: "GET" });
Summary Table
| Pattern | Generic Leverage Point | Key Benefit |
|---|---|---|
| Repository | T extends Entity | CRUD code written once |
| Builder (this type) | this return type | Subclasses keep method chaining |
| Builder (immutable) | New instance per step | Safe chaining, no side effects |
| Result<T, E> | Separate success/failure types | Forced error handling, null eliminated |
| Paginated<T> | Wrap data type | Pagination type reused across entities |
| useFetch<T> | Return data type | Hook reuse + type safety |
| EventEmitter<Events> | Event map type | Event names and payloads both type-safe |
| StateMachine<S, E> | State and event types | Illegal transitions caught at compile time |
| GenericForm<T> | Form value type | Type-safe field access |
| Pipeline<TIn, TOut> | Per-step type transformation | Types tracked through the entire chain |
Up Next...
Chapter 6 covers the Advanced Type System. You will explore deeper features of TypeScript's type system — template literal types, advanced mapped types, recursive types, and type-level programming patterns — building on the generics foundation from Chapter 5 to express complex constraints and transformations entirely in types.