Why TypeScript Best Practices Matter
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.
1. Enable Strict Mode
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.
2. Avoid any — Use unknown Instead
any 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');
}
3. Use Discriminated Unions for State
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 };
4. Use Utility Types
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
5. Const Assertions for Literal Types
const ROLES = ['admin', 'editor', 'viewer'] as const;
type Role = typeof ROLES[number]; // "admin" | "editor" | "viewer"
const config = {
endpoint: '/api/v2',
timeout: 5000,
} as const;
6. Prefer Interfaces for Objects, Types for Unions
// 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];
7. Use Generics Properly
// 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
8. Type Your API Responses
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>>;
}
9. Never Use Enums — Use Const Enums or Union Types
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];
10. Use satisfies for Validation Without Widening
const config = {
port: 3000,
host: 'localhost',
} satisfies Record<string, string | number>;
// config.port is still inferred as 3000, not number | string
Conclusion
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.