TypeScript Best Practices for Large Codebases
After maintaining TypeScript codebases ranging from 100,000 to 600,000 lines throughout 2025, I've learned that the patterns that work for small projects often fall apart at scale. Here's what actually matters when your codebase grows beyond what any single developer can hold in their head.
Start Strict or Don't Start At All
The most painful technical debt I've inherited is TypeScript codebases that started without strict mode. Migrating a 300k line codebase to strict checking took three engineers two months—and we still have escape hatches scattered throughout.
If I could enforce one rule for every new TypeScript project, it would be: enable strict mode on day one, or stick with JavaScript.
The Configuration That Actually Catches Bugs
Here's my base tsconfig.json for every new project:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noPropertyAccessFromIndexSignature": true,
"noUnusedLocals": true,
"noUnusedParameters": true
}
}
The one that surprises people is noUncheckedIndexedAccess. It forces you to check array bounds and object keys before accessing them. This single setting would have prevented two production incidents I've dealt with where we assumed array elements existed without verification.
Using AI to Audit Type Safety
I regularly use Claude to audit my TypeScript configurations:
Prompt I use:
"Review this tsconfig.json for a large production codebase. Suggest additional strict checks that would catch bugs without being overly restrictive. Consider the latest TypeScript 5.4 features and best practices for 2025."
The AI often catches settings I've overlooked, like allowUnusedLabels: false or suggests newer TypeScript features that improve type safety.
Type Design: Make Invalid States Unrepresentable
The biggest improvement in my TypeScript work came from one principle: use the type system to make invalid states impossible to represent.
Discriminated Unions Over Optional Fields
Early in my career, I used optional fields everywhere. Now I use discriminated unions to model state explicitly.
The problem with optional fields:
// Bad: Allows invalid states
interface ApiRequest {
status: 'idle' | 'loading' | 'success' | 'error';
data?: UserData;
error?: Error;
}
// This compiles but makes no sense:
const bad: ApiRequest = {
status: 'success',
error: new Error()
};
The solution with discriminated unions:
// Good: Invalid states are unrepresentable
type ApiRequest =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: UserData }
| { status: 'error'; error: Error };
// TypeScript exhaustiveness checking ensures you handle all cases
function handleRequest(req: ApiRequest) {
switch (req.status) {
case 'idle': return <button>Load</button>;
case 'loading': return <Spinner />;
case 'success': return <Display data={req.data} />;
case 'error': return <Error error={req.error} />;
}
}
This pattern eliminated an entire class of bugs in our data fetching layer. If the request status is 'success', TypeScript guarantees that data exists. No runtime checks needed.
Branded Types for Domain Safety
Here's a pattern that's saved me from countless bugs: branded types for domain primitives.
The problem:
// Everything is just a string
function transferMoney(from: string, to: string, amount: number) {
// Easy to accidentally swap from and to
}
const userId = "user_123";
const accountId = "acct_456";
// This compiles but is semantically wrong:
transferMoney(userId, accountId, 100);
The solution:
// Brand your domain types
type UserId = string & { readonly __brand: 'UserId' };
type AccountId = string & { readonly __brand: 'AccountId' };
function createUserId(id: string): UserId {
return id as UserId;
}
function createAccountId(id: string): AccountId {
return id as AccountId;
}
function transferMoney(from: AccountId, to: AccountId, amount: number) {
// TypeScript prevents passing UserId where AccountId is expected
}
const userId = createUserId("user_123");
const accountId = createAccountId("acct_456");
// Compile error: UserId is not assignable to AccountId
transferMoney(userId, accountId, 100); // ❌ Won't compile
I use this pattern for IDs, currencies, timestamps, email addresses—anything where mixing values would be semantically wrong even if the underlying types match.
Module Boundaries and Dependency Management
In codebases over 50k lines, module organization becomes critical. I've settled on a pattern that scales well.
Path Aliases That Make Sense
Configure path aliases that mirror your architecture:
{
"compilerOptions": {
"paths": {
"@/lib/*": ["./lib/*"],
"@/features/*": ["./features/*"],
"@/shared/*": ["./shared/*"],
"@/types/*": ["./types/*"]
}
}
}
The rules I follow:
@/lib- Pure utilities, no dependencies on features@/features- Feature modules, can depend on lib and shared@/shared- Cross-feature code, minimal dependencies@/types- Global types, no implementation code
Enforcing Dependencies with ESLint
I use eslint-plugin-import to prevent circular dependencies and enforce module boundaries:
// .eslintrc.js
rules: {
'import/no-restricted-paths': [
'error',
{
zones: [
{
target: './lib',
from: './features',
message: 'lib/ cannot depend on features/'
}
]
}
]
}
This catches architectural violations at lint time instead of discovering them months later when they're expensive to fix.
Advanced Type Patterns I Use Daily
Const Assertions for Literal Types
// Without const assertion
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000
};
// Type: { apiUrl: string; timeout: number }
// With const assertion
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000
} as const;
// Type: { readonly apiUrl: "https://api.example.com"; readonly timeout: 5000 }
Const assertions give you exact literal types, which enables better autocomplete and catches more errors at compile time.
Template Literal Types for String Patterns
TypeScript's template literal types are underused. I use them for type-safe string patterns:
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Endpoint = `/api/${string}`;
type RouteHandler = `${HTTPMethod} ${Endpoint}`;
// Valid
const route1: RouteHandler = 'GET /api/users';
const route2: RouteHandler = 'POST /api/orders';
// Invalid - TypeScript catches these
const bad1: RouteHandler = 'PATCH /api/users'; // ❌
const bad2: RouteHandler = 'GET /users'; // ❌
This pattern has prevented bugs in our routing layer where endpoint strings were constructed incorrectly.
Type Narrowing with Custom Type Guards
For complex runtime type checking, I write custom type guards:
interface User {
type: 'user';
id: string;
email: string;
}
interface Admin {
type: 'admin';
id: string;
email: string;
permissions: string[];
}
type Account = User | Admin;
// Type guard
function isAdmin(account: Account): account is Admin {
return account.type === 'admin';
}
function handleAccount(account: Account) {
if (isAdmin(account)) {
// TypeScript knows account is Admin here
console.log(account.permissions);
}
}
Leveraging TypeScript 5.4 Features (2025)
TypeScript 5.4 brought features I use constantly:
NoInfer for Type Parameter Control
function createState<T>(
initial: T,
options?: NoInfer<Partial<T>>
) {
return { ...initial, ...options };
}
// Works correctly - options can't widen T
const state = createState({ count: 0 }, { count: 1 });
Improved Type Inference in Generic Functions
The 2025 TypeScript updates significantly improved inference for generic functions, reducing the need for explicit type parameters.
AI-Assisted Type Refactoring
One of my favorite uses of AI assistants is refactoring types. Here's a prompt I use frequently:
Refactoring Prompt:
"Refactor this TypeScript interface to use discriminated unions instead of optional fields. The interface represents a form that can be in draft, submitting, or submitted states. Each state should have only the properties relevant to that state."
The AI generates a discriminated union structure I can then refine. It's dramatically faster than doing it manually, especially for complex state machines.
Performance Considerations at Scale
Type Complexity and Compilation Speed
In large codebases, type complexity affects compilation speed. I've learned to avoid:
- Deeply nested mapped types
- Excessive use of conditional types
- Union types with hundreds of members
Slow:
type DeepPartial<T> = {
[K in keyof T]: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
Faster:
type Partial2Levels<T> = {
[K in keyof T]?: T[K] extends object
? Partial<T[K]>
: T[K];
};
When TypeScript compilation takes minutes on large codebases, these optimizations matter.
Using Project References
For monorepos or large projects, TypeScript project references dramatically improve build times:
{
"references": [
{ "path": "./packages/shared" },
{ "path": "./packages/api" },
{ "path": "./packages/web" }
]
}
This enables incremental compilation—TypeScript only recompiles changed projects instead of the entire codebase.
Testing Types with tsd
For libraries and complex type utilities, I test the types themselves:
import { expectType, expectError } from 'tsd';
// Test that types work as expected
const userId = createUserId('user_123');
expectType<UserId>(userId);
// Test that invalid operations are caught
expectError(transferMoney(userId, accountId, 100));
This catches type regressions when refactoring complex type utilities.
The Tooling Stack That Scales
For large TypeScript codebases in 2025, I rely on:
typescript-eslint: Catches TypeScript-specific anti-patterns ts-prune: Finds unused exports (critical for large codebases) dpdm: Detects circular dependencies type-coverage: Measures how much of your code is actually typed
I run these weekly in CI to prevent type safety degradation over time.
What I've Learned
Building large TypeScript applications taught me that the type system is a tool for encoding business logic and invariants. The more you can express in types, the fewer runtime checks and tests you need.
The key insights that transformed my TypeScript work:
- Make invalid states unrepresentable with discriminated unions
- Brand primitive types to prevent semantic errors
- Use strict mode from day one or don't use TypeScript at all
- Enforce module boundaries with ESLint and project structure
- Leverage AI assistants for type refactoring and auditing
These patterns have scaled across multiple 100k+ line codebases throughout 2025, and I'm confident they'll continue serving me well as TypeScript and tooling evolve.