TypeScript has become the default choice for serious JavaScript projects. But adding types isn’t enough—how you use TypeScript determines whether it helps or hinders. Large codebases need deliberate patterns.
Here are TypeScript best practices that scale.
Type Safety
Strict Mode
// tsconfig.json - Enable strict mode
{
"compilerOptions": {
"strict": true,
// Or individually:
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true
}
}
// With strictNullChecks
function getUser(id: string): User | undefined {
return users.get(id);
}
// Must handle undefined
const user = getUser('123');
if (user) {
console.log(user.name); // Safe
}
// Or
console.log(user?.name); // Optional chaining
Avoid any
// BAD: any defeats the purpose
function processData(data: any) {
return data.name; // No type safety
}
// GOOD: Use unknown for truly unknown types
function processData(data: unknown) {
if (isUser(data)) {
return data.name; // Type-safe after narrowing
}
throw new Error('Invalid data');
}
// Type guard
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'name' in obj &&
typeof (obj as User).name === 'string'
);
}
// GOOD: Generic for flexible but typed functions
function processData<T extends { name: string }>(data: T): string {
return data.name;
}
Type Design
Interface vs. Type
// Interface: Preferred for objects, can be extended
interface User {
id: string;
email: string;
name: string;
}
interface AdminUser extends User {
permissions: string[];
}
// Type: For unions, primitives, computed types
type UserId = string;
type UserOrAdmin = User | AdminUser;
type UserKeys = keyof User;
// Types can do things interfaces can't
type EventType = 'click' | 'hover' | 'focus';
type Nullable<T> = T | null;
type ReadonlyUser = Readonly<User>;
Discriminated Unions
// Discriminated union for state handling
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function handleState<T>(state: RequestState<T>) {
switch (state.status) {
case 'idle':
return 'Ready';
case 'loading':
return 'Loading...';
case 'success':
return `Got ${state.data}`; // TypeScript knows data exists
case 'error':
return `Error: ${state.error.message}`; // TypeScript knows error exists
}
}
Branded Types
// Prevent accidental mixing of similar types
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
function createUserId(id: string): UserId {
return id as UserId;
}
function getUser(id: UserId): User {
// ...
}
function getOrder(id: OrderId): Order {
// ...
}
const userId = createUserId('user-123');
const orderId = 'order-456' as OrderId;
getUser(userId); // OK
getUser(orderId); // Error! Type 'OrderId' is not assignable to type 'UserId'
Generics
Constrained Generics
// Constrain what T can be
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: 'Alice', age: 30 };
const name = getProperty(user, 'name'); // type: string
const age = getProperty(user, 'age'); // type: number
// getProperty(user, 'invalid'); // Error!
// Multiple constraints
function merge<T extends object, U extends object>(a: T, b: U): T & U {
return { ...a, ...b };
}
Generic Defaults
// Default type parameter
interface ApiResponse<T = unknown> {
data: T;
status: number;
timestamp: Date;
}
// Can use without specifying T
const response: ApiResponse = await fetchApi('/users');
// Or specify T
const userResponse: ApiResponse<User[]> = await fetchApi('/users');
Utility Types
Built-in Utilities
interface User {
id: string;
email: string;
name: string;
createdAt: Date;
}
// Partial: All properties optional
type UserUpdate = Partial<User>;
// { id?: string; email?: string; name?: string; createdAt?: Date; }
// Required: All properties required
type RequiredUser = Required<Partial<User>>;
// Pick: Select specific properties
type UserPreview = Pick<User, 'id' | 'name'>;
// { id: string; name: string; }
// Omit: Exclude specific properties
type UserWithoutDates = Omit<User, 'createdAt'>;
// Readonly: Immutable version
type ImmutableUser = Readonly<User>;
// Record: Object with specific key and value types
type UserMap = Record<string, User>;
// { [key: string]: User }
Custom Utility Types
// Make specific properties required
type RequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>;
type UserWithEmail = RequireFields<Partial<User>, 'email'>;
// email is required, others optional
// Deep partial
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
// Non-nullable properties
type NonNullableFields<T> = {
[P in keyof T]: NonNullable<T[P]>;
};
Module Organization
Barrel Exports
// models/index.ts - Barrel export
export { User, UserRole } from './user';
export { Order, OrderStatus } from './order';
export { Product } from './product';
// Clean imports
import { User, Order, Product } from './models';
Type-Only Imports
// Import types without runtime overhead
import type { User, Order } from './models';
// Mixed import
import { createUser, type User } from './models';
Declaration Files
// types/global.d.ts
declare global {
interface Window {
analytics: Analytics;
}
namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string;
API_KEY: string;
NODE_ENV: 'development' | 'production' | 'test';
}
}
}
export {}; // Make this a module
Error Handling
Typed Errors
// Custom error types
class ValidationError extends Error {
constructor(
message: string,
public readonly field: string,
public readonly code: string
) {
super(message);
this.name = 'ValidationError';
}
}
class NotFoundError extends Error {
constructor(public readonly resource: string, public readonly id: string) {
super(`${resource} with id ${id} not found`);
this.name = 'NotFoundError';
}
}
// Type-safe error handling
function handleError(error: unknown) {
if (error instanceof ValidationError) {
console.log(`Validation failed for ${error.field}: ${error.message}`);
} else if (error instanceof NotFoundError) {
console.log(`${error.resource} not found`);
} else if (error instanceof Error) {
console.log(`Unknown error: ${error.message}`);
} else {
console.log('Unknown error type');
}
}
Result Types
// Result type for explicit error handling
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
async function fetchUser(id: string): Promise<Result<User, 'NOT_FOUND' | 'NETWORK_ERROR'>> {
try {
const response = await fetch(`/users/${id}`);
if (response.status === 404) {
return { success: false, error: 'NOT_FOUND' };
}
const user = await response.json();
return { success: true, data: user };
} catch {
return { success: false, error: 'NETWORK_ERROR' };
}
}
// Usage
const result = await fetchUser('123');
if (result.success) {
console.log(result.data.name);
} else {
switch (result.error) {
case 'NOT_FOUND':
console.log('User not found');
break;
case 'NETWORK_ERROR':
console.log('Network error');
break;
}
}
Performance Considerations
Type-Only Imports for Bundle Size
// Bad: Imports runtime code even if only using types
import { SomeClass } from 'large-library';
type MyType = InstanceType<typeof SomeClass>;
// Good: Type-only import, no runtime impact
import type { SomeClass } from 'large-library';
Avoid Complex Conditional Types in Hot Paths
// Expensive type computation
type DeepReadonly<T> = T extends Function
? T
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
// Pre-compute complex types where possible
interface UserState {
user: Readonly<User>;
settings: Readonly<Settings>;
}
// Instead of DeepReadonly<UserState> everywhere
Key Takeaways
- Enable strict mode from the start; retrofitting is painful
- Avoid
any; useunknownwith type guards for truly unknown types - Use discriminated unions for state management
- Prefer interfaces for objects; use types for unions and utilities
- Constrain generics to prevent misuse
- Use branded types to prevent accidental type confusion
- Leverage utility types (Partial, Pick, Omit, etc.)
- Use type-only imports to avoid bundle bloat
- Design custom error types for better error handling
- Consider Result types for explicit error handling
- Organize types with barrel exports and declaration files
TypeScript is a tool. How you use it determines whether it helps.