Bulletproof TypeScript Patterns for Production Apps
Production-ready TypeScript patterns that will help you write more maintainable and robust applications.
Bulletproof TypeScript Patterns for Production Apps
TypeScript has become the standard choice for large-scale JavaScript applications, but using it effectively requires more than just adding type annotations. In this post, I’ll share battle-tested patterns I use in production applications to maximize type safety while maintaining code flexibility.
Discriminated Unions for State Management
When managing state, especially with reducers, discriminated unions provide compile-time guarantees about which actions can be performed in which states:
type RequestState<T> = | { status: "idle" } | { status: "loading" } | { status: "success"; data: T } | { status: "error"; error: Error };
// Usagefunction UserProfile() { const [userState, setUserState] = useState<RequestState<User>>({ status: "idle", });
useEffect(() => { const fetchUser = async () => { setUserState({ status: "loading" }); try { const user = await api.getUser(); setUserState({ status: "success", data: user }); } catch (error) { setUserState({ status: "error", error: error instanceof Error ? error : new Error(String(error)), }); } };
fetchUser(); }, []);
// Type-safe rendering based on state if (userState.status === "loading") { return <Spinner />; }
if (userState.status === "error") { return <ErrorMessage message={userState.error.message} />; }
if (userState.status === "success") { // TypeScript knows userState.data is available here return <UserDetails user={userState.data} />; }
return ( <button onClick={() => setUserState({ status: "loading" })}> Load User </button> );}The Builder Pattern for Complex Objects
When creating objects with many optional properties, the builder pattern provides a fluent interface with type safety:
class QueryBuilder<T> { private query: Partial<T> = {};
where<K extends keyof T>(key: K, value: T[K]): this { this.query[key] = value; return this; }
whereIn<K extends keyof T>(key: K, values: Array<T[K]>): this { // Implementation return this; }
orderBy<K extends keyof T>(key: K, direction: "asc" | "desc" = "asc"): this { // Implementation return this; }
limit(limit: number): this { // Implementation return this; }
build(): Partial<T> { return { ...this.query }; }}
// Usageconst usersQuery = new QueryBuilder<User>() .where("status", "active") .whereIn("role", ["admin", "editor"]) .orderBy("createdAt", "desc") .limit(10) .build();Type Guards for Runtime Validation
Type guards bridge the gap between compile-time and runtime type checking:
interface User { id: string; name: string; email: string;}
// Type guard functionfunction isUser(obj: unknown): obj is User { return ( typeof obj === "object" && obj !== null && "id" in obj && "name" in obj && "email" in obj && typeof (obj as User).id === "string" && typeof (obj as User).name === "string" && typeof (obj as User).email === "string" );}
// Usage with API responsesasync function getUser(id: string): Promise<User> { const response = await fetch(`/api/users/${id}`); const data = await response.json();
if (isUser(data)) { return data; // TypeScript knows data is User }
throw new Error("Invalid user data received from API");}Branded Types for Type Safety
Sometimes you need to differentiate between types with the same structure but different semantics:
// Define branded typestype UserId = string & { readonly __brand: unique symbol };type OrderId = string & { readonly __brand: unique symbol };
// Create factory functionsfunction createUserId(id: string): UserId { return id as UserId;}
function createOrderId(id: string): OrderId { return id as OrderId;}
// Now these functions will only accept the correct typefunction fetchUser(id: UserId) { // Implementation}
function fetchOrder(id: OrderId) { // Implementation}
// Usageconst userId = createUserId("user-123");const orderId = createOrderId("order-456");
fetchUser(userId); // ✓ OKfetchOrder(orderId); // ✓ OKfetchUser(orderId); // ✗ Type error: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'Advanced Type Utilities
TypeScript’s type system is Turing-complete, allowing for powerful type transformations:
// Make all properties optional and nullabletype Nullable<T> = { [P in keyof T]: T[P] | null };
// Extract the type of a Promise's resolved valuetype Awaited<T> = T extends Promise<infer R> ? R : T;
// Make specific properties requiredtype RequireKeys<T, K extends keyof T> = T & { [P in K]-?: T[P] };
// Create a type with only the specified keystype Pick<T, K extends keyof T> = { [P in K]: T[P] };
// Remove readonly modifier from propertiestype Mutable<T> = { -readonly [P in keyof T]: T[P] };Dependency Injection with Interfaces
For testable, maintainable code, use interfaces for services and inject dependencies:
interface Logger { debug(message: string): void; info(message: string): void; warn(message: string): void; error(message: string, error?: Error): void;}
interface UserService { getUser(id: string): Promise<User>; updateUser(user: User): Promise<User>;}
class UserController { constructor(private userService: UserService, private logger: Logger) {}
async handleGetUser(id: string): Promise<User> { this.logger.info(`Fetching user: ${id}`); try { const user = await this.userService.getUser(id); return user; } catch (error) { this.logger.error("Failed to get user", error as Error); throw error; } }}
// Easy to test with mocksconst mockLogger: Logger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),};
const mockUserService: UserService = { getUser: jest.fn(), updateUser: jest.fn(),};
const controller = new UserController(mockUserService, mockLogger);Conclusion
TypeScript’s power goes far beyond simple type annotations. By leveraging these patterns, you can build applications that are not only type-safe but also more maintainable, testable, and robust in production.
What are your favorite TypeScript patterns? Share in the comments!
Related Posts
Continue exploring similar topics