7 TypeScript Patterns I Use in Every Project
These patterns caught bugs before they reached production. Every single time.
1. The Discriminated Union Pattern
// ❌ BAD: Using optional fields to distinguish states
interface User {
id: string;
email: string;
name?: string; // Is this "loading" or "no name"?
error?: string; // Is this "error" or "no error"?
}
// ✅ GOOD: Discriminated union — every state is explicit
type UserState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; user: { id: string; email: string; name: string } }
| { status: 'error'; error: string };
function renderUser(state: UserState): string {
// TypeScript KNOWS which fields exist in each state
switch (state.status) {
case 'idle': return 'Enter a user ID';
case 'loading': return 'Loading...';
case 'success': return `Hello, ${state.user.name}`; // ✅ user is guaranteed here
case 'error': return `Error: ${state.error}`; // ✅ error is guaranteed here
}
}
Why it matters: No more if (user && user.name) checks. TypeScript narrows the type automatically.
2. Branded Types for Type Safety
// Problem: Mixing up similar string types
function getUser(id: string) { /* ... */ }
function getPost(id: string) { /* ... */ */
getUser(postId); // Oops! Wrong ID but TypeScript doesn't catch it
// Solution: Branded types
type UserId = string & { __brand: 'UserId' };
type PostId = string & { __brand: 'PostId' };
type Email = string & { __brand: 'Email' };
// Factory functions to create branded types
function UserId(id: string): UserId { return id as UserId; }
function PostId(id: string): PostId { return id as PostId; }
function Email(email: string): Email {
if (!email.includes('@')) throw new Error('Invalid email');
return email as Email;
}
// Now TypeScript catches mistakes!
function getUser(id: UserId) { /* ... */ }
function getPost(id: PostId) { /* ... */ }
const uid = UserId('abc123');
const pid = PostId('xyz789');
getUser(uid); // ✅
getUser(pid); // ❌ Error: Type 'PostId' is not assignable to type 'UserId'
3. The Result/Either Pattern for Errors
// Instead of throwing errors, return a type-safe result
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
async function fetchUser(id: UserId): Promise<Result<User>> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
return { success: false, error: new Error(`HTTP ${response.status}`) };
}
const user = await response.json();
return { success: true, data: user };
} catch (err) {
return { success: false, error: err as Error };
}
}
// Usage — no try/catch needed!
const result = await fetchUser(someId);
if (result.success) {
console.log(result.data.name); // TypeScript knows .data exists
} else {
console.error(result.error.message); // TypeScript knows .error exists
}
4. Const Assertions for Exact Types
// Without const assertion:
const ROLES = ['admin', 'user', 'moderator'];
// Type: string[] — you can push anything!
// With const assertion:
const ROLES = ['admin', 'user', 'moderator'] as const;
// Type: readonly ["admin", "user", "moderator"] — exact values!
type Role = typeof ROLES[number]; // "admin" | "user" | "moderator"
function setRole(role: Role) { /* ... */ }
setRole('admin'); // ✅
setRole('superadmin'); // ❌ Error!
// Also works with objects:
const HTTP_STATUS = {
OK: 200,
CREATED: 201,
NOT_FOUND: 404,
SERVER_ERROR: 500,
} as const;
type StatusCode = typeof HTTP_STATUS[keyof typeof HTTP_STATUS];
// 200 | 201 | 404 | 500
5. Template Literal Types for String Validation
// Enforce specific string patterns at compile time:
type EventName = `${string}:${string}`;
// "click:button", "submit:form", etc.
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
type ApiRoute = `/api/${string}`;
// Real example: typed event emitter
type Events = {
'user:login': { userId: string; timestamp: number };
'user:logout': { userId: string };
'post:create': { postId: string; authorId: string };
};
class TypedEmitter<T extends Record<string, unknown>> {
on<K extends keyof T>(event: K, handler: (data: T[K]) => void) {}
emit<K extends keyof T>(event: K, data: T[K]) {}
}
const emitter = new TypedEmitter<Events>();
emitter.on('user:login', (data) => {
console.log(data.userId); // ✅ TypeScript knows the shape
console.log(data.timestamp); // ✅
});
emitter.on('user:login', (data) => {
console.log(data.postId); // ❌ Error! 'postId' not in 'user:login'
});
emitter.emit('post:create', { // ✅ Must match exact shape
postId: 'abc',
authorId: 'def',
});
6. Satisfies for Validation + Inference
// Problem: 'as const' gives exact types but makes them hard to use
// Problem: Explicit typing loses inference
// Solution: satisfies — validates shape AND infers wider types
const config = {
api: {
baseUrl: 'https://api.example.com',
timeout: 5000,
retries: 3,
},
features: {
darkMode: true,
notifications: false,
},
} satisfies {
api: { baseUrl: string; timeout: number; retries: number };
features: Record<string, boolean>;
};
// config.api.baseUrl is string (not literal 'https://...')
// But the structure was validated at write time
// Can still do math on numbers:
config.config.api.timeout *= 2; // Works because it's number, not 5000
7. The Builder Pattern with Method Chaining
class QueryBuilder<T> {
private clauses: string[] = [];
private params: unknown[] = [];
where(condition: string, value: unknown): this {
this.clauses.push(`WHERE ${condition}`);
this.params.push(value);
return this;
}
limit(count: number): this {
this.clauses.push(`LIMIT ?`);
this.params.push(count);
return this;
}
orderBy(column: keyof T, direction: 'ASC' | 'DESC' = 'ASC'): this {
this.clauses.push(`ORDER BY ${String(column)} ${direction}`);
return this;
}
build(): { sql: string; params: unknown[] } {
return {
sql: this.clauses.join(' '),
params: [...this.params],
};
}
}
// Usage:
const query = new QueryBuilder<User>()
.where('active = ?', true)
.where('role = ?', 'admin')
.limit(10)
.orderBy('created_at', 'DESC')
.build();
// query.sql = "WHERE active = ? WHERE role = ? LIMIT ? ORDER BY created_at DESC"
// query.params = [true, "admin", 10]
Bonus: My tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
Key flags that catch bugs:
-
strict— All strict checks enabled -
noUncheckedIndexedAccess—arr[0]could be undefined (catches off-by-one!) -
exactOptionalPropertyTypes—{ x?: string }won't accept{ x: undefined } -
noFallthroughCasesInSwitch— Catches missingbreakstatements
Which TypeScript pattern saves you the most headaches?
Follow @armorbreak for more TypeScript content.
Top comments (0)