Back to Blog
TypeScriptJavaScriptTypesBest Practices

Advanced TypeScript Patterns for Production Code

Level up your TypeScript skills. From conditional types to template literals to type-safe API patterns.

B
Bootspring Team
Engineering
January 28, 2024
6 min read

TypeScript's type system is powerful enough to catch bugs at compile time that would otherwise reach production. Here are advanced patterns that make your code safer and more expressive.

Discriminated Unions#

1// Type-safe state handling 2type LoadingState = { status: 'loading' }; 3type SuccessState<T> = { status: 'success'; data: T }; 4type ErrorState = { status: 'error'; error: Error }; 5 6type AsyncState<T> = LoadingState | SuccessState<T> | ErrorState; 7 8function handleState<T>(state: AsyncState<T>): string { 9 switch (state.status) { 10 case 'loading': 11 return 'Loading...'; 12 case 'success': 13 return `Data: ${state.data}`; // TypeScript knows data exists 14 case 'error': 15 return `Error: ${state.error.message}`; // TypeScript knows error exists 16 } 17} 18 19// Exhaustive checking 20function assertNever(x: never): never { 21 throw new Error(`Unexpected value: ${x}`); 22} 23 24function handleStateExhaustive<T>(state: AsyncState<T>): string { 25 switch (state.status) { 26 case 'loading': 27 return 'Loading...'; 28 case 'success': 29 return `Data: ${state.data}`; 30 case 'error': 31 return `Error: ${state.error.message}`; 32 default: 33 return assertNever(state); // Compiler error if case missing 34 } 35}

Template Literal Types#

1// Type-safe event names 2type EventName = `on${Capitalize<string>}`; 3type ValidEvent = 'onClick' | 'onHover' | 'onSubmit'; 4 5// CSS units 6type CSSUnit = 'px' | 'rem' | 'em' | '%'; 7type CSSValue = `${number}${CSSUnit}`; 8 9const padding: CSSValue = '16px'; // ✅ 10const margin: CSSValue = '1.5rem'; // ✅ 11// const invalid: CSSValue = '16'; // ❌ Error 12 13// API routes 14type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; 15type ApiRoute = `/${string}`; 16type ApiEndpoint = `${HttpMethod} ${ApiRoute}`; 17 18const endpoint: ApiEndpoint = 'GET /users'; // ✅ 19const createUser: ApiEndpoint = 'POST /users'; // ✅

Conditional Types#

1// Extract return type 2type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never; 3 4// Extract promise value 5type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T; 6 7// Filter array type 8type Filter<T, U> = T extends U ? T : never; 9 10type Numbers = Filter<string | number | boolean, number>; // number 11 12// Distributive conditional types 13type ToArray<T> = T extends any ? T[] : never; 14type StrOrNumArray = ToArray<string | number>; // string[] | number[] 15 16// Non-distributive 17type ToArrayNonDist<T> = [T] extends [any] ? T[] : never; 18type StrOrNumArray2 = ToArrayNonDist<string | number>; // (string | number)[]

Mapped Types#

1// Make all properties optional 2type Partial<T> = { [P in keyof T]?: T[P] }; 3 4// Make all properties required 5type Required<T> = { [P in keyof T]-?: T[P] }; 6 7// Make all properties readonly 8type Readonly<T> = { readonly [P in keyof T]: T[P] }; 9 10// Pick specific properties 11type Pick<T, K extends keyof T> = { [P in K]: T[P] }; 12 13// Transform property types 14type Nullable<T> = { [P in keyof T]: T[P] | null }; 15 16// Rename properties 17type Getters<T> = { 18 [P in keyof T as `get${Capitalize<string & P>}`]: () => T[P]; 19}; 20 21interface User { 22 name: string; 23 age: number; 24} 25 26type UserGetters = Getters<User>; 27// { getName: () => string; getAge: () => number }

Type-Safe Builder Pattern#

1class QueryBuilder<T extends object = {}> { 2 private query: T = {} as T; 3 4 select<K extends string>( 5 fields: K[] 6 ): QueryBuilder<T & { select: K[] }> { 7 (this.query as any).select = fields; 8 return this as any; 9 } 10 11 where<W extends object>( 12 conditions: W 13 ): QueryBuilder<T & { where: W }> { 14 (this.query as any).where = conditions; 15 return this as any; 16 } 17 18 limit(n: number): QueryBuilder<T & { limit: number }> { 19 (this.query as any).limit = n; 20 return this as any; 21 } 22 23 build(): T { 24 return this.query; 25 } 26} 27 28const query = new QueryBuilder() 29 .select(['name', 'email']) 30 .where({ status: 'active' }) 31 .limit(10) 32 .build(); 33 34// query type: { select: string[]; where: { status: string }; limit: number }

Type-Safe API Client#

1// Define API schema 2interface ApiSchema { 3 '/users': { 4 GET: { response: User[] }; 5 POST: { body: CreateUser; response: User }; 6 }; 7 '/users/:id': { 8 GET: { response: User }; 9 PUT: { body: UpdateUser; response: User }; 10 DELETE: { response: void }; 11 }; 12} 13 14type PathParams<T extends string> = 15 T extends `${infer _Start}:${infer Param}/${infer Rest}` 16 ? { [K in Param | keyof PathParams<Rest>]: string } 17 : T extends `${infer _Start}:${infer Param}` 18 ? { [K in Param]: string } 19 : {}; 20 21class ApiClient { 22 async request< 23 Path extends keyof ApiSchema, 24 Method extends keyof ApiSchema[Path] 25 >( 26 method: Method, 27 path: Path, 28 options?: { 29 params?: PathParams<Path & string>; 30 body?: ApiSchema[Path][Method] extends { body: infer B } ? B : never; 31 } 32 ): Promise< 33 ApiSchema[Path][Method] extends { response: infer R } ? R : never 34 > { 35 // Implementation 36 return {} as any; 37 } 38} 39 40const client = new ApiClient(); 41 42// Type-safe API calls 43const users = await client.request('GET', '/users'); 44// users: User[] 45 46const user = await client.request('GET', '/users/:id', { 47 params: { id: '123' }, 48}); 49// user: User 50 51await client.request('POST', '/users', { 52 body: { name: 'John', email: 'john@example.com' }, 53});

Branded Types#

1// Prevent mixing similar types 2declare const brand: unique symbol; 3 4type Brand<T, B> = T & { [brand]: B }; 5 6type UserId = Brand<string, 'UserId'>; 7type PostId = Brand<string, 'PostId'>; 8 9function createUserId(id: string): UserId { 10 return id as UserId; 11} 12 13function createPostId(id: string): PostId { 14 return id as PostId; 15} 16 17function getUser(id: UserId): User { 18 // ... 19} 20 21const userId = createUserId('user-123'); 22const postId = createPostId('post-456'); 23 24getUser(userId); // ✅ 25// getUser(postId); // ❌ Type error! 26// getUser('raw-string'); // ❌ Type error!

Type Guards#

1// Custom type guards 2function isString(value: unknown): value is string { 3 return typeof value === 'string'; 4} 5 6function isUser(value: unknown): value is User { 7 return ( 8 typeof value === 'object' && 9 value !== null && 10 'id' in value && 11 'email' in value 12 ); 13} 14 15// Assertion functions 16function assertIsUser(value: unknown): asserts value is User { 17 if (!isUser(value)) { 18 throw new Error('Value is not a User'); 19 } 20} 21 22function processValue(value: unknown) { 23 assertIsUser(value); 24 // TypeScript now knows value is User 25 console.log(value.email); 26}

Utility Types#

1// Deep partial 2type DeepPartial<T> = { 3 [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]; 4}; 5 6// Deep readonly 7type DeepReadonly<T> = { 8 readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P]; 9}; 10 11// Exact type (no extra properties) 12type Exact<T, U extends T> = T & { 13 [K in Exclude<keyof U, keyof T>]: never; 14}; 15 16// NonNullable nested 17type DeepNonNullable<T> = { 18 [P in keyof T]: NonNullable<T[P]> extends object 19 ? DeepNonNullable<NonNullable<T[P]>> 20 : NonNullable<T[P]>; 21};

Best Practices#

DO: ✓ Use discriminated unions for state ✓ Prefer type inference where clear ✓ Use branded types for domain safety ✓ Write custom type guards ✓ Use template literals for patterns DON'T: ✗ Overuse 'any' or 'as' ✗ Create overly complex types ✗ Ignore compiler errors ✗ Skip type documentation

Conclusion#

Advanced TypeScript patterns encode business logic in the type system. Discriminated unions handle state, branded types prevent mixing, and conditional types enable flexible APIs.

The goal is catching bugs at compile time—before they reach production.

Share this article

Help spread the word about Bootspring