TypeScript adoption has crossed the tipping point. Most serious web applications being built in the US market today start with TypeScript, not JavaScript. The question is no longer "should we use TypeScript" but "are we using it well."
After working on large-scale TypeScript codebases, SaaS platforms, FinTech APIs and healthcare applications, the patterns that separate a maintainable codebase from a messy one are consistent. Here's what actually matters at scale.
1. Stop Using any. Use unknown Instead
any is TypeScript's escape hatch, and like all escape hatches it gets overused. The problem: any disables type checking entirely. You get none of TypeScript's safety guarantees on that value or anything it touches.
unknown is the type-safe alternative. It forces you to narrow the type before using it:
// Bad: any disables all type checking downstream
function parseConfig(raw: any) {
return raw.database.host; // No error, but crashes if shape is wrong
}
// Good: unknown forces you to validate before using
function parseConfig(raw: unknown): AppConfig {
if (!isAppConfig(raw)) {
throw new Error('Invalid config shape');
}
return raw; // TypeScript now knows this is AppConfig
}
function isAppConfig(value: unknown): value is AppConfig {
return (
typeof value === 'object' &&
value !== null &&
'database' in value &&
typeof (value as any).database.host === 'string'
);
}
At scale, every any you write is a bug waiting to happen during a refactor. Search your codebase for any usages and replace them systematically.
2. Use Discriminated Unions for State Management
One of TypeScript's most powerful features is discriminated unions and most teams underuse them. They're especially valuable for representing states that are mutually exclusive:
// Without discriminated unions: unclear what properties exist in each state
interface RequestState {
status: 'idle' | 'loading' | 'success' | 'error';
data?: User[];
error?: Error;
}
// Problem: TypeScript can't tell you that data only exists when status is 'success'
// With discriminated unions: crystal clear, impossible to access wrong properties
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: Error };
function renderState(state: RequestState) {
switch (state.status) {
case 'success':
return state.data.map(u => u.name); // TypeScript knows data exists here
case 'error':
return state.error.message; // TypeScript knows error exists here
case 'loading':
return 'Loading...';
case 'idle':
return null;
}
}
This pattern eliminates entire categories of "cannot read property of undefined" errors, which are the most common runtime errors in JavaScript applications.
3. Strict Mode Is Non-Negotiable
If you're not running TypeScript in strict mode, you're using TypeScript lite. Enable these in your tsconfig.json:
{
"compilerOptions": {
"strict": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitAny": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
noUncheckedIndexedAccess is especially valuable. It forces you to handle the possibility that an array index might be undefined:
const users: User[] = getUsers();
// Without noUncheckedIndexedAccess: TypeScript says users[0] is User
// With noUncheckedIndexedAccess: TypeScript correctly says users[0] is User | undefined
const first = users[0];
if (first) {
console.log(first.name); // Safe
}
Yes, strict mode surfaces a lot of existing errors when you turn it on in an existing codebase. That's the point, those were already bugs.
4. Zod for Runtime Validation at System Boundaries
TypeScript only gives you compile-time safety. The moment data crosses a system boundary: API response, form input, database query, webhook payload, you're back to runtime land where types are promises, not guarantees.
Zod lets you define a schema once and get both runtime validation and TypeScript type inference:
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(['admin', 'member', 'viewer']),
createdAt: z.coerce.date(),
});
type User = z.infer<typeof UserSchema>; // TypeScript type, inferred automatically
// At API boundary: validate before trusting
async function getUser(id: string): Promise<User> {
const raw = await fetch(`/api/users/${id}`).then(r => r.json());
return UserSchema.parse(raw); // Throws if shape doesn't match
}
I use Zod on every API route handler in Node.js to validate incoming request bodies, and on every API call in React to validate responses before they touch application state. The runtime errors it catches in staging would have been production incidents without it.
5. Generic Utility Types You Should Know Cold
TypeScript's built-in utility types eliminate enormous amounts of boilerplate in large codebases. The ones I use most:
interface User {
id: string;
email: string;
name: string;
passwordHash: string;
createdAt: Date;
}
// Partial: all fields optional — useful for update payloads
type UserUpdatePayload = Partial<User>;
// Pick: select specific fields — useful for API response shapes
type PublicUser = Pick<User, 'id' | 'email' | 'name'>;
// Omit: exclude specific fields — useful to strip sensitive data
type SafeUser = Omit<User, 'passwordHash'>;
// Required: make all fields required — useful to enforce completion
type CompleteUser = Required<User>;
// Record: typed key-value map
type UsersByRole = Record<User['role'], User[]>;
// ReturnType: infer return type of a function
async function fetchUser() { return { id: '1', name: 'Waqar' }; }
type FetchedUser = Awaited<ReturnType<typeof fetchUser>>;
Knowing these well means you never manually redeclare types that can be derived which is one of the biggest sources of type drift in large codebases.
6. Path Aliases for Clean Imports
In large codebases, relative imports become unreadable:
// Nightmare imports in a deep module
import { UserService } from '../../../../services/user/UserService';
import { validateEmail } from '../../../utils/validators';
Configure path aliases in tsconfig.json:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@services/*": ["src/services/*"],
"@utils/*": ["src/utils/*"],
"@types/*": ["src/types/*"],
"@hooks/*": ["src/hooks/*"]
}
}
}
Now imports are clean and refactor-safe:
import { UserService } from '@services/user/UserService';
import { validateEmail } from '@utils/validators';
When you move a file, you update one alias definition, not every file that imports from it.
7. Type-Safe Environment Variables
This one is small but eliminates an entire class of runtime errors, accessing an environment variable that was never set:
// Bad: process.env.DATABASE_URL is string | undefined
// This silently passes undefined to your database client
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Good: validate all env vars at startup, fail loudly if missing
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
PORT: z.coerce.number().default(3000),
});
const env = EnvSchema.parse(process.env);
// env.DATABASE_URL is now string — guaranteed, never undefined
If a required environment variable is missing, the app crashes on startup with a clear error. Not 20 minutes later with a confusing database error.
The Mental Model That Makes TypeScript Scale
TypeScript's value multiplies with team size and codebase age. On day one, types feel like overhead. In year two, when you're refactoring a shared utility used in 80 places, types are the thing that tells you every call site that needs to change.
The teams I've seen get the most from TypeScript treat types as documentation that the compiler enforces. Every function signature is a contract. Every discriminated union is a state machine. Every Zod schema is a trust boundary.
Treat it that way and TypeScript pays for itself within months.
Building a large-scale TypeScript application for a US business? I specialize in full-stack development with TypeScript across React, Node.js, and cloud infrastructure. See my approach at waqarhabib.com/services/full-stack-development.
Originally published at waqarhabib.com
Top comments (0)