7 TypeScript Patterns I Use in Every Project
These patterns make my TypeScript code cleaner, safer, and more maintainable.
Pattern 1: Discriminated Unions for State
// ❌ Using optional fields (ambiguous state)
interface User {
name: string;
email?: string; // Is this loaded or just missing?
error?: string; // What if both email and error are present?
}
// ✅ Discriminated union — each state is explicit
type UserState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; user: { name: string; email: string } }
| { status: 'error'; error: string };
function renderUser(state: UserState) {
switch (state.status) {
case 'idle': return <p>Enter a user ID</p>;
case 'loading': return <Spinner />;
case 'success': return <div>{state.user.name} ({state.user.email})</div>;
case 'error': return <p className="error">{state.error}</p>;
// TypeScript ensures all cases are covered!
// If you add a new state type, TS will error until you handle it
}
}
Pattern 2: Branded Types for Type Safety
// Problem: Primitive types are too loose
type UserId = string;
type PostId = string;
function getUser(id: UserId) { /* ... */ }
function getPost(id: PostId) { /* ... */ }
const id = 'abc123';
getUser(id); // OK but should be UserId
getPost(id); // Also OK but should be PostId — no error!
// Solution: Branded types
type UserId = string & { __brand: 'UserId' };
type PostId = string & { __brand: 'PostId' };
// Factory functions to create branded types
function UserId(id: string): UserId { return id as UserId; }
function PostId(id: string): PostId { return id as PostId; }
function getUser(id: UserId) { /* ... */ }
function getPost(id: PostId) { /* ... */ }
const rawId = 'abc123';
getUser(UserId(rawId)); // ✅ OK
getPost(PostId(rawId)); // ✅ OK
getUser(rawId); // ❌ Error! string is not assignable to UserId
getPost(UserId(rawId)); // ❌ Error! UserId is not assignable to PostId
Pattern 3: The Builder Pattern with Fluent API
class QueryBuilder<T> {
private query: Partial<Record<string, unknown>> = {};
private _limit = 50;
private _offset = 0;
where(field: keyof T, value: unknown) {
this.query[String(field)] = value;
return this;
}
limit(n: number) {
this._limit = n;
return this;
}
offset(n: number) {
this._offset = n;
return this;
}
build() {
return {
filter: this.query,
pagination: { limit: this._limit, offset: this._offset },
};
}
}
// Usage — clean, chainable, type-safe
const q = new QueryBuilder<User>()
.where('role', 'admin')
.where('active', true)
.limit(20)
.offset(0)
.build();
// q.filter.role is inferred as string, but we know it's 'admin' | true
Pattern 4: Result Type Instead of Exceptions
// ❌ Throwing errors loses type information
function parseConfig(file: string): Config {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
// If data is wrong shape → runtime error, no compile-time check
return data as Config;
}
// ✅ Return Result type — forces handling of both cases
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
function parseConfig(file: string): Result<Config, string[]> {
try {
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
const errors = validateConfig(raw);
if (errors.length > 0) return { success: false, error: errors };
return { success: true, data: raw as Config };
} catch (e) {
return { success: false, error: [(e as Error).message] };
}
}
// Caller MUST check success
const result = parseConfig('config.json');
if (result.success) {
console.log(result.data.apiKey); // TypeScript knows data exists
} else {
console.error('Config errors:', result.error); // Must handle errors
}
Pattern 5: Generic Repository
// Reusable CRUD operations for any entity
interface BaseEntity {
id: number;
created_at: Date;
updated_at: Date;
}
abstract class Repository<T extends BaseEntity> {
constructor(protected tableName: string) {}
async findById(id: number): Promise<T | null> {
const row = db.prepare(`SELECT * FROM ${this.tableName} WHERE id = ?`).get(id);
return row ?? null;
}
async findAll(options?: { limit?: number; offset?: number }): Promise<T[]> {
let sql = `SELECT * FROM ${this.tableName}`;
const params: unknown[] = [];
if (options?.limit) {
sql += ` LIMIT ?`;
params.push(options.limit);
}
if (options?.offset) {
sql += ` OFFSET ?`;
params.push(options.offset);
}
return db.prepare(sql).all(...params) as T[];
}
async create(data: Omit<T, keyof BaseEntity>): Promise<T> {
const keys = Object.keys(data).join(', ');
const placeholders = Object.keys(data).fill('?').join(', ');
const result = db.prepare(
`INSERT INTO ${this.tableName} (${keys}) VALUES (${placeholders})`
).run(...Object.values(data));
return this.findById(result.lastInsertRowid!)!;
}
async update(id: number, data: Partial<Omit<T, keyof BaseEntity>>): Promise<T | null> {
const sets = Object.keys(data).map(k => `${k} = ?`).join(', ');
db.prepare(
`UPDATE ${this.tableName} SET ${sets}, updated_at = datetime('now') WHERE id = ?`
).run(...Object.values(data), id);
return this.findById(id);
}
async delete(id: number): Promise<boolean> {
const result = db.prepare(`DELETE FROM ${this.tableName} WHERE id = ?`).run(id);
return result.changes > 0;
}
}
// Usage — type-safe repository in seconds
class UserRepository extends Repository<User> {
constructor() {
super('users');
}
findByEmail(email: string): User | null {
return db.prepare('SELECT * FROM users WHERE email = ?').get(email) as User | null;
}
findActive(): User[] {
return db.prepare('SELECT * FROM users WHERE active = 1').all() as User[];
}
}
Pattern 6: Event Emitter with Typed Events
type EventMap = {
user: { action: 'create' | 'update' | 'delete'; user: User };
error: { message: string; code: number };
ready: void;
};
class TypedEventEmitter<M extends Record<string, any>> {
private listeners = new Map<keyof M, Set<Function>>();
on<K extends keyof M>(event: K, listener: (data: M[K]) => void) {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event)!.add(listener);
return () => this.off(event, listener); // Returns unsubscribe function
}
emit<K extends keyof M>(event: K, data: M[K]) {
this.listeners.get(event)?.forEach(fn => fn(data));
}
off<K extends keyof M>(event: K, listener: Function) {
this.listeners.get(event)?.delete(listener);
}
}
const emitter = new TypedEventEmitter<EventMap>();
emitter.on('user', (data) => {
// data.action is typed as 'create' | 'update' | 'delete'
// data.user is typed as User
console.log(`${data.action}: ${data.user.name}`);
});
emitter.emit('user', { action: 'create', user: { id: 1, name: 'Alex' } });
emitter.emit('error', { message: 'Not found', code: 404 }); // ✅ Correct type
emitter.emit('error', { message: 123, code: 'x' }); // ❌ Type error!
Pattern 7: Singleton with Lazy Initialization
class DatabaseConnection {
private static instance: DatabaseConnection | null = null;
// Private constructor prevents direct instantiation
private constructor(private url: string) {
console.log(`Connecting to ${url}`);
}
static getInstance(url: string): DatabaseConnection {
if (!DatabaseConnection.instance) {
DatabaseConnection.instance = new DatabaseConnection(url);
}
return DatabaseConnection.instance;
}
query(sql: string) {
console.log(`Executing: ${sql}`);
}
}
// Always returns the same instance
const db1 = DatabaseConnection.getInstance('postgres://localhost/db');
const db2 = DatabaseConnection.getInstance('postgres://localhost/db');
console.log(db1 === db2); // true
// Modern alternative (simpler):
let _instance: ApiClient | null = null;
function getApiClient(): ApiClient {
if (!_instance) {
_instance = new ApiClient(process.env.API_URL!);
}
return _instance;
}
Which TypeScript pattern do you use most? Any I'm missing?
Follow @armorbreak for more TypeScript content.
Top comments (0)