When you are working on a small project, TypeScript mostly just stays out of your way. You add a few interfaces, sprinkle in some type annotations, and everything is fine. But once your codebase crosses a certain threshold, maybe 50,000 lines, maybe 100,000, the patterns you use start to matter a lot. Types that were convenient at 5,000 lines become maintenance nightmares at 50,000. I want to share the patterns that have helped me keep large TypeScript codebases manageable.
Discriminated Unions for State Management
One of the most powerful TypeScript patterns I use daily is discriminated unions. Instead of modeling state as an object with a bunch of optional properties, you create a union where each variant has a literal type discriminator. This makes impossible states unrepresentable:
// Instead of this:
interface RequestState {
loading: boolean;
data?: User[];
error?: string;
}
// Use this:
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string };
function renderUsers(state: RequestState) {
switch (state.status) {
case 'idle':
return 'Click to load';
case 'loading':
return 'Loading...';
case 'success':
return state.data.map(u => u.name).join(', ');
case 'error':
return `Error: ${state.error}`;
}
}
With the first approach, nothing stops you from having loading: true and error: "something" at the same time. With discriminated unions, the compiler enforces that each state has exactly the properties it should. This pattern has eliminated entire categories of bugs in our application.
Branded Types for Domain Safety
Here is a bug I have seen in almost every codebase I have worked on: passing a user ID where a conversation ID was expected, or mixing up two string values that happen to have the same underlying type. Branded types solve this elegantly:
type UserId = string & { readonly __brand: 'UserId' };
type ConversationId = string & { readonly __brand: 'ConversationId' };
function createUserId(id: string): UserId {
return id as UserId;
}
function createConversationId(id: string): ConversationId {
return id as ConversationId;
}
function getConversation(id: ConversationId): Promise<Conversation> {
// ...
}
const userId = createUserId('abc-123');
const convId = createConversationId('conv-456');
getConversation(convId); // OK
getConversation(userId); // Compile error!
The brand property never exists at runtime, so there is zero overhead. But at compile time, TypeScript treats UserId and ConversationId as completely incompatible types. We introduced this pattern for all entity IDs in our codebase, and it caught at least a dozen bugs in the first week of adoption.
Strict Configuration Is Non-Negotiable
If you are starting a new project, turn on strict: true from day one. If you have an existing project without it, make a plan to enable it incrementally. Here is the tsconfig I start every project with:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"exactOptionalPropertyTypes": true
}
}
The one that surprises most people is noUncheckedIndexedAccess. With this enabled, accessing an array element or object property by index returns T | undefined instead of just T. It forces you to handle the case where the element might not exist, which reflects what actually happens at runtime.
Utility Type Patterns
TypeScript's built-in utility types are useful, but the real power comes from composing your own. Here are a few I find myself reaching for constantly:
// Make specific properties required while keeping the rest
type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
// Deep partial for nested objects
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
// Extract the resolved type from a Promise
type Awaited<T> = T extends Promise<infer U> ? U : T;
// Strict omit that errors if you try to omit a non-existent key
type StrictOmit<T, K extends keyof T> = Omit<T, K>;
The StrictOmit type is particularly important. The built-in Omit accepts any string as the second parameter, which means typos silently pass. By constraining K to keyof T, you get a compile error if you try to omit a property that does not exist on the type. This has caught several refactoring bugs where a property was renamed but the Omit usage was not updated.
Template Literal Types for API Routes
One pattern that has been particularly useful in our NestJS backend is using template literal types to enforce API route consistency:
type ApiVersion = 'v1' | 'v2';
type Resource = 'users' | 'conversations' | 'messages';
type ApiRoute = `/api/${ApiVersion}/${Resource}`;
function createEndpoint(route: ApiRoute): void {
// route is guaranteed to match the pattern
}
createEndpoint('/api/v1/users'); // OK
createEndpoint('/api/v3/users'); // Error: 'v3' not in ApiVersion
createEndpoint('/api/v1/something'); // Error: 'something' not in Resource
These patterns are not silver bullets, and they do add complexity. But in a large codebase with multiple developers, they turn entire classes of runtime errors into compile-time errors. The upfront cost of writing stricter types pays for itself many times over in avoided production bugs and faster code reviews. Start with discriminated unions and strict config, then layer in branded types and custom utilities as your codebase grows.