TypeScript Advanced Patterns: From Good to Great
"Level up your TypeScript skills with these advanced patterns used in production applications at scale."
Emma Wilson
Senior Developer
Published
January 8, 2024
Mastering TypeScript's Type System
TypeScript's type system is Turing complete, meaning it can express any computation that a Turing machine can perform. While you rarely need that theoretical power, understanding advanced type patterns transforms how you write and architect TypeScript applications. This guide explores patterns used by teams at Google, Microsoft, and Stripe to build type-safe applications at scale.
We'll move beyond basic generics and interfaces into the sophisticated techniques that make impossible states unrepresentable, enforce business rules at compile time, and provide IDE experiences that feel magical. These patterns require investment to learn but pay dividends in reduced runtime errors and faster refactoring.
Conditional Types Deep Dive
Conditional types are the if-statements of TypeScript's type system. They enable type-level logic that adapts based on input types. Mastering them is essential for advanced TypeScript development.
Basic Syntax and Usage:
type IsString = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<123>; // false
The extends keyword checks if a type is assignable to another. The conditional distributes over unions, making it powerful for mapping types:
type ToArray = T extends any ? T[] : never;
type StringsOrNumbers = ToArray; // string[] | number[]
Infer Keyword: The infer keyword extracts types from complex structures. It's essential for unpacking function return types, promise resolutions, and array elements:
type ReturnType = T extends (...args: any[]) => infer R ? R : never;
type PromiseType = T extends Promise ? P : never;
async function fetchUser() { return { id: 1, name: "Alice" }; }
type User = PromiseType>; // { id: number; name: string; }
Real-World Pattern: API Response Normalization:
type ApiResponse =
| { status: 'success'; data: T }
| { status: 'error'; error: string };
type ExtractData = T extends ApiResponse ? D : never;
// Usage: Create a type-safe API client
function createApiClient() {
return async (url: string): Promise>> => {
const response = await fetch(url);
const result: ApiResponse = await response.json();
if (result.status === 'error') throw new Error(result.error);
return result.data;
};
}
Template Literal Types and String Manipulation
TypeScript 4.1 introduced template literal types, enabling type-safe string manipulation. This feature is transformative for routing, event handling, and CSS-in-JS libraries.
Basic Template Literals:
type EventName = `on${Capitalize}`;
type ClickEvent = EventName<'click'>; // 'onClick'
type HoverEvent = EventName<'hover'>; // 'onHover'
Type-Safe Routing: Build routers where paths and parameters are fully typed:
type RouteParams =
T extends `${infer Start}/:${infer Param}/${infer Rest}`
? { [K in Param]: string } & RouteParams<`${Start}/${Rest}`>
: T extends `${string}/:${infer Param}`
? { [K in Param]: string }
: {};
// Usage
type UserRoute = RouteParams<'/users/:id/posts/:postId'>;
// Result: { id: string; postId: string }
CSS-in-JS Type Safety: Enforce design system constraints at the type level:
type Spacing = 'sm' | 'md' | 'lg' | 'xl';
type SpacingToken = `space-${Spacing}`;
type Colors = 'primary' | 'secondary' | 'danger';
type ColorToken = `color-${Colors}`;
type DesignToken = SpacingToken | ColorToken;
// Now autocomplete only shows valid tokens
const styles: Record = {
'space-sm': '0.25rem',
'color-primary': '#007bff',
// 'space-xxl': '...' // Error: Type '"space-xxl"' is not assignable
};
Recursive Types and Tree Structures
Recursive types describe self-referential data structures like trees, nested menus, and JSON. They're essential for working with hierarchical data.
JSON Type Definition: A complete type for any valid JSON:
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [key: string]: JSONValue };
// Usage: Type-safe JSON parsing
function parseJSON(input: string): JSONValue {
return JSON.parse(input);
}
Nested Menu Structure:
interface MenuItem {
label: string;
icon?: string;
action?: () => void;
children?: MenuItem[];
}
// Type-safe recursive component
const Menu: React.FC<{ items: MenuItem[] }> = ({ items }) => (
{items.map(item => (
-
{item.label}
{item.children && }
))}
);
Deep Partial and Deep Required: Utility types that recurse into nested objects:
type DeepPartial = {
[P in keyof T]?: T[P] extends object ? DeepPartial : T[P];
};
type DeepRequired = {
[P in keyof T]-?: T[P] extends object ? DeepRequired : T[P];
};
// Usage with configuration objects
interface Config {
server: {
port: number;
ssl: { cert: string; key: string };
};
database: { url: string };
}
type PartialConfig = DeepPartial;
// All properties optional at every level
Discriminated Unions and Exhaustiveness Checking
Discriminated unions are TypeScript's most powerful feature for modeling state. Combined with exhaustiveness checking, they eliminate entire classes of runtime errors.
State Machine Pattern: Model UI states precisely:
type AsyncState =
| { status: 'idle' }
| { status: 'loading'; progress: number }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function handleState(state: AsyncState): string {
switch (state.status) {
case 'idle': return 'Waiting to start...';
case 'loading': return `Loading: ${state.progress}%`;
case 'success': return `Loaded: ${state.data}`;
case 'error': return `Error: ${state.error.message}`;
default:
// Compile-time exhaustiveness check
const _exhaustive: never = state;
return _exhaustive;
}
}
The never assignment in the default case ensures that if you add a new status to AsyncState, TypeScript will error until you handle it. This makes impossible states unrepresentable and ensures all cases are handled.
Redux-Style Actions:
type UserAction =
| { type: 'USER_LOGIN'; payload: { userId: string; token: string } }
| { type: 'USER_LOGOUT' }
| { type: 'USER_UPDATE_PROFILE'; payload: { name: string; email: string } };
function userReducer(state: UserState, action: UserAction): UserState {
switch (action.type) {
case 'USER_LOGIN':
return { ...state, isAuthenticated: true, userId: action.payload.userId };
case 'USER_LOGOUT':
return { ...state, isAuthenticated: false, userId: null };
case 'USER_UPDATE_PROFILE':
return { ...state, profile: action.payload };
default:
return assertNever(action);
}
}
function assertNever(x: never): never {
throw new Error(`Unexpected object: ${x}`);
}
Type Guards and Custom Narrowing
Type guards are functions that narrow types based on runtime checks. Custom type guards enable sophisticated validation logic with type safety.
Built-in Type Guards:
// typeof guards for primitives
function isString(value: unknown): value is string {
return typeof value === 'string';
}
// instanceof guards for classes
function isDate(value: unknown): value is Date {
return value instanceof Date;
}
// Array.isArray guard
function isArray(value: unknown): value is T[] {
return Array.isArray(value);
}
Custom Validation Guards:
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
}
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'email' in value &&
'role' in value &&
typeof (value as User).id === 'number' &&
typeof (value as User).name === 'string' &&
['admin', 'user'].includes((value as User).role)
);
}
// Usage with API responses
async function fetchUser(id: number): Promise {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
if (!isUser(data)) {
throw new Error('Invalid user data received from API');
}
return data; // TypeScript knows this is User
}
Branded Types for Type-Safe IDs: Prevent mixing up different ID types:
type Brand = K & { __brand: T };
type UserId = Brand;
type OrderId = Brand;
function createUserId(id: number): UserId {
return id as UserId;
}
function createOrderId(id: number): OrderId {
return id as OrderId;
}
// Now these are incompatible
const userId = createUserId(1);
const orderId = createOrderId(1);
function getUser(id: UserId) { /* ... */ }
getUser(userId); // OK
// getUser(orderId); // Error: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'
Mapped Types and Key Remapping
Mapped types create new types by transforming properties of existing types. TypeScript 4.1 added key remapping, making them even more powerful.
Basic Mapped Types:
// Make all properties optional
type Partial = { [P in keyof T]?: T[P] };
// Make all properties required
type Required = { [P in keyof T]-?: T[P] };
// Make all properties readonly
type Readonly = { readonly [P in keyof T]: T[P] };
// Remove readonly modifier
type Mutable = { -readonly [P in keyof T]: T[P] };
// Pick specific keys
type Pick = { [P in K]: T[P] };
// Omit specific keys
type Omit = Pick>;
Key Remapping with as:
// Transform keys to camelCase
type CamelCase = S extends `${infer P}_${infer Q}`
? `${P}${Capitalize>}`
: S;
type Camelize = {
[K in keyof T as CamelCase]: T[K] extends object
? Camelize
: T[K]
};
// Usage
interface Snake_Case {
user_name: string;
email_address: string;
contact_info: { phone_number: string };
}
type CamelCaseType = Camelize;
// { userName: string; emailAddress: string; contactInfo: { phoneNumber: string } }
Event Payload Mapping: Create type-safe event emitters:
type EventMap = {
'user:login': { userId: string; timestamp: number };
'user:logout': { userId: string };
'error': { message: string; code: number };
};
type EventPayload = EventMap[T];
class TypedEventEmitter {
emit(event: T, payload: EventPayload) {
// Implementation
}
on(event: T, handler: (payload: EventPayload) => void) {
// Implementation
}
}
const emitter = new TypedEventEmitter();
emitter.emit('user:login', { userId: '123', timestamp: Date.now() }); // OK
// emitter.emit('user:login', { userId: '123' }); // Error: Property 'timestamp' is missing
Variance and Type Relationships
Understanding variance—how type relationships work with generics—is crucial for designing robust APIs.
The Variance Problem:
interface Animal { name: string; }
interface Dog extends Animal { breed: string; }
// Is Array assignable to Array?
// Yes - arrays are covariant
const dogs: Dog[] = [{ name: 'Rex', breed: 'German Shepherd' }];
const animals: Animal[] = dogs; // OK
// But what about functions?
type AnimalHandler = (animal: Animal) => void;
type DogHandler = (dog: Dog) => void;
// Is DogHandler assignable to AnimalHandler?
// No - functions are contravariant in their parameters
const handleAnimal: AnimalHandler = (animal) => console.log(animal.name);
const handleDog: DogHandler = handleAnimal; // Error!
Readonly and Mutable Variance:
// Mutable arrays are invariant (strict)
interface MutableArray {
push(item: T): void;
pop(): T;
}
// Readonly arrays are covariant
interface ReadonlyArray {
readonly length: number;
get(index: number): T;
}
// Function arguments are contravariant
interface Comparator {
compare(a: T, b: T): number;
}
Understanding these relationships helps you design APIs that are flexible yet type-safe. Covariance is safe for producers (returning values), contravariance for consumers (accepting values).
Performance and Compilation Optimization
Complex types can slow down compilation. These patterns help maintain type safety without sacrificing build performance.
Avoid Deep Nesting: TypeScript has a recursion depth limit (around 50 levels). Flatten types when possible:
// Bad: Deeply nested
type Nested = { a: { b: { c: { d: string } } } };
// Better: Flattened with path notation
type Flattened = { 'a.b.c.d': string };
Use Interfaces over Type Aliases for Objects: Interfaces are generally faster to compile and provide better error messages:
// Prefer
interface User {
name: string;
email: string;
}
// Over
type User = {
name: string;
email: string;
};
Conditional Type Distribution: Be aware that conditional types distribute over unions. Sometimes you need to prevent this:
// Distributes: ToArray = string[] | number[]
type ToArray = T extends any ? T[] : never;
// Prevents distribution: ToArrayNonDist = (string | number)[]
type ToArrayNonDist = [T] extends [any] ? T[] : never;
Real-World Architecture Patterns
Putting it all together, here are architectural patterns used in production systems.
Type-Safe Dependency Injection:
interface ServiceMap {
logger: Logger;
database: Database;
cache: Cache;
emailService: EmailService;
}
type ServiceKey = keyof ServiceMap;
class Container {
private services = new Map();
register(key: K, service: ServiceMap[K]) {
this.services.set(key, service);
}
resolve(key: K): ServiceMap[K] {
const service = this.services.get(key);
if (!service) throw new Error(`Service ${key} not registered`);
return service as ServiceMap[K];
}
}
// Usage
const container = new Container();
container.register('logger', new ConsoleLogger());
const logger = container.resolve('logger'); // TypeScript knows this is Logger
Builder Pattern with Method Chaining:
class QueryBuilder {
private conditions: string[] = [];
private params: unknown[] = [];
where(
field: K,
operator: '=',
value: T[K]
): this {
this.conditions.push(`${String(field)} = ?`);
this.params.push(value);
return this;
}
orderBy(field: K, direction: 'ASC' | 'DESC' = 'ASC'): this {
// Implementation
return this;
}
build(): { sql: string; params: unknown[] } {
return {
sql: `SELECT * FROM table WHERE ${this.conditions.join(' AND ')}`,
params: this.params
};
}
}
interface User {
id: number;
name: string;
age: number;
}
const query = new QueryBuilder()
.where('name', '=', 'Alice') // Autocomplete knows 'name' is valid
.where('age', '=', 30) // Type error if you pass a string
.orderBy('id', 'DESC')
.build();
Conclusion
Advanced TypeScript patterns transform your codebase from error-prone JavaScript with types to a robust system where the compiler verifies your logic. The investment in learning these patterns pays off through fewer production bugs, faster refactoring, and superior developer experience.
Start by implementing discriminated unions for your application state. Add template literal types for your routing and theming systems. Use branded types to prevent ID confusion. Gradually adopt more complex patterns as your comfort grows.
Remember that types are a means to an end—reliable, maintainable software. Don't pursue type sophistication for its own sake. The best TypeScript code is code that other developers can understand and modify confidently. Use these patterns to make impossible states unrepresentable, enforce business rules at compile time, and create APIs that guide users toward correct usage.
Enjoyed this article?
If you found this helpful, consider sharing it with your network. It helps us grow.