TypeScript Best Practices for Scalable Applications
TypeScript adoption has exploded for good reason—it catches bugs before they hit production. But I've seen teams struggle with overly complex types, slow build times, and type definitions that don't actually provide safety. Here's what actually works at scale.
1. Start Strict, Stay Strict
Enable these in your tsconfig.json from day one:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true
}
}These settings feel restrictive initially but save countless hours debugging by catching potential runtime errors at compile time.
2. Avoid 'any' Like the Plague
Using any defeats the entire purpose of TypeScript. If you don't know the type:
- Use
unknownand narrow with type guards - Create a proper interface, even if it's incomplete
- Use generics to preserve type information
// Bad
function processData(data: any) {
return data.map(x => x.value); // No safety
}
// Good
function processData<T extends { value: unknown }>(data: T[]) {
return data.map(x => x.value); // Type-safe
}3. Leverage Type Inference
Don't over-annotate. TypeScript's inference is powerful—use it:
// Overly verbose
const user: User = getUserById(id);
const userName: string = user.name;
const isActive: boolean = user.isActive;
// Better - let TypeScript infer
const user = getUserById(id); // TypeScript knows it's User
const userName = user.name; // Inferred as string
const isActive = user.isActive; // Inferred as booleanExplicit types are useful for function parameters, return types, and complex data structures. For simple assignments, inference keeps code clean.
4. Use Discriminated Unions for State Management
This pattern is incredibly powerful for reducers, API responses, and state machines:
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function handleState<T>(state: AsyncState<T>) {
switch (state.status) {
case 'idle':
return <div>Not started</div>;
case 'loading':
return <div>Loading...</div>;
case 'success':
return <div>{state.data}</div>; // TypeScript knows data exists
case 'error':
return <div>{state.error.message}</div>; // TypeScript knows error exists
}
}This eliminates entire classes of bugs where you access data that doesn't exist in certain states.
5. Create Domain-Specific Types
Primitive obsession is a code smell. Create types that represent your domain:
// Weak typing
function sendEmail(email: string, subject: string, body: string) {
// Easy to mix up parameters
sendEmail(subject, email, body); // Compiles but wrong
}
// Strong domain types
type EmailAddress = string & { __brand: 'EmailAddress' };
type Subject = string & { __brand: 'Subject' };
type EmailBody = string & { __brand: 'EmailBody' };
function sendEmail(
email: EmailAddress,
subject: Subject,
body: EmailBody
) {
// Can't mix up parameters - type error
}6. Organize Types by Feature, Not by Kind
Don't create a massive types.ts file. Organize by feature:
// Bad structure
/types
interfaces.ts // All interfaces
types.ts // All types
enums.ts // All enums
// Good structure
/features
/user
user.types.ts
user.service.ts
user.component.tsx
/auth
auth.types.ts
auth.service.ts
auth.component.tsxThis keeps related code together and makes refactoring easier.
7. Use Utility Types for Transformation
TypeScript's built-in utility types are incredibly useful:
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
}
// Public-facing user (omit sensitive fields)
type PublicUser = Omit<User, 'password'>;
// User creation payload (no ID or createdAt yet)
type CreateUserDto = Omit<User, 'id' | 'createdAt'>;
// Partial update (all fields optional)
type UpdateUserDto = Partial<User>;
// Make specific fields required
type UserWithId = Required<Pick<User, 'id'>> & Partial<User>;8. Handle API Responses Properly
Never trust external data. Validate and transform at the boundary:
import { z } from 'zod'; // or use io-ts, yup, etc.
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
createdAt: z.string().transform(s => new Date(s))
});
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
// Validate and transform
return UserSchema.parse(data); // Throws if invalid
}This catches API contract violations at runtime before they corrupt your application state.
9. Avoid Enum, Use Union Types
TypeScript enums have weird runtime behavior. Use const objects or union types instead:
// Avoid enums
enum Status {
Active,
Inactive,
Pending
}
// Better: const object with 'as const'
const Status = {
Active: 'active',
Inactive: 'inactive',
Pending: 'pending'
} as const;
type Status = typeof Status[keyof typeof Status];
// Or just a union type
type Status = 'active' | 'inactive' | 'pending';10. Configure Path Aliases
Stop writing '../../../components'. Use path aliases:
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@components/*": ["src/components/*"],
"@utils/*": ["src/utils/*"],
"@types/*": ["src/types/*"]
}
}
}
// Now import like this
import { Button } from '@components/Button';
import { formatDate } from '@utils/date';Common Pitfalls to Avoid
- Type assertions everywhere: If you're using 'as' constantly, your types are wrong
- Overly generic types:
Record<string, any>is just 'any' with extra steps - Ignoring null/undefined: Always handle these cases explicitly
- Complex mapped types: If your type takes 20 lines, rethink your approach
- Not updating types: When your API changes, update types immediately
Build Time Optimization
Large TypeScript projects can have slow builds. Speed them up:
- Enable incremental compilation:
"incremental": true - Use project references for monorepos
- Skip lib checks:
"skipLibCheck": true - Use SWC or esbuild instead of tsc for builds (keep tsc for type checking)
The Payoff
Done right, TypeScript provides:
- Confidence: Refactor without fear
- Documentation: Types are always up-to-date docs
- Velocity: Catch bugs at compile time, not in production
- Developer Experience: Autocomplete and inline docs in your IDE
The upfront investment in proper types pays dividends through reduced bugs, faster development cycles, and improved code maintainability.
Final Advice
Start with strict mode enabled. Make your types as specific as possible without over-engineering. Trust the compiler—if TypeScript complains, there's usually a legitimate edge case you haven't considered.
And remember: the goal isn't perfect types, it's correct runtime behavior. Types are a tool to help you achieve that, not an end in themselves.
Need help migrating to TypeScript or improving your existing type system? Reach out for a codebase assessment and migration strategy.