Write safer, cleaner TypeScript with these battle-tested best practices covering types, generics, strict mode, utility types, and common mistakes to avoid.
TypeScript can be used minimally — sprinkling any everywhere and calling it done — or it can be used as a powerful correctness tool. The difference between the two is your team's knowledge of its best practices. This guide covers the patterns that make TypeScript genuinely useful.
Always start with strict: true in your tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
This enables strictNullChecks, noImplicitAny, strictFunctionTypes, and more. If you start a project without strict mode, adding it later is painful.
any — Use unknown Insteadany completely disables type checking. unknown forces you to narrow the type before using it:
// Bad
function processInput(input: any) {
return input.toUpperCase(); // No error, but crashes at runtime if input is a number
}
// Good
function processInput(input: unknown) {
if (typeof input === 'string') {
return input.toUpperCase(); // TypeScript is now satisfied
}
throw new Error('Expected string');
}
Instead of booleans and optional fields, model state explicitly:
// Messy
interface RequestState {
loading: boolean;
data?: User;
error?: string;
}
// Clean — only valid combinations are representable
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; message: string };
TypeScript ships with powerful utility types. Use them instead of duplicating type definitions:
interface User {
id: string;
name: string;
email: string;
password: string;
}
type PublicUser = Omit<User, 'password'>;
type CreateUserDTO = Pick<User, 'name' | 'email' | 'password'>;
type PartialUser = Partial<User>; // all fields optional
type ReadonlyUser = Readonly<User>; // all fields readonly
const ROLES = ['admin', 'editor', 'viewer'] as const;
type Role = typeof ROLES[number]; // "admin" | "editor" | "viewer"
const config = {
endpoint: '/api/v2',
timeout: 5000,
} as const;
// Interface for object shapes (supports declaration merging)
interface User {
id: string;
name: string;
}
// Type alias for unions, tuples, mapped types
type StringOrNumber = string | number;
type Pair<T> = [T, T];
// Too loose
function first(arr: any[]): any {
return arr[0];
}
// Type-safe and reusable
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const name = first(['Alice', 'Bob']); // inferred as string | undefined
Don't leave API data untyped. Define response shapes explicitly:
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
async function fetchUser(id: string): Promise<ApiResponse<User>> {
const res = await fetch(`/api/users/${id}`);
return res.json() as Promise<ApiResponse<User>>;
}
Regular enums compile to verbose JavaScript. Prefer:
// Instead of enum
const Direction = {
UP: 'UP',
DOWN: 'DOWN',
LEFT: 'LEFT',
RIGHT: 'RIGHT',
} as const;
type Direction = typeof Direction[keyof typeof Direction];
satisfies for Validation Without Wideningconst config = {
port: 3000,
host: 'localhost',
} satisfies Record<string, string | number>;
// config.port is still inferred as 3000, not number | string
The goal of TypeScript isn't just to add types — it's to make impossible states unrepresentable. When you use discriminated unions, strict mode, generics, and utility types properly, TypeScript catches entire categories of bugs before they reach production. These practices make that possible.