10 TypeScript Patterns for Cleaner Code
A practical collection of 10 TypeScript patterns — from discriminated unions and template literal types to the satisfies operator — that make your codebase more type-safe and maintainable.
TypeScript is more than JavaScript with type annotations. Used correctly, it catches bugs at compile time, improves developer experience, and makes codebases dramatically easier to refactor. These are 10 patterns the Ventra Rocket team uses daily across all production projects.
1. Discriminated Unions Instead of Boolean Flags
Multiple boolean flags can produce invalid states that are impossible to represent correctly:
// Bad: allows impossible states like isLoading=true AND error="..."
interface DataState {
isLoading: boolean;
data?: User[];
error?: string;
}
// Good: discriminated union guarantees valid state at all times
type DataState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string };
function renderState(state: DataState) {
switch (state.status) {
case 'loading': return <Spinner />;
case 'success': return <UserList users={state.data} />; // data guaranteed present
case 'error': return <ErrorView message={state.error} />;
default: return null;
}
}
2. Template Literal Types for Type-Safe Strings
type EventName = `on${Capitalize<string>}`;
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
type ApiRoute = `/api/v${number}/${string}`;
function request(method: HttpMethod, url: ApiRoute) { /* ... */ }
request('GET', '/api/v1/users'); // OK
request('GET', '/v1/users'); // Error: must start with /api/v
3. The satisfies Operator (TS 4.9+)
// satisfies preserves literal types while still checking against the interface
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3,
} satisfies Record<string, string | number>;
const palette = {
primary: '#06d6a0',
secondary: '#8b5cf6',
} satisfies Record<string, `#${string}`>;
// Compile error if any value is not a valid hex colour string
4. Infer in Conditional Types
// Extract the resolved type from a Promise
type Awaited<T> = T extends Promise<infer U> ? U : T;
// Extract route params from a path string literal
type RouteParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param | keyof RouteParams<Rest>]: string }
: T extends `${string}:${infer Param}`
? { [K in Param]: string }
: {};
type UserRoute = RouteParams<'/users/:userId/posts/:postId'>;
// { userId: string; postId: string }
5. Builder Pattern with Method Chaining
class QueryBuilder<T> {
private filters: Partial<T>[] = [];
private sortField?: keyof T;
private limitValue = 50;
where(filter: Partial<T>): this {
this.filters.push(filter);
return this;
}
orderBy(field: keyof T): this {
this.sortField = field;
return this;
}
limit(n: number): this {
this.limitValue = n;
return this;
}
build(): string {
return JSON.stringify({
filters: this.filters,
sort: this.sortField,
limit: this.limitValue,
});
}
}
const query = new QueryBuilder<User>()
.where({ role: 'admin' })
.orderBy('createdAt')
.limit(20)
.build();
6. Readonly and Deep Immutability
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
type ImmutableConfig = DeepReadonly<{
database: { host: string; port: number };
features: string[];
}>;
// Every nested property is readonly — mutation causes a compile error
7. Function Overloads for Clear APIs
function parseInput(input: string): number;
function parseInput(input: number): string;
function parseInput(input: string | number): string | number {
if (typeof input === 'string') return parseInt(input, 10);
return input.toString();
}
const num = parseInput('42'); // TypeScript knows return type is number
const str = parseInput(42); // TypeScript knows return type is string
8. Mapped Types for Object Transformation
// Make all values into arrays
type Arrayify<T> = { [K in keyof T]: T[K][] };
// Extract only method keys
type MethodKeys<T> = {
[K in keyof T]: T[K] extends (...args: unknown[]) => unknown ? K : never
}[keyof T];
// Generate event map from state type
type StateEventMap<T> = {
[K in keyof T as `${string & K}Changed`]: (newValue: T[K]) => void;
};
9. Branded Types for Domain Primitives
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type ProductId = Brand<string, 'ProductId'>;
function createUserId(id: string): UserId { return id as UserId; }
function getUser(id: UserId) { /* ... */ }
function getOrder(id: OrderId) { /* ... */ }
const userId = createUserId('user-123');
getUser(userId); // OK
getOrder(userId); // Compile error: UserId is not assignable to OrderId
10. as const for Precise Literal Types
const ROUTES = {
home: '/',
about: '/about',
blog: '/blog',
contact: '/contact',
} as const;
type Route = typeof ROUTES[keyof typeof ROUTES];
// '/' | '/about' | '/blog' | '/contact'
// Precise tuple types
function createPoint(x: number, y: number) {
return [x, y] as const; // readonly [number, number] — not number[]
}
Conclusion
TypeScript is most powerful when you let the compiler do the work. Discriminated unions eliminate null checks, branded types prevent logic bugs, and template literal types move runtime errors to compile time. Investing time in these patterns produces codebases with fewer bugs and far easier refactoring paths.
Related Articles
Next.js App Router: Patterns for Production
A practical guide to building fast, scalable web apps with the Next.js App Router — server components, streaming, parallel routes, intercepting routes, and caching strategies.
React State Management in 2026: Zustand, TanStack Query, and URL State
Choosing the right state management for React — TanStack Query for server state, Zustand for global UI state, URL state for filters, useState for local state.
Web Security Essentials: OWASP Top 10 for Node.js and React
Practical security — preventing SQL injection, XSS, broken authentication, and IDOR in Node.js applications.