Architecture, Scalability & Anti-Patterns

0/5 in this phase0/21 across the roadmap

📖 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., UserId vs PostId, 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:

  • any creep — One any infects everything it touches
  • Over-engineering types — 10-level-deep conditional types no one can read
  • Boolean flagsisLoading && !isError && hasData instead 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

codeTap to expand ⛶
1// ⭐ Branded types — nominal typing in a structural type system
2type Brand<T, B extends string> = T & { readonly __brand: B };
3
4type UserId = Brand<string, "UserId">;
5type PostId = Brand<string, "PostId">;
6type Email = Brand<string, "Email">;
7
8// Constructor functions (the only way to create branded types)
9function createUserId(id: string): UserId {
10 // validate format
11 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}
20
21function getUser(id: UserId): void { /* ... */ }
22function getPost(id: PostId): void { /* ... */ }
23
24const userId = createUserId("user-123");
25const postId = createPostId("post-456");
26
27getUser(userId); // ✅
28// getUser(postId); // ❌ Type error! PostId is not UserId
29// getUser("raw"); // ❌ Type error! string is not UserId
30
31// ⭐ Result type — functional error handling
32type Result<T, E = Error> =
33 | { success: true; data: T }
34 | { success: false; error: E };
35
36function 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}
42
43// Chainable Result operations
44function mapResult<T, U, E>(
45 result: Result<T, E>,
46 fn: (data: T) => U
47): Result<U, E> {
48 if (result.success) return ok(fn(result.data));
49 return result;
50}
51
52// Usage
53function parseJSON(input: string): Result<unknown, string> {
54 try {
55 return ok(JSON.parse(input));
56 } catch {
57 return err("Invalid JSON");
58 }
59}
60
61function 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}
68
69// ⭐ Domain-Driven Design with TypeScript
70// Make impossible states impossible!
71
72// BAD: boolean flags
73interface BadOrder {
74 isPaid: boolean;
75 isShipped: boolean;
76 isDelivered: boolean;
77 isCancelled: boolean;
78 // Can isPaid=false AND isDelivered=true? 😱
79}
80
81// GOOD: discriminated union
82type 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 };
88
89interface OrderItem { productId: string; quantity: number; price: number; }
90
91// Each transition is type-safe
92function 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!
100
101// ⭐ Dependency Injection with interfaces
102interface Logger {
103 info(msg: string): void;
104 error(msg: string, err?: Error): void;
105}
106
107interface Database {
108 query<T>(sql: string, params?: unknown[]): Promise<T[]>;
109}
110
111interface UserRepository {
112 findById(id: UserId): Promise<User | null>;
113 create(data: CreateUserDTO): Promise<User>;
114}
115
116// Implementing with injected dependencies
117class UserService {
118 constructor(
119 private readonly repo: UserRepository,
120 private readonly logger: Logger,
121 private readonly db: Database
122 ) {}
123
124 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:

  1. Create branded types for UserId, Email, and Money with validation constructors
  2. Model a state machine for an e-commerce order using discriminated unions where impossible transitions are compile errors
  3. Implement a Result<T, E> type with map, flatMap, and unwrapOr methods
  4. Refactor a { isLoading, isError, data, error } to a discriminated union
  5. 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 mixing

  • Boolean flag state — { isLoading: true, isError: true } is possible but meaningless; use discriminated unions

  • Catching errors and re-throwing as any — use Result types or typed error hierarchies to preserve error type information

  • Giant types.ts files — co-locate types with their domain modules, not in a central dump file

  • Ignoring any in library types — any is viral; one any in a dependency can infect your entire codebase. Wrap with proper types

💼 Interview Questions

🎤 Mock Interview

Practice a live interview for Architecture, Scalability & Anti-Patterns