title: "TypeScript Patterns I Wish I Knew Earlier"
date: 2026-04-25
readingTime: 9 min read
tags: ["TypeScript", "Best Practices"]
After 16 years of software development—most of it before TypeScript existed—I've learned that good type design prevents bugs better than any amount of testing.
This post covers the TypeScript patterns I wish someone had taught me earlier. Each one solved a real problem I encountered in production code.
Problem: All strings are the same type. UserId and Email are both string, but mixing them causes bugs.
Solution: Brand primitive types.
// Before: Easy to mix up
function getUser(userId: string) { ... }
getUser(userEmail); // Compiles, but wrong!
// After: Branded types
type UserId = string & { readonly brand: unique symbol };
type Email = string & { readonly brand: unique symbol };
function createUserId(id: string): UserId {
return id as UserId;
}
function getUser(userId: UserId) { ... }
// Usage
const userId = createUserId("123");
getUser(userId); // ✅
getUser("123" as Email); // ❌ Type error
Real-world example:
// In our ERP system
type EmployeeId = string & { readonly brand: unique symbol };
type DepartmentId = string & { readonly brand: unique symbol };
type PayrollBatchId = string & { readonly brand: unique symbol };
function processPayroll(batchId: PayrollBatchId, employeeId: EmployeeId) { ... }
// Can't accidentally swap parameters
processPayroll(employeeId, batchId); // ❌ Type error
Problem: Component state with multiple mutually exclusive states.
Solution: Discriminated unions.
// Before: Unclear which fields exist
interface ApiState {
status: 'idle' | 'loading' | 'success' | 'error';
data?: any;
error?: Error;
}
// Have to check status before accessing data/error
if (state.status === 'success' && state.data) { ... }
// After: Discriminated union
type ApiState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: Transaction[] }
| { status: 'error'; error: Error };
// TypeScript knows what exists based on status
function render(state: ApiState) {
switch (state.status) {
case 'idle':
return <EmptyState />;
case 'loading':
return <Spinner />;
case 'success':
return <DataTable data={state.data} />; // ✅ data exists
case 'error':
return <ErrorMessage error={state.error} />; // ✅ error exists
}
}
Real-world example:
// Payroll processing state
type PayrollState =
| { status: 'draft'; employees: Employee[] }
| { status: 'calculating'; progress: number }
| { status: 'review'; summary: PayrollSummary }
| { status: 'approved'; batchId: string }
| { status: 'paid'; paymentRef: string };
function PayrollDashboard({ state }: { state: PayrollState }) {
if (state.status === 'calculating') {
return <ProgressBar value={state.progress} />;
}
if (state.status === 'review') {
return <ReviewTable summary={state.summary} />;
}
// ... TypeScript ensures we handle all cases
}
Problem: Backend and frontend types drift apart.
Solution: Derive frontend types from backend contract.
// Backend entity (database model)
interface User {
id: string;
email: string;
password: string; // Never send to frontend!
createdAt: Date;
updatedAt: Date;
}
// Frontend types derived from backend
type UserPublic = Omit<User, 'password'>;
type UserCreate = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
type UserUpdate = Partial<Omit<UserCreate, 'email'>>;
// API response types
type ApiResponse<T> = {
data: T;
timestamp: string;
};
type PaginatedResponse<T> = ApiResponse<T[]> & {
pagination: {
page: number;
pageSize: number;
total: number;
};
};
// Usage
async function getUsers(): Promise<PaginatedResponse<UserPublic>> { ... }
async function createUser(user: UserCreate): Promise<ApiResponse<UserPublic>> { ... }
Advanced: Make it stricter with Readonly:
type ReadonlyUser = Readonly<UserPublic>;
function processUser(user: ReadonlyUser) {
user.email = 'new@email.com'; // ❌ Error: Cannot assign to 'email'
}
Problem: String enums that should follow a pattern.
Solution: Template literal types.
// Before: String enum, any value possible
type Currency = 'BHD' | 'USD' | 'EUR' | 'GBP';
// After: Template literal for patterns
type IsoCurrency = `${'A' | 'B' | 'D' | 'E' | 'G' | 'U'}${string}${string}`;
// Even better: Exact union
type Currency = 'BHD' | 'USD' | 'EUR' | 'GBP';
// For more complex patterns
type EventName = `user:${'created' | 'updated' | 'deleted'}`;
// Valid: "user:created", "user:updated", "user:deleted"
// Invalid: "user:modified", "product:created"
type HttpMethod = `${'GET' | 'POST' | 'PUT' | 'DELETE'}`;
type Endpoint = `/api/${'users' | 'transactions'}/${string}`;
// Valid: "/api/users/123", "/api/transactions/456"
Real-world example:
// ERP module routing
type ModuleRoute =
| `/payroll/${'employees' | 'batches' | 'reports'}/${string}`
| `/accounting/${'ledger' | 'invoices' | 'reports'}/${string}`
| `/inventory/${'items' | 'warehouses' | 'transfers'}/${string}`;
function navigate(route: ModuleRoute) { ... }
navigate('/payroll/employees/123'); // ✅
navigate('/payroll/unknown/123'); // ❌
Problem: Different shapes for different API operations.
Solution: Conditional types.
// Base entity
interface Entity {
id: string;
createdAt: Date;
updatedAt: Date;
}
// Transform for API responses
type ApiField<T> = T extends Date ? string : T;
type ApiEntity<T extends Entity> = {
[K in keyof T]: ApiField<T[K]>;
};
// Usage
interface Employee extends Entity {
name: string;
salary: number;
hireDate: Date;
}
type EmployeeApi = ApiEntity<Employee>;
// {
// id: string;
// createdAt: string; // Date → string
// updatedAt: string; // Date → string
// name: string;
// salary: number;
// hireDate: string; // Date → string
// }
Advanced: Make fields optional based on operation:
type UpdatePayload<T> = {
[K in keyof T]?: T[K];
} & { id: string }; // id always required
type CreatePayload<T> = Omit<UpdatePayload<T>, 'id' | 'createdAt' | 'updatedAt'>;
Problem: TypeScript types don't exist at runtime. API responses need validation.
Solution: Type guards that validate.
// Type guard function
function isEmployee(data: unknown): data is Employee {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'name' in data &&
typeof (data as any).id === 'string' &&
typeof (data as any).name === 'string'
);
}
// Usage
async function fetchEmployee(id: string): Promise<Employee> {
const response = await fetch(`/api/employees/${id}`);
const data = await response.json();
if (!isEmployee(data)) {
throw new Error('Invalid employee data');
}
return data;
}
Better: Use a validation library with TypeScript:
import { z } from 'zod';
const EmployeeSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
salary: z.number().positive(),
hireDate: z.string().transform(s => new Date(s)),
});
type Employee = z.infer<typeof EmployeeSchema>;
// Runtime validation + type inference
function validateEmployee(data: unknown): Employee {
return EmployeeSchema.parse(data);
}
Problem: Components that work with different entity types.
Solution: Generic constraints.
// Base interface all entities must satisfy
interface BaseEntity {
id: string;
createdAt: Date;
}
// Generic component
function DataTable<T extends BaseEntity>({
data,
columns,
renderRow,
}: {
data: T[];
columns: (keyof T)[];
renderRow: (item: T) => React.ReactNode;
}) {
return (
<table>
<thead>
<tr>
{columns.map(col => (
<th key={String(col)}>{String(col)}</th>
))}
</tr>
</thead>
<tbody>
{data.map(renderRow)}
</tbody>
</table>
);
}
// Usage
interface Employee extends BaseEntity {
name: string;
department: string;
}
<DataTable<Employee>
data={employees}
columns={['name', 'department']}
renderRow={(emp) => (
<tr>
<td>{emp.name}</td>
<td>{emp.department}</td>
</tr>
)}
/>
Problem: Need to transform all properties of a type.
Solution: Mapped types.
// Make all properties nullable
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
// Make all properties optional
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
// Make specific properties required
type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
// Transform all properties to async versions
type Asyncify<T> = {
[K in keyof T]: T[K] extends (...args: infer A) => infer R
? (...args: A) => Promise<R>
: T[K];
};
// Usage
interface SyncService {
getUser(id: string): User;
createUser(data: UserCreate): User;
}
type AsyncService = Asyncify<SyncService>;
// {
// getUser(id: string): Promise<User>;
// createUser(data: UserCreate): Promise<User>;
// }
Real-world example:
// Form state from entity
type FormState<T> = {
[K in keyof T]: T[K] extends Date
? string // Dates become ISO strings in forms
: T[K] extends (infer U)[]
? U[] // Arrays stay arrays
: T[K] | ''; // Other fields can be empty string
};
type EmployeeForm = FormState<Employee>;
// {
// id: string;
// name: string | '';
// email: string | '';
// hireDate: string; // Date → string
// skills: string[]; // Array stays array
// }
Problem: Arrays and objects widen to generic types.
Solution: as const assertions.
// Before: Widened types
const roles = ['admin', 'user', 'guest'];
// Type: string[]
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
};
// Type: { apiUrl: string; timeout: number }
// After: Narrow types with as const
const roles = ['admin', 'user', 'guest'] as const;
// Type: readonly ["admin", "user", "guest"]
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
} as const;
// Type: { readonly apiUrl: "https://api.example.com"; readonly timeout: 5000 }
// Extract types
type Role = typeof roles[number]; // "admin" | "user" | "guest"
type ApiUrl = typeof config.apiUrl; // "https://api.example.com"
Real-world example:
// API error codes
const ERROR_CODES = {
NOT_FOUND: 404,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
VALIDATION: 400,
} as const;
type ErrorCode = typeof ERROR_CODES[keyof typeof ERROR_CODES];
function handleError(code: ErrorCode) {
// Only accepts specific error codes
}
handleError(404); // ❌
handleError(ERROR_CODES.NOT_FOUND); // ✅
Problem: Event systems with string-based event names are error-prone.
Solution: Type-safe event maps.
// Define event map
interface AppEvents {
'user:login': { userId: string; timestamp: Date };
'user:logout': { userId: string };
'payroll:processed': { batchId: string; employeeCount: number };
'error': { code: number; message: string };
}
// Type-safe event emitter
class TypedEventEmitter {
private listeners: {
[K in keyof AppEvents]?: Array<(data: AppEvents[K]) => void>;
} = {};
on<K extends keyof AppEvents>(
event: K,
callback: (data: AppEvents[K]) => void
) {
this.listeners[event]?.push(callback) ??
(this.listeners[event] = [callback]);
}
emit<K extends keyof AppEvents>(event: K, data: AppEvents[K]) {
this.listeners[event]?.forEach(cb => cb(data));
}
}
// Usage
const emitter = new TypedEventEmitter();
emitter.on('user:login', (data) => {
console.log(`User ${data.userId} logged in at ${data.timestamp}`);
});
emitter.emit('user:login', { userId: '123', timestamp: new Date() }); // ✅
emitter.emit('user:login', { userId: '123' }); // ❌ Missing timestamp
emitter.emit('unknown:event', {}); // ❌ Unknown event
What TypeScript patterns have saved you? Share on GitHub or LinkedIn.