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 aren't groundbreaking. They're the boring patterns that prevent bugs and save hours of debugging.

1. The Result Type (No More try/catch Hell)

// Instead of throwing exceptions everywhere:
function divide(a: number, b: number): number {
  if (b === 0) throw new Error("Division by zero");
  return a / b;
}

// Use a Result type:
type Result<T, E = Error> = 
  | { ok: true; value: T }
  | { ok: false; error: E };

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) return { ok: false, error: "Division by zero" };
  return { ok: true, value: a / b };
}

// Usage — the compiler FORCES you to handle errors:
const result = divide(10, 0);
if (result.ok) {
  console.log(result.value); // TypeScript knows this is number
} else {
  console.error(result.error); // TypeScript knows this is string
}
Enter fullscreen mode Exit fullscreen mode

Why this matters: No more "undefined is not a function" because you forgot a try/catch. The type system enforces error handling.

2. Branded Types (Prevent Mixing Up IDs)

// The problem:
function getUser(id: string) { /* ... */ }
function getPost(id: string) { /* ... */ }

// Easy to mix up:
getUser(postId);  // TypeScript allows this! But it's wrong!

// The fix — branded types:
type UserId = string & { readonly __brand: 'UserId' };
type PostId = string & { readonly __brand: 'PostId' };

function createUser(id: string): UserId {
  return id as UserId;
}

function getUser(id: UserId) { /* ... */ }
function getPost(id: PostId) { /* ... */ }

const userId = createUser('abc');
getUser(userId);  // ✅ Works
// getPost(userId); // ❌ TypeScript error! Type 'UserId' is not assignable to 'PostId'
Enter fullscreen mode Exit fullscreen mode

When to use: Anytime you have multiple IDs that look the same (userId, orderId, sessionId) but mean different things.

3. The Builder Pattern for Complex Objects

interface Config {
  host: string;
  port: number;
  database: string;
  ssl: boolean;
  poolSize: number;
  timeout: number;
}

class ConfigBuilder {
  private config: Partial<Config> = {};

  static create() {
    return new ConfigBuilder();
  }

  withHost(host: string) {
    this.config.host = host;
    return this; // Chain!
  }

  withPort(port: number) {
    this.config.port = port;
    return this;
  }

  withDatabase(database: string) {
    this.config.database = database;
    return this;
  }

  withSSL(ssl = true) {
    this.config.ssl = ssl;
    return this;
  }

  withPoolSize(size: number) {
    this.config.poolSize = size;
    return this;
  }

  build(): Config {
    if (!this.config.host) throw new Error('host is required');
    if (!this.config.database) throw new Error('database is required');

    return {
      host: this.config.host,
      port: this.config.port ?? 5432,
      database: this.config.database,
      ssl: this.config.ssl ?? false,
      poolSize: this.config.poolSize ?? 10,
      timeout: this.config.timeout ?? 5000,
    };
  }
}

// Usage — readable, flexible, with defaults:
const config = ConfigBuilder.create()
  .withHost('localhost')
  .withDatabase('myapp')
  .withSSL()
  .withPoolSize(20)
  .build();
Enter fullscreen mode Exit fullscreen mode

4. Strict Event Emitter Types

// Instead of the loose EventEmitter from Node:
import { EventEmitter } from 'events';

const emitter = new EventEmitter();
emitter.on('data', (payload: unknown) => { /* type safety? nah */ });
emitter.emit('data', 123);       // No type check
emitter.emit('typo', 'anything'); // No type check

// Strict typed version:
type EventMap = {
  'user:login': { userId: string; timestamp: Date };
  'user:logout': { userId: string };
  'error': { message: string; code: number };
  'data:update': { key: string; value: unknown };
};

class TypedEmitter<Events extends Record<string, unknown>> {
  private emitter = new EventEmitter();

  on<K extends keyof Events & string>(
    event: K,
    handler: (payload: Events[K]) => void
  ) {
    this.emitter.on(event, handler as (...args: unknown[]) => void);
    return () => this.emitter.off(event, handler as (...args: unknown[]) => void);
  }

  emit<K extends keyof Events & string>(
    event: K,
    payload: Events[K]
  ) {
    this.emitter.emit(event, payload);
  }
}

// Usage — fully typed:
const bus = new TypedEmitter<EventMap>();

bus.on('user:login', (payload) => {
  console.log(payload.userId);    // TypeScript knows this is string
  console.log(payload.timestamp); // TypeScript knows this is Date
});

bus.on('error', (payload) => {
  console.log(payload.code); // TypeScript knows this is number
});

// bus.emit('user:login', { userId: 123 }); // ❌ Error: string expected
// bus.emit('nonexistent', {});             // ❌ Error: not in EventMap
Enter fullscreen mode Exit fullscreen mode

5. The Guard Pattern (Narrow Types in Functions)

interface User {
  id: string;
  name: string;
  role: 'admin' | 'editor' | 'viewer';
  email?: string; // Might not be set
}

// Instead of checking everywhere:
function sendEmail(user: User) {
  if (!user.email) throw new Error('No email');
  // TypeScript still thinks email might be undefined here in some cases
}

// Use type guards:
function hasEmail(user: User): user is User & { email: string } {
  return typeof user.email === 'string' && user.email.length > 0;
}

function isAdmin(user: User): user is User & { role: 'admin' } {
  return user.role === 'admin';
}

// Now TypeScript knows:
function sendEmail(user: User) {
  if (!hasEmail(user)) return; // Guard clause
  console.log(user.email.toLowerCase()); // ✅ TypeScript knows email is string
}

function deleteEverything(user: User) {
  if (!isAdmin(user)) throw new Error('Unauthorized');
  // ✅ TypeScript knows role is 'admin' here
}
Enter fullscreen mode Exit fullscreen mode

6. Zod for Runtime Validation

import { z } from 'zod';

// Define schema once — use for both types AND runtime validation
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  role: z.enum(['admin', 'editor', 'viewer']),
  createdAt: z.string().datetime(),
});

// Infer TypeScript type from schema
type User = z.infer<typeof UserSchema>;

// Validate API responses at runtime:
async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();

  // This throws if data doesn't match schema
  return UserSchema.parse(data);
}

// Safe parse (doesn't throw):
const result = UserSchema.safeParse(mysteryData);
if (result.success) {
  const user: User = result.data;
} else {
  console.error(result.error.flatten());
}
Enter fullscreen mode Exit fullscreen mode

Why Zod over Joi/ Yup? Type inference. Your schema IS your type definition. No duplication.

7. The Async Pool Pattern

// When you need to run many async operations but limit concurrency:
async function asyncPool<T>(
  concurrency: number,
  items: T[],
  fn: (item: T, index: number) => Promise<void>
): Promise<void> {
  const executing = new Set<Promise<void>>();

  for (const [index, item] of items.entries()) {
    const promise = fn(item, index).then(() => {
      executing.delete(promise);
    });
    executing.add(promise);

    if (executing.size >= concurrency) {
      await Promise.race(executing);
    }
  }

  await Promise.all(executing);
}

// Usage — fetch 100 URLs but only 5 at a time:
const urls = Array.from({ length: 100 }, (_, i) => `https://api.example.com/page/${i}`);

await asyncPool(5, urls, async (url) => {
  const response = await fetch(url);
  if (!response.ok) console.error(`Failed: ${url}`);
});

// Compare with naive approach (all at once — might crash):
// await Promise.all(urls.map(url => fetch(url))); // 💥 Too many connections
Enter fullscreen mode Exit fullscreen mode

Bonus: The Never-Returning Function

// For functions that always throw (assertions, unreachable code):
function assert(condition: unknown, message: string): asserts condition {
  if (!condition) throw new Error(message);
}

function unreachable(x: never): never {
  throw new Error(`Unreachable code reached: ${x}`);
}

// Usage:
type Status = 'loading' | 'success' | 'error';

function handleStatus(status: Status): string {
  switch (status) {
    case 'loading': return 'Loading...';
    case 'success': return 'Done!';
    case 'error': return 'Failed!';
  }
  // If you add a new status type, TypeScript will error here
  // because the switch is no longer exhaustive
}
Enter fullscreen mode Exit fullscreen mode

Which patterns do you use daily? Drop your favorites in the comments.

Follow @armorbreak for more TypeScript and Node.js content.

Top comments (0)