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 caught bugs before they reached production. Every single time.

1. The Discriminated Union Pattern

// ❌ BAD: Using optional fields to distinguish states
interface User {
  id: string;
  email: string;
  name?: string;        // Is this "loading" or "no name"?
  error?: string;       // Is this "error" or "no error"?
}

// ✅ GOOD: Discriminated union — every state is explicit
type UserState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; user: { id: string; email: string; name: string } }
  | { status: 'error'; error: string };

function renderUser(state: UserState): string {
  // TypeScript KNOWS which fields exist in each state
  switch (state.status) {
    case 'idle': return 'Enter a user ID';
    case 'loading': return 'Loading...';
    case 'success': return `Hello, ${state.user.name}`; // ✅ user is guaranteed here
    case 'error': return `Error: ${state.error}`;       // ✅ error is guaranteed here
  }
}
Enter fullscreen mode Exit fullscreen mode

Why it matters: No more if (user && user.name) checks. TypeScript narrows the type automatically.

2. Branded Types for Type Safety

// Problem: Mixing up similar string types
function getUser(id: string) { /* ... */ }
function getPost(id: string) { /* ... */ */

getUser(postId); // Oops! Wrong ID but TypeScript doesn't catch it

// Solution: Branded types
type UserId = string & { __brand: 'UserId' };
type PostId = string & { __brand: 'PostId' };
type Email = string & { __brand: 'Email' };

// 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 Email(email: string): Email {
  if (!email.includes('@')) throw new Error('Invalid email');
  return email as Email;
}

// Now TypeScript catches mistakes!
function getUser(id: UserId) { /* ... */ }
function getPost(id: PostId) { /* ... */ }

const uid = UserId('abc123');
const pid = PostId('xyz789');

getUser(uid);  // ✅
getUser(pid);  // ❌ Error: Type 'PostId' is not assignable to type 'UserId'
Enter fullscreen mode Exit fullscreen mode

3. The Result/Either Pattern for Errors

// Instead of throwing errors, return a type-safe result
type Result<T, E = Error> =
  | { success: true; data: T }
  | { success: false; error: E };

async function fetchUser(id: UserId): Promise<Result<User>> {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      return { success: false, error: new Error(`HTTP ${response.status}`) };
    }
    const user = await response.json();
    return { success: true, data: user };
  } catch (err) {
    return { success: false, error: err as Error };
  }
}

// Usage — no try/catch needed!
const result = await fetchUser(someId);

if (result.success) {
  console.log(result.data.name); // TypeScript knows .data exists
} else {
  console.error(result.error.message); // TypeScript knows .error exists
}
Enter fullscreen mode Exit fullscreen mode

4. Const Assertions for Exact Types

// Without const assertion:
const ROLES = ['admin', 'user', 'moderator'];
// Type: string[] — you can push anything!

// With const assertion:
const ROLES = ['admin', 'user', 'moderator'] as const;
// Type: readonly ["admin", "user", "moderator"] — exact values!

type Role = typeof ROLES[number]; // "admin" | "user" | "moderator"

function setRole(role: Role) { /* ... */ }
setRole('admin');     // ✅
setRole('superadmin'); // ❌ Error!

// Also works with objects:
const HTTP_STATUS = {
  OK: 200,
  CREATED: 201,
  NOT_FOUND: 404,
  SERVER_ERROR: 500,
} as const;

type StatusCode = typeof HTTP_STATUS[keyof typeof HTTP_STATUS];
// 200 | 201 | 404 | 500
Enter fullscreen mode Exit fullscreen mode

5. Template Literal Types for String Validation

// Enforce specific string patterns at compile time:
type EventName = `${string}:${string}`;
// "click:button", "submit:form", etc.

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
type ApiRoute = `/api/${string}`;

// Real example: typed event emitter
type Events = {
  'user:login': { userId: string; timestamp: number };
  'user:logout': { userId: string };
  'post:create': { postId: string; authorId: string };
};

class TypedEmitter<T extends Record<string, unknown>> {
  on<K extends keyof T>(event: K, handler: (data: T[K]) => void) {}
  emit<K extends keyof T>(event: K, data: T[K]) {}
}

const emitter = new TypedEmitter<Events>();

emitter.on('user:login', (data) => {
  console.log(data.userId);    // ✅ TypeScript knows the shape
  console.log(data.timestamp);  // ✅
});

emitter.on('user:login', (data) => {
  console.log(data.postId);    // ❌ Error! 'postId' not in 'user:login'
});

emitter.emit('post:create', {   // ✅ Must match exact shape
  postId: 'abc',
  authorId: 'def',
});
Enter fullscreen mode Exit fullscreen mode

6. Satisfies for Validation + Inference

// Problem: 'as const' gives exact types but makes them hard to use
// Problem: Explicit typing loses inference

// Solution: satisfies — validates shape AND infers wider types
const config = {
  api: {
    baseUrl: 'https://api.example.com',
    timeout: 5000,
    retries: 3,
  },
  features: {
    darkMode: true,
    notifications: false,
  },
} satisfies {
  api: { baseUrl: string; timeout: number; retries: number };
  features: Record<string, boolean>;
};

// config.api.baseUrl is string (not literal 'https://...')
// But the structure was validated at write time

// Can still do math on numbers:
config.config.api.timeout *= 2; // Works because it's number, not 5000
Enter fullscreen mode Exit fullscreen mode

7. The Builder Pattern with Method Chaining

class QueryBuilder<T> {
  private clauses: string[] = [];
  private params: unknown[] = [];

  where(condition: string, value: unknown): this {
    this.clauses.push(`WHERE ${condition}`);
    this.params.push(value);
    return this;
  }

  limit(count: number): this {
    this.clauses.push(`LIMIT ?`);
    this.params.push(count);
    return this;
  }

  orderBy(column: keyof T, direction: 'ASC' | 'DESC' = 'ASC'): this {
    this.clauses.push(`ORDER BY ${String(column)} ${direction}`);
    return this;
  }

  build(): { sql: string; params: unknown[] } {
    return {
      sql: this.clauses.join(' '),
      params: [...this.params],
    };
  }
}

// Usage:
const query = new QueryBuilder<User>()
  .where('active = ?', true)
  .where('role = ?', 'admin')
  .limit(10)
  .orderBy('created_at', 'DESC')
  .build();

// query.sql = "WHERE active = ? WHERE role = ? LIMIT ? ORDER BY created_at DESC"
// query.params = [true, "admin", 10]
Enter fullscreen mode Exit fullscreen mode

Bonus: My tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}
Enter fullscreen mode Exit fullscreen mode

Key flags that catch bugs:

  • strict — All strict checks enabled
  • noUncheckedIndexedAccessarr[0] could be undefined (catches off-by-one!)
  • exactOptionalPropertyTypes{ x?: string } won't accept { x: undefined }
  • noFallthroughCasesInSwitch — Catches missing break statements

Which TypeScript pattern saves you the most headaches?

Follow @armorbreak for more TypeScript content.

Top comments (0)