Blog
TypeScriptClean CodeBest Practices

TypeScript Patterns for Clean Code

·10 min read

TypeScript Patterns for Clean Code

TypeScript's type system is incredibly powerful, but it takes practice to use it effectively. Here are patterns I've learned from working on large-scale applications.

Discriminated Unions for State Management

Instead of optional properties, use discriminated unions:

// Bad: Optional properties make state unclear
interface ApiResponse {
  data?: User;
  error?: Error;
  loading?: boolean;
}

// Good: Discriminated union makes state explicit
type ApiState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

function handleState(state: ApiState<User>) {
  switch (state.status) {
    case 'idle':
      return <Placeholder />;
    case 'loading':
      return <Spinner />;
    case 'success':
      return <UserCard user={state.data} />;
    case 'error':
      return <ErrorMessage error={state.error} />;
  }
}

Branded Types for Type Safety

Create distinct types for values that are structurally identical:

type UserId = string & { readonly brand: unique symbol };
type OrderId = string & { readonly brand: unique symbol };

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

function createOrderId(id: string): OrderId {
  return id as OrderId;
}

function getUser(id: UserId): User { /* ... */ }
function getOrder(id: OrderId): Order { /* ... */ }

const userId = createUserId('user-123');
const orderId = createOrderId('order-456');

getUser(userId);  // OK
getUser(orderId); // Error! OrderId is not assignable to UserId

Builder Pattern with Method Chaining

class QueryBuilder<T> {
  private query: Partial<Query> = {};

  select<K extends keyof T>(...fields: K[]): this {
    this.query.select = fields;
    return this;
  }

  where(condition: Partial<T>): this {
    this.query.where = condition;
    return this;
  }

  orderBy(field: keyof T, direction: 'asc' | 'desc' = 'asc'): this {
    this.query.orderBy = { field, direction };
    return this;
  }

  limit(count: number): this {
    this.query.limit = count;
    return this;
  }

  build(): Query {
    return this.query as Query;
  }
}

// Usage
const query = new QueryBuilder<User>()
  .select('id', 'name', 'email')
  .where({ active: true })
  .orderBy('createdAt', 'desc')
  .limit(10)
  .build();

Conclusion

These patterns have helped me write more maintainable TypeScript code. The key is to leverage the type system to make invalid states unrepresentable.