7 TypeScript Patterns I Use in Every Project
These aren't groundbreaking. They're the boring patterns that prevent bugs and save hours of debugging.
1. The Result Type (No More try/catch Hell)
// Instead of throwing exceptions everywhere:
function divide(a: number, b: number): number {
if (b === 0) throw new Error("Division by zero");
return a / b;
}
// Use a Result type:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function divide(a: number, b: number): Result<number, string> {
if (b === 0) return { ok: false, error: "Division by zero" };
return { ok: true, value: a / b };
}
// Usage — the compiler FORCES you to handle errors:
const result = divide(10, 0);
if (result.ok) {
console.log(result.value); // TypeScript knows this is number
} else {
console.error(result.error); // TypeScript knows this is string
}
Why this matters: No more "undefined is not a function" because you forgot a try/catch. The type system enforces error handling.
2. Branded Types (Prevent Mixing Up IDs)
// The problem:
function getUser(id: string) { /* ... */ }
function getPost(id: string) { /* ... */ }
// Easy to mix up:
getUser(postId); // TypeScript allows this! But it's wrong!
// The fix — branded types:
type UserId = string & { readonly __brand: 'UserId' };
type PostId = string & { readonly __brand: 'PostId' };
function createUser(id: string): UserId {
return id as UserId;
}
function getUser(id: UserId) { /* ... */ }
function getPost(id: PostId) { /* ... */ }
const userId = createUser('abc');
getUser(userId); // ✅ Works
// getPost(userId); // ❌ TypeScript error! Type 'UserId' is not assignable to 'PostId'
When to use: Anytime you have multiple IDs that look the same (userId, orderId, sessionId) but mean different things.
3. The Builder Pattern for Complex Objects
interface Config {
host: string;
port: number;
database: string;
ssl: boolean;
poolSize: number;
timeout: number;
}
class ConfigBuilder {
private config: Partial<Config> = {};
static create() {
return new ConfigBuilder();
}
withHost(host: string) {
this.config.host = host;
return this; // Chain!
}
withPort(port: number) {
this.config.port = port;
return this;
}
withDatabase(database: string) {
this.config.database = database;
return this;
}
withSSL(ssl = true) {
this.config.ssl = ssl;
return this;
}
withPoolSize(size: number) {
this.config.poolSize = size;
return this;
}
build(): Config {
if (!this.config.host) throw new Error('host is required');
if (!this.config.database) throw new Error('database is required');
return {
host: this.config.host,
port: this.config.port ?? 5432,
database: this.config.database,
ssl: this.config.ssl ?? false,
poolSize: this.config.poolSize ?? 10,
timeout: this.config.timeout ?? 5000,
};
}
}
// Usage — readable, flexible, with defaults:
const config = ConfigBuilder.create()
.withHost('localhost')
.withDatabase('myapp')
.withSSL()
.withPoolSize(20)
.build();
4. Strict Event Emitter Types
// Instead of the loose EventEmitter from Node:
import { EventEmitter } from 'events';
const emitter = new EventEmitter();
emitter.on('data', (payload: unknown) => { /* type safety? nah */ });
emitter.emit('data', 123); // No type check
emitter.emit('typo', 'anything'); // No type check
// Strict typed version:
type EventMap = {
'user:login': { userId: string; timestamp: Date };
'user:logout': { userId: string };
'error': { message: string; code: number };
'data:update': { key: string; value: unknown };
};
class TypedEmitter<Events extends Record<string, unknown>> {
private emitter = new EventEmitter();
on<K extends keyof Events & string>(
event: K,
handler: (payload: Events[K]) => void
) {
this.emitter.on(event, handler as (...args: unknown[]) => void);
return () => this.emitter.off(event, handler as (...args: unknown[]) => void);
}
emit<K extends keyof Events & string>(
event: K,
payload: Events[K]
) {
this.emitter.emit(event, payload);
}
}
// Usage — fully typed:
const bus = new TypedEmitter<EventMap>();
bus.on('user:login', (payload) => {
console.log(payload.userId); // TypeScript knows this is string
console.log(payload.timestamp); // TypeScript knows this is Date
});
bus.on('error', (payload) => {
console.log(payload.code); // TypeScript knows this is number
});
// bus.emit('user:login', { userId: 123 }); // ❌ Error: string expected
// bus.emit('nonexistent', {}); // ❌ Error: not in EventMap
5. The Guard Pattern (Narrow Types in Functions)
interface User {
id: string;
name: string;
role: 'admin' | 'editor' | 'viewer';
email?: string; // Might not be set
}
// Instead of checking everywhere:
function sendEmail(user: User) {
if (!user.email) throw new Error('No email');
// TypeScript still thinks email might be undefined here in some cases
}
// Use type guards:
function hasEmail(user: User): user is User & { email: string } {
return typeof user.email === 'string' && user.email.length > 0;
}
function isAdmin(user: User): user is User & { role: 'admin' } {
return user.role === 'admin';
}
// Now TypeScript knows:
function sendEmail(user: User) {
if (!hasEmail(user)) return; // Guard clause
console.log(user.email.toLowerCase()); // ✅ TypeScript knows email is string
}
function deleteEverything(user: User) {
if (!isAdmin(user)) throw new Error('Unauthorized');
// ✅ TypeScript knows role is 'admin' here
}
6. Zod for Runtime Validation
import { z } from 'zod';
// Define schema once — use for both types AND runtime validation
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['admin', 'editor', 'viewer']),
createdAt: z.string().datetime(),
});
// Infer TypeScript type from schema
type User = z.infer<typeof UserSchema>;
// Validate API responses at runtime:
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
// This throws if data doesn't match schema
return UserSchema.parse(data);
}
// Safe parse (doesn't throw):
const result = UserSchema.safeParse(mysteryData);
if (result.success) {
const user: User = result.data;
} else {
console.error(result.error.flatten());
}
Why Zod over Joi/ Yup? Type inference. Your schema IS your type definition. No duplication.
7. The Async Pool Pattern
// When you need to run many async operations but limit concurrency:
async function asyncPool<T>(
concurrency: number,
items: T[],
fn: (item: T, index: number) => Promise<void>
): Promise<void> {
const executing = new Set<Promise<void>>();
for (const [index, item] of items.entries()) {
const promise = fn(item, index).then(() => {
executing.delete(promise);
});
executing.add(promise);
if (executing.size >= concurrency) {
await Promise.race(executing);
}
}
await Promise.all(executing);
}
// Usage — fetch 100 URLs but only 5 at a time:
const urls = Array.from({ length: 100 }, (_, i) => `https://api.example.com/page/${i}`);
await asyncPool(5, urls, async (url) => {
const response = await fetch(url);
if (!response.ok) console.error(`Failed: ${url}`);
});
// Compare with naive approach (all at once — might crash):
// await Promise.all(urls.map(url => fetch(url))); // 💥 Too many connections
Bonus: The Never-Returning Function
// For functions that always throw (assertions, unreachable code):
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function unreachable(x: never): never {
throw new Error(`Unreachable code reached: ${x}`);
}
// Usage:
type Status = 'loading' | 'success' | 'error';
function handleStatus(status: Status): string {
switch (status) {
case 'loading': return 'Loading...';
case 'success': return 'Done!';
case 'error': return 'Failed!';
}
// If you add a new status type, TypeScript will error here
// because the switch is no longer exhaustive
}
Which patterns do you use daily? Drop your favorites in the comments.
Follow @armorbreak for more TypeScript and Node.js content.
Top comments (0)