After maintaining TypeScript codebases ranging from 50k to 500k+ lines of code, I've learned that the patterns that work for small projects often become liabilities at scale. The type system that once felt like a safety net starts to slow down your IDE, your build times balloon, and your team starts adding any types just to ship features. Here's what actually works when your codebase grows beyond the tutorial phase.
Nominal Types Over Structural Typing
TypeScript's structural typing is elegant until you accidentally pass a UserId where an OrderId was expected—both are strings, so TypeScript happily compiles. In large codebases with dozens of domain entities, this becomes a real problem. The solution is to implement nominal typing using branded types.
// Create branded types for domain primitives
type Brand<K, T> = K & { __brand: T };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type EmailAddress = Brand<string, 'EmailAddress'>;
// Factory functions enforce validation
function createUserId(id: string): UserId {
if (!id.startsWith('usr_')) throw new Error('Invalid user ID format');
return id as UserId;
}
function createOrderId(id: string): OrderId {
if (!id.startsWith('ord_')) throw new Error('Invalid order ID format');
return id as OrderId;
}
// Now this won't compile - type safety across your domain
function getOrder(orderId: OrderId) { /* ... */ }
const userId = createUserId('usr_123');
getOrder(userId); // Error: Argument of type 'UserId' is not assignable to 'OrderId'
Strategic Use of Type Assertions
Type assertions (as) get a bad rap, but they're essential in large codebases—you just need to be disciplined about where you use them. I follow a simple rule: assertions are allowed at system boundaries (API responses, database queries, external libraries) but banned everywhere else. Create a dedicated validation layer where runtime checks and type assertions live together.
// validation.ts - centralized validation with runtime checks
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().refine(id => id.startsWith('usr_')),
email: z.string().email(),
createdAt: z.string().datetime(),
});
type User = z.infer<typeof UserSchema>;
// Safe assertion at the boundary
export function parseUser(data: unknown): User {
return UserSchema.parse(data); // Throws if invalid
}
// In your API layer
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return parseUser(data); // Single point of assertion with validation
}
// Rest of your codebase works with properly typed User objects
function processUser(user: User) {
// No assertions needed - type is guaranteed
console.log(user.email.toLowerCase());
}
Discriminated Unions for State Machines
One of TypeScript's most underutilized features is discriminated unions, especially for modeling state machines. Instead of optional fields that may or may not be present depending on state, make impossible states unrepresentable. This pattern eliminates entire categories of runtime errors.
// Bad: Optional fields lead to invalid states
type BadRequest = {
status: 'idle' | 'loading' | 'success' | 'error';
data?: User;
error?: Error;
};
// Good: Discriminated union makes invalid states impossible
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; error: Error };
function handleRequest(state: RequestState) {
switch (state.status) {
case 'idle':
return 'Ready to start';
case 'loading':
return 'Loading...';
case 'success':
// TypeScript knows data exists here
return `Welcome ${state.data.email}`;
case 'error':
// TypeScript knows error exists here
return `Failed: ${state.error.message}`;
}
}
Module Boundaries and Barrel Files
As your codebase grows, managing imports becomes a nightmare. Barrel files (index.ts) can help, but they're often misused. The key is to use them to enforce module boundaries, not just to make imports prettier. Each major domain or feature should have a single public API surface.
- Do: Use barrel files to export only the public API of a module. Internal implementation details stay private.
- Do: Structure folders by feature/domain, not by technical layer.
features/orders/is better thanmodels/,services/,utils/. - Don't: Create barrel files that re-export everything. This defeats tree-shaking and creates circular dependencies.
- Don't: Import across feature boundaries at the file level. Only import from the feature's public
index.ts. - Use: TypeScript's path mapping in
tsconfig.jsonto create clean import aliases:@features/ordersinstead of../../../features/orders.
Generic Constraints That Actually Help
Generics are powerful, but unconstrained generics are nearly useless in large codebases. Every generic should have meaningful constraints that encode business rules. This makes your APIs self-documenting and catches errors at compile time rather than runtime.
// Weak: Too generic, allows anything
function processEntity<T>(entity: T): void {
// What can we actually do with T here?
}
// Better: Constrain to entities with required fields
interface Entity {
id: string;
createdAt: Date;
updatedAt: Date;
}
function processEntity<T extends Entity>(entity: T): void {
console.log(`Processing entity ${entity.id}`);
// TypeScript knows these fields exist
}
// Best: Constrain with branded types for domain safety
interface DomainEntity<ID extends string> extends Entity {
id: Brand<string, ID>;
}
interface User extends DomainEntity<'UserId'> {
email: EmailAddress;
}
function updateEntity<T extends DomainEntity<string>>(
entity: T,
updates: Partial<Omit<T, 'id' | 'createdAt'>>
): T {
return { ...entity, ...updates, updatedAt: new Date() };
}
These patterns aren't theoretical—they're born from pain points in real production systems. The initial investment in setting up branded types, validation layers, and proper module boundaries pays dividends as your team and codebase scale. TypeScript's type system is powerful enough to encode your business rules and prevent entire classes of bugs, but only if you use it strategically. Start with these patterns early, and your future self will thank you when you're refactoring a feature that touches 50 files across 10 teams.