After maintaining a 200K-line TypeScript codebase for three years, I’ve learned that the difference between a TypeScript project that scales and one that collapses under its own weight comes down to a handful of architectural decisions made early on. Here are the practices that kept our codebase maintainable as the team grew from 3 to 15 developers.
1. Strict Mode Is Not Optional
Every TypeScript project I start now uses “strict”: true from day one. No exceptions. Here’s what it enables:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"noPropertyAccessFromIndexSignature": true
}
}
The noUncheckedIndexedAccess flag is particularly important. Without it, this code compiles without errors:
// Without noUncheckedIndexedAccess — compiles fine, crashes at runtime
const users: User[] = [];
const firstUser = users[0]; // Type: User (WRONG — it's undefined!)
firstUser.name; // Runtime error: Cannot read property 'name' of undefined
// With noUncheckedIndexedAccess — caught at compile time
const firstUser = users[0]; // Type: User | undefined
firstUser.name; // TS2532: Object is possibly 'undefined'
firstUser?.name; // ✅ Correct
This single flag has prevented more production bugs than any other TypeScript feature. It forces you to handle edge cases at compile time rather than discovering them in production.
2. Use ‘unknown’ Instead of ‘any’
The any type is a type-checking escape hatch. Every time you use any, you’re telling TypeScript to stop protecting you. Here’s the progression I follow:
// ❌ BAD: any — no type safety at all
function processData(data: any) {
return data.foo.bar.baz; // No compile-time check
}
// 🟡 OKAY: Record — at least we know it's an object
function processData(data: Record<string, unknown>) {
return data.foo; // Type: unknown
}
// ✅ GOOD: unknown — forces explicit type narrowing
function processData(data: unknown) {
if (typeof data === 'object' && data !== null && 'foo' in data) {
const foo = (data as { foo: string }).foo;
return foo;
}
throw new Error('Invalid data format');
}
// ✅✅ BEST: Zod schema — runtime validation + type inference
import { z } from 'zod';
const DataSchema = z.object({
foo: z.string(),
bar: z.number(),
baz: z.boolean().optional(),
});
type Data = z.infer<typeof DataSchema>;
function processData(data: unknown): Data {
return DataSchema.parse(data); // Throws if invalid
}
The pattern is simple: never use any. If you don’t know the type, use unknown and narrow it. If the data comes from an external source (API, file, user input), use Zod for runtime validation.
3. Utility Types: Your Best Friends
TypeScript’s built-in utility types eliminate a huge category of code duplication. Here are the ones I use daily:
Pick and Omit for API Variants
// Base user type from database
interface User {
id: string;
email: string;
name: string;
passwordHash: string;
createdAt: Date;
updatedAt: Date;
role: 'admin' | 'user' | 'moderator';
}
// API response — never send passwordHash
type UserResponse = Omit<User, 'passwordHash' | 'passwordHash'>;
// Create request — id and timestamps are auto-generated
type CreateUserRequest = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
// Update request — all fields optional except id
type UpdateUserRequest = Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>
& Pick<User, 'id'>; // id is required
ReturnType for Function Contracts
// Define the function
async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('User not found');
return response.json() as Promise<User>;
}
// Extract the return type for use elsewhere
type FetchUserReturn = Awaited<ReturnType<typeof fetchUser>>;
// Now you can use this type in tests
function testFetchUser() {
const mockUser: FetchUserReturn = {
id: '1',
email: '[email protected]',
name: 'Test User',
// TypeScript will error if you forget any field
};
}
4. Project References for Monorepos
When your codebase grows beyond a single package, Project References are essential. Here’s how we structured our monorepo:
// Root tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true
},
"references": [
{ "path": "./packages/shared" },
{ "path": "./packages/api" },
{ "path": "./packages/web" }
]
}
// packages/shared/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
// packages/api/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"references": [
{ "path": "../shared" } // API depends on shared
]
}
This gives you incremental builds — when you change one package, TypeScript only recompiles the affected packages. Our build time dropped from 45 seconds to 8 seconds.
5. Discriminated Unions for State Management
This pattern has replaced Redux/Zustand state objects in most of my projects. Instead of a single object with multiple optional fields, use a discriminated union:
// ❌ BAD: All fields optional — impossible to know which state we're in
interface UserState {
isLoading: boolean;
error: string | null;
user: User | null;
posts: Post[] | null;
}
// ✅ GOOD: Discriminated union — exact state at every point
type UserState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; error: string }
| {
status: 'success';
user: User;
posts: Post[];
};
// Usage — TypeScript knows exactly what's available
function renderUserState(state: UserState) {
switch (state.status) {
case 'idle':
return <Placeholder />;
case 'loading':
return <Spinner />;
case 'error':
return <Error message={state.error} />; // error is guaranteed
case 'success':
return (
<div>
<h1>{state.user.name}</h1> // user is guaranteed
{state.posts.map(p => <Post key={p.id} {...p} />)}
</div>
);
}
}
This eliminates an entire category of bugs where you access state.user when state.status is ‘loading’. The compiler catches it.
6. Branded Types for Primitive Obsession
One of the most common bugs in large codebases is mixing up parameters that have the same type:
// ❌ BAD: Both are strings — easy to mix up
function transfer(from: string, to: string, amount: number) { ... }
// These compile fine but are bugs:
transfer(userId, accountId, 100); // Wrong order!
transfer(accountId, userId, 100); // Also wrong!
// ✅ GOOD: Branded types — impossible to mix up
type UserId = string & { __brand: 'UserId' };
type AccountId = string & { __brand: 'AccountId' };
function createUserId(id: string): UserId { return id as UserId; }
function createAccountId(id: string): AccountId { return id as AccountId; }
function transfer(from: AccountId, to: AccountId, amount: number) { ... }
// Now this is a compile error:
transfer(createUserId('123'), createAccountId('456'), 100);
// TS2345: Type 'UserId' is not assignable to type 'AccountId'
7. The ‘as const’ Assertion
Use as const to create immutable, deeply-readonly values with precise types:
// Without as const — type is string[]
const ROUTES = ['/home', '/about', '/contact'];
// With as const — type is readonly ['/home', '/about', '/contact']
const ROUTES = ['/home', '/about', '/contact'] as const;
// Now you can use it as a type
type Route = (typeof ROUTES)[number]; // '/home' | '/about' | '/contact'
// Compile-time validation
function navigate(route: Route) { ... }
navigate('/home'); // ✅
navigate('/settings'); // ❌ TS2345
8. Error Handling with Result Types
Don’t throw exceptions for expected failures. Use a Result type:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
async function findUser(id: string): Promise<Result<User, 'NOT_FOUND' | 'NETWORK_ERROR'>> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) return { ok: false, error: 'NOT_FOUND' };
return { ok: true, value: await response.json() };
} catch {
return { ok: false, error: 'NETWORK_ERROR' };
}
}
// Usage — forced to handle both cases
const result = await findUser('123');
if (result.ok) {
console.log(result.value.name); // value is guaranteed
} else {
console.error(result.error); // error is guaranteed
}
Conclusion
TypeScript is a tool, and like any tool, its effectiveness depends on how you use it. The practices above aren’t theoretical — they’re battle-tested patterns from a codebase that handles 10M+ requests per day. Start with strict mode and unknown-over-any. Add utility types and discriminated unions as your codebase grows. And always, always validate external data at runtime.
The investment in type safety pays for itself many times over. Our team ships with confidence because the compiler catches entire categories of bugs before they reach production. That’s the real value of TypeScript.