TypeScript Best Practices for 2024
Level up your TypeScript skills with modern best practices, type safety patterns, and practical tips for building robust applications.
TypeScript Best Practices for 2024
TypeScript has become the de facto standard for building large-scale JavaScript applications. As the language evolves, so do the best practices. Let’s explore modern TypeScript patterns that will make your code more maintainable and type-safe.
Use Strict Mode Always
Enable strict mode in your tsconfig.json - no exceptions!
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true
}
}
These flags catch potential bugs at compile time rather than runtime.
Prefer Type Inference
Let TypeScript infer types when possible. Don’t over-annotate!
// ❌ Bad - redundant type annotation
const name: string = "John";
const age: number = 30;
// ✅ Good - let TypeScript infer
const name = "John";
const age = 30;
However, always annotate function return types:
// ✅ Good - explicit return type
function getUserName(id: number): string {
return `user-${id}`;
}
Use unknown Instead of any
unknown is type-safe, any is not:
// ❌ Avoid any
function processData(data: any) {
return data.value; // No type checking
}
// ✅ Use unknown
function processData(data: unknown) {
if (typeof data === "object" && data !== null && "value" in data) {
return (data as { value: string }).value;
}
throw new Error("Invalid data");
}
Discriminated Unions for State Management
Model your application state with discriminated unions:
type LoadingState = {
status: "loading";
};
type SuccessState<T> = {
status: "success";
data: T;
};
type ErrorState = {
status: "error";
error: Error;
};
type AsyncState<T> = LoadingState | SuccessState<T> | ErrorState;
function handleState<T>(state: AsyncState<T>) {
switch (state.status) {
case "loading":
return "Loading...";
case "success":
return state.data; // TypeScript knows data exists
case "error":
return state.error.message; // TypeScript knows error exists
}
}
Use satisfies for Better Type Checking
The satisfies operator (TypeScript 4.9+) ensures type safety while preserving literal types:
type Colors = "red" | "green" | "blue";
// ❌ Old way - loses literal types
const palette: Record<string, Colors> = {
primary: "red",
secondary: "green",
};
// ✅ New way - keeps literal types and ensures type safety
const palette = {
primary: "red",
secondary: "green",
} satisfies Record<string, Colors>;
// Now TypeScript knows palette.primary is specifically "red", not just Colors
Branded Types for Nominal Typing
Create distinct types even when they’re structurally identical:
type UserId = string & { readonly __brand: "UserId" };
type ProductId = string & { readonly __brand: "ProductId" };
function getUserById(id: UserId) {
// Implementation
}
function getProductById(id: ProductId) {
// Implementation
}
const userId = "user-123" as UserId;
const productId = "product-456" as ProductId;
getUserById(userId); // ✅ OK
getUserById(productId); // ❌ Error - Type mismatch!
Const Assertions for Immutable Data
Use as const for truly immutable data:
// Without const assertion
const config = {
apiUrl: "https://api.example.com",
timeout: 5000,
};
// Type: { apiUrl: string; timeout: number }
// With const assertion
const config = {
apiUrl: "https://api.example.com",
timeout: 5000,
} as const;
// Type: { readonly apiUrl: "https://api.example.com"; readonly timeout: 5000 }
Generic Constraints for Better APIs
Use generic constraints to create flexible yet type-safe APIs:
// Basic generic
function firstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
// With constraints
interface HasId {
id: string;
}
function findById<T extends HasId>(items: T[], id: string): T | undefined {
return items.find(item => item.id === id);
}
// Now TypeScript ensures T has an id property
Utility Types Are Your Friends
Master the built-in utility types:
interface User {
id: string;
name: string;
email: string;
age: number;
}
// Pick specific properties
type UserPreview = Pick<User, "id" | "name">;
// Omit properties
type UserWithoutId = Omit<User, "id">;
// Make all properties optional
type PartialUser = Partial<User>;
// Make all properties required
type RequiredUser = Required<Partial<User>>;
// Make all properties readonly
type ReadonlyUser = Readonly<User>;
// Extract return type
function getUser(): User {
return { id: "1", name: "John", email: "[email protected]", age: 30 };
}
type UserReturnType = ReturnType<typeof getUser>;
Template Literal Types
Create powerful string types:
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type Endpoint = "/users" | "/products" | "/orders";
// Combine them
type ApiRoute = `${HttpMethod} ${Endpoint}`;
// Result: "GET /users" | "POST /users" | "GET /products" | ... (12 combinations)
// Practical example
type CssUnit = "px" | "rem" | "em" | "%";
type CssValue<T extends number> = `${T}${CssUnit}`;
const margin: CssValue<16> = "16px"; // ✅
const padding: CssValue<2> = "2rem"; // ✅
Avoid Enums, Use Union Types
Enums have quirks. Use union types instead:
// ❌ Enum
enum Status {
Active = "ACTIVE",
Inactive = "INACTIVE",
}
// ✅ Union type
const Status = {
Active: "ACTIVE",
Inactive: "INACTIVE",
} as const;
type Status = (typeof Status)[keyof typeof Status];
// Benefits:
// - No runtime code
// - Better tree-shaking
// - More flexible
Type Guards for Runtime Safety
Create custom type guards for better type narrowing:
interface Cat {
meow(): void;
}
interface Dog {
bark(): void;
}
// Type guard
function isCat(pet: Cat | Dog): pet is Cat {
return "meow" in pet;
}
function makeSound(pet: Cat | Dog) {
if (isCat(pet)) {
pet.meow(); // TypeScript knows it's a Cat
} else {
pet.bark(); // TypeScript knows it's a Dog
}
}
Async Best Practices
Always type your promises properly:
// ❌ Bad
async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// ✅ Good
interface User {
id: string;
name: string;
}
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
Avoid Non-null Assertions
The ! operator is dangerous. Use proper type guards instead:
// ❌ Dangerous
function getFirstUser(users: User[]) {
return users[0]!; // What if array is empty?
}
// ✅ Safe
function getFirstUser(users: User[]): User | undefined {
return users[0];
}
// Or throw if it must exist
function getFirstUser(users: User[]): User {
const first = users[0];
if (!first) {
throw new Error("No users found");
}
return first;
}
Organize Types Properly
Keep your types organized and discoverable:
// types/user.ts
export interface User {
id: string;
name: string;
}
export type UserId = User["id"];
export type UserName = User["name"];
// types/api.ts
import type { User } from "./user";
export interface ApiResponse<T> {
data: T;
status: number;
}
export type UserResponse = ApiResponse<User>;
Conclusion
TypeScript is more than just adding types to JavaScript. It’s about leveraging the type system to catch bugs early, improve code documentation, and enhance the development experience.
Start applying these best practices today, and you’ll write more robust, maintainable code. Remember: strict mode is your friend, type inference is powerful, and any is the enemy!
Happy typing! 🎯
Resources: