Architecture, Scalability & Anti-Patterns
📖 Concept
At the architect level, TypeScript is a tool for encoding business rules, preventing impossible states, and creating self-documenting codebases. This topic covers patterns and anti-patterns seen in large-scale TypeScript applications.
Architectural patterns:
- Branded types — Create nominally unique types from structural types (e.g.,
UserIdvsPostId, both strings) - Opaque types — Hide implementation details behind type-safe wrappers
- Result/Either type — Functional error handling without exceptions
- Domain-Driven Design (DDD) — Use types to model business domain rules
- Dependency injection — Interface-based DI for testable, modular code
Anti-patterns to avoid:
anycreep — Oneanyinfects everything it touches- Over-engineering types — 10-level-deep conditional types no one can read
- Boolean flags —
isLoading && !isError && hasDatainstead of discriminated unions - God types — One massive interface for everything
- Stringly typed — Using plain strings instead of literal unions or branded types
Scaling TypeScript:
- Project references for monorepos
- Strict tsconfig in CI (
noEmitOnError,noUnusedLocals) - Zod/io-ts for runtime validation at system boundaries
- Co-locate types with their domain (no
types/folder with everything)
🏠 Real-world analogy: TypeScript architecture is like city zoning laws. Residential areas (UI types), commercial zones (API types), and industrial parks (data processing types) each have rules that prevent chaos. Branded types are like address systems — "123 Main St" in CityA is different from "123 Main St" in CityB, even though they look the same.
💻 Code Example
1// ⭐ Branded types — nominal typing in a structural type system2type Brand<T, B extends string> = T & { readonly __brand: B };34type UserId = Brand<string, "UserId">;5type PostId = Brand<string, "PostId">;6type Email = Brand<string, "Email">;78// Constructor functions (the only way to create branded types)9function createUserId(id: string): UserId {10 // validate format11 return id as UserId;12}13function createPostId(id: string): PostId {14 return id as PostId;15}16function createEmail(raw: string): Email {17 if (!raw.includes("@")) throw new Error("Invalid email");18 return raw as Email;19}2021function getUser(id: UserId): void { /* ... */ }22function getPost(id: PostId): void { /* ... */ }2324const userId = createUserId("user-123");25const postId = createPostId("post-456");2627getUser(userId); // ✅28// getUser(postId); // ❌ Type error! PostId is not UserId29// getUser("raw"); // ❌ Type error! string is not UserId3031// ⭐ Result type — functional error handling32type Result<T, E = Error> =33 | { success: true; data: T }34 | { success: false; error: E };3536function ok<T>(data: T): Result<T, never> {37 return { success: true, data };38}39function err<E>(error: E): Result<never, E> {40 return { success: false, error };41}4243// Chainable Result operations44function mapResult<T, U, E>(45 result: Result<T, E>,46 fn: (data: T) => U47): Result<U, E> {48 if (result.success) return ok(fn(result.data));49 return result;50}5152// Usage53function parseJSON(input: string): Result<unknown, string> {54 try {55 return ok(JSON.parse(input));56 } catch {57 return err("Invalid JSON");58 }59}6061function validateUser(data: unknown): Result<{ name: string; age: number }, string> {62 if (typeof data !== "object" || data === null) return err("Not an object");63 const obj = data as Record<string, unknown>;64 if (typeof obj.name !== "string") return err("Missing name");65 if (typeof obj.age !== "number") return err("Missing age");66 return ok({ name: obj.name, age: obj.age });67}6869// ⭐ Domain-Driven Design with TypeScript70// Make impossible states impossible!7172// BAD: boolean flags73interface BadOrder {74 isPaid: boolean;75 isShipped: boolean;76 isDelivered: boolean;77 isCancelled: boolean;78 // Can isPaid=false AND isDelivered=true? 😱79}8081// GOOD: discriminated union82type Order =83 | { status: "pending"; items: OrderItem[]; total: number }84 | { status: "paid"; items: OrderItem[]; total: number; paymentId: string }85 | { status: "shipped"; items: OrderItem[]; total: number; paymentId: string; trackingNumber: string }86 | { status: "delivered"; items: OrderItem[]; total: number; paymentId: string; deliveredAt: Date }87 | { status: "cancelled"; items: OrderItem[]; total: number; reason: string };8889interface OrderItem { productId: string; quantity: number; price: number; }9091// Each transition is type-safe92function shipOrder(order: Extract<Order, { status: "paid" }>): Extract<Order, { status: "shipped" }> {93 return {94 ...order,95 status: "shipped",96 trackingNumber: `TRACK-${Date.now()}`97 };98}99// Can only ship a PAID order — calling shipOrder on a "pending" order is a compile error!100101// ⭐ Dependency Injection with interfaces102interface Logger {103 info(msg: string): void;104 error(msg: string, err?: Error): void;105}106107interface Database {108 query<T>(sql: string, params?: unknown[]): Promise<T[]>;109}110111interface UserRepository {112 findById(id: UserId): Promise<User | null>;113 create(data: CreateUserDTO): Promise<User>;114}115116// Implementing with injected dependencies117class UserService {118 constructor(119 private readonly repo: UserRepository,120 private readonly logger: Logger,121 private readonly db: Database122 ) {}123124 async getUser(id: UserId): Promise<Result<User, string>> {125 this.logger.info(`Fetching user ${id}`);126 const user = await this.repo.findById(id);127 if (!user) return err(`User ${id} not found`);128 return ok(user);129 }130}
🏋️ Practice Exercise
Mini Exercise:
- Create branded types for
UserId,Email, andMoneywith validation constructors - Model a state machine for an e-commerce order using discriminated unions where impossible transitions are compile errors
- Implement a
Result<T, E>type withmap,flatMap, andunwrapOrmethods - Refactor a
{ isLoading, isError, data, error }to a discriminated union - Design a dependency injection system using interfaces and a typed container
⚠️ Common Mistakes
Using plain strings for IDs —
getUser(postId)compiles fine but is a bug; use branded types to prevent mixingBoolean flag state —
{ isLoading: true, isError: true }is possible but meaningless; use discriminated unionsCatching errors and re-throwing as
any— use Result types or typed error hierarchies to preserve error type informationGiant
types.tsfiles — co-locate types with their domain modules, not in a central dump fileIgnoring
anyin library types —anyis viral; oneanyin a dependency can infect your entire codebase. Wrap with proper types
💼 Interview Questions
🎤 Mock Interview
Practice a live interview for Architecture, Scalability & Anti-Patterns