DEV Community

Alex Chen
Alex Chen

Posted on

7 TypeScript Patterns I Use in Every Project

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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[];
  }
}
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Which TypeScript pattern do you use most? Any I'm missing?

Follow @armorbreak for more TypeScript content.

Top comments (0)