TypeScript at Scale
TypeScript prevents bugs through static typing. But large codebases require discipline. Without proper patterns, TypeScript becomes a burden—types everywhere, complex generics, any-typed escape hatches.
Strict Mode and Compiler Settings
Start with strict mode. It enables:
- noImplicitAny: require explicit types
- strictNullChecks: null/undefined are explicit
- strictFunctionTypes: function parameters typed strictly
- strictBindCallApply: bind/call/apply type checked
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitThis": true
}
}
Strict mode catches errors at compile time, preventing runtime surprises.
Module Organization and Boundaries
Structure codebases by domain, not by file type.
Poor structure:
src/
components/
pages/
utils/
hooks/
Better structure:
src/
features/
users/
components/
pages/
hooks/
utils/
types.ts
payments/
components/
pages/
services/
types.ts
Each feature owns its types, preventing cross-feature type leakage and making code relocatable.
Type Naming and Conventions
Prefix interfaces with I: Controversial but explicit. IUser clearly marks interfaces.
Actually, modern TypeScript prefers type User over interface IUser. Use descriptive names:
type UserWithEmail = User & { email: string };
type AdminUser = User & { role: 'admin' };
type UserApiResponse = Pick<User, 'id' | 'name'>;
Generics and Utility Types
Generics enable reusable, type-safe code:
type ApiResponse<T> = {
status: number;
data: T;
error?: string;
};
type PaginatedResponse<T> = ApiResponse<{
items: T[];
total: number;
page: number;
}>;
Utility types simplify transformations:
type UserDTO = Omit<User, 'password' | 'createdAt'>;
type ReadOnlyUser = Readonly<User>;
type PartialUser = Partial<User>;
Avoiding Any and Unknown
any defeats TypeScript's purpose. When type is genuinely unknown, use unknown:
// Wrong - any disables all type checking
function process(data: any) {
return data.toUpperCase();
}
// Right - unknown requires type narrowing
function process(data: unknown) {
if (typeof data === 'string') {
return data.toUpperCase();
}
throw new Error('Expected string');
}
Dependency Injection for Testability
Large codebases need mocking. Use dependency injection:
class UserService {
constructor(private db: Database, private cache: Cache) {}
async getUser(id: string): Promise<User | null> {
const cached = await this.cache.get(id);
if (cached) return cached;
return this.db.findUser(id);
}
}
// Testing - inject mocks
const mockDb = { findUser: jest.fn() };
const mockCache = { get: jest.fn() };
const service = new UserService(mockDb, mockCache);
This pattern scales better than directly importing dependencies.
Frequently asked questions
Should we use interfaces or types in TypeScript?
Types are more flexible—they support unions, intersections, and tuples. Interfaces work better for object shapes and declaration merging. For most cases, prefer types. Use interfaces for public API contracts needing extension.
How do we enforce type consistency across large teams?
Establish shared type packages: @company/types. Document naming conventions and patterns. Use ESLint rules enforcing no-explicit-any and no-unused-vars. Require type reviews in code review process. Run automated type audits in CI.
What's the best way to type API responses?
Generate types from API schemas using OpenAPI generators. Never hand-write types matching API responses—they diverge. Use Zod or io-ts for runtime validation and type inference. This catches API contract violations.