TypeScript has become the standard for building scalable JavaScript applications. As we move into 2026, here are the best practices every developer should follow to write clean, maintainable, and type-safe code.
1. Always Enable Strict Mode
Enabling strict: true in your tsconfig.json catches more errors at compile time. This enables all strict type-checking options including noImplicitAny, strictNullChecks, and more.
// tsconfig.json { "compilerOptions": { "strict": true, "target": "ES2022", "module": "ESNext" } }
2. Leverage Type Inference
TypeScript is smart enough to infer types in many cases. Don't over-annotate when the type is obvious from the assignment. Let the compiler do its job.
// Bad - unnecessary type annotation const name: string = "Koeuk"; const age: number = 25; // Good - let TypeScript infer const name = "Koeuk"; const age = 25; // Do annotate when the type isn't obvious const response: ApiResponse<User> = await fetchUser(id);
3. Prefer Interfaces Over Type Aliases for Objects
Interfaces are extendable and provide better error messages. Use interface for defining object shapes and type for unions, intersections, and utility types.
// Use interface for object shapes interface User { id: number; name: string; email: string; } // Interfaces can be extended interface Admin extends User { role: "admin"; permissions: string[]; } // Use type for unions & utilities type Status = "active" | "inactive" | "pending"; type ReadonlyUser = Readonly<User>;
4. Master Utility Types
TypeScript provides powerful built-in utility types that help you transform and reuse existing types without duplicating code.
interface Post { id: number; title: string; content: string; author: string; createdAt: Date; } // Only some fields needed for creation type CreatePost = Pick<Post, "title" | "content">; // All fields optional for updates type UpdatePost = Partial<Post>; // Exclude specific fields type PostPreview = Omit<Post, "content">; // Record for key-value maps type PostMap = Record<string, Post>;
5. Use Discriminated Unions for State Management
Discriminated unions make it impossible to access data that doesn't exist in a given state. This pattern is especially useful for handling API responses and component states.
type AsyncState<T> = | { status: "idle" } | { status: "loading" } | { status: "success"; data: T } | { status: "error"; error: Error }; function handleState(state: AsyncState<User>) { switch (state.status) { case "loading": return "Loading..."; case "success": return state.data.name; // TS knows data exists case "error": return state.error.message; // TS knows error exists } }
6. Write Reusable Code with Generics
Generics allow you to create flexible, reusable functions and classes while maintaining type safety.
// Generic API fetcher async function fetchData<T>(url: string): Promise<T> { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); } return response.json() as Promise<T>; } // Usage - fully typed! const user = await fetchData<User>("/api/user/1"); const posts = await fetchData<Post[]>("/api/posts");
7. Use Const Assertions
The as const assertion tells TypeScript to infer the narrowest possible type, making values readonly and literal.
// Without as const - type is string[] const roles = ["admin", "user", "guest"]; // With as const - type is readonly ["admin", "user", "guest"] const roles = ["admin", "user", "guest"] as const; // Now you can derive types from it type Role = (typeof roles)[number]; // Result: "admin" | "user" | "guest"
8. Template Literal Types
Template literal types allow you to build string types dynamically, great for creating type-safe event names, CSS properties, or API routes.
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE"; type ApiVersion = "v1" | "v2"; // Build dynamic route types type ApiRoute = `/api/${ApiVersion}/${string}`; const route1: ApiRoute = "/api/v1/users"; // OK const route2: ApiRoute = "/api/v3/users"; // Error! // Type-safe event handlers type EventName = `on${Capitalize<"click" | "focus" | "blur">}`; // Result: "onClick" | "onFocus" | "onBlur"
9. Exhaustive Checking with Never
Use the never type to ensure all cases in a union are handled. If a new variant is added later, TypeScript will throw a compile error reminding you to handle it.
type Shape = | { kind: "circle"; radius: number } | { kind: "square"; side: number } | { kind: "triangle"; base: number; height: number }; function getArea(shape: Shape): number { switch (shape.kind) { case "circle": return Math.PI * shape.radius ** 2; case "square": return shape.side ** 2; case "triangle": return (shape.base * shape.height) / 2; default: // If you add a new shape and forget to handle it, // TypeScript will error here! const _exhaustive: never = shape; return _exhaustive; } }
10. Custom Type Guards
Type guards narrow types at runtime while keeping full type safety. Use the is keyword to create reusable type narrowing functions.
interface Fish { swim: () => void } interface Bird { fly: () => void } // Custom type guard function isFish(animal: Fish | Bird): animal is Fish { return (animal as Fish).swim !== undefined; } function move(animal: Fish | Bird) { if (isFish(animal)) { animal.swim(); // TS knows it's Fish } else { animal.fly(); // TS knows it's Bird } } // Also useful for filtering arrays const animals: (Fish | Bird)[] = getAnimals(); const fishes: Fish[] = animals.filter(isFish);
11. The Satisfies Operator (Game Changer)
Introduced in TypeScript 4.9, satisfies validates that a value matches a type without widening it. This means you get both type checking AND the narrowest inferred type. It's one of the most important features in modern TypeScript.
type Colors = Record<string, string | number[]>; // Problem with type annotation - loses specific type info const palette: Colors = { red: "#ff0000", green: "#00ff00", blue: [0, 0, 255] }; palette.red.toUpperCase(); // Error! TS thinks it might be number[] // Solution with satisfies - keeps narrow types! const palette = { red: "#ff0000", green: "#00ff00", blue: [0, 0, 255] } satisfies Colors; palette.red.toUpperCase(); // OK! TS knows red is string palette.blue.map(x => x); // OK! TS knows blue is number[]
Why this matters: Before satisfies, you had to choose between type safety (annotation) and type narrowing (inference). Now you get both. Use it whenever you want to validate a value against a type while keeping its specific inferred type.
12. Mapped Types for Type Transformations
Mapped types let you create new types by transforming every property in an existing type. Combined with template literal types, they become incredibly powerful for building type-safe APIs.
// Make all properties optional and nullable type Nullable<T> = { [K in keyof T]: T[K] | null; }; // Create getter functions for each property type Getters<T> = { [K in keyof T as `get${Capitalize<K & string>}`]: () => T[K]; }; interface User { name: string; age: number; } type UserGetters = Getters<User>; // Result: { getName: () => string; getAge: () => number } // Create event handlers from a state type type EventHandlers<T> = { [K in keyof T as `on${Capitalize<K & string>}Change`]: (value: T[K]) => void; }; type UserEvents = EventHandlers<User>; // Result: { onNameChange: (value: string) => void; onAgeChange: (value: number) => void }
13. Type-Safe Error Handling
JavaScript's catch clause types errors as unknown. Using a Result pattern gives you compile-time error handling without exceptions.
// Result type - no more try/catch guessing type Result<T, E = Error> = | { ok: true; data: T } | { ok: false; error: E }; async function fetchUser(id: string): Promise<Result<User>> { try { const res = await fetch(`/api/users/${id}`); if (!res.ok) { return { ok: false, error: new Error(`Status: ${res.status}`) }; } const data = await res.json(); return { ok: true, data }; } catch (e) { return { ok: false, error: e instanceof Error ? e : new Error(String(e)) }; } } // Usage - you MUST handle both cases const result = await fetchUser("123"); if (result.ok) { console.log(result.data.name); // TS knows data exists } else { console.error(result.error); // TS knows error exists }
Why this matters: With try/catch, TypeScript can't know what errors a function throws. The Result pattern makes error handling explicit in the type system - the caller is forced to check for errors before accessing data.
14. Branded Types for Extra Safety
Branded types prevent you from accidentally mixing up values that share the same underlying type. For example, a UserId and an OrderId are both strings, but passing one where the other is expected is a bug.
// Create a brand using intersection with a unique symbol type Brand<T, B> = T & { __brand: B }; type UserId = Brand<string, "UserId">; type OrderId = Brand<string, "OrderId">; function getUser(id: UserId) { /* ... */ } function getOrder(id: OrderId) { /* ... */ } // Factory functions to create branded values const userId = "user_123" as UserId; const orderId = "order_456" as OrderId; getUser(userId); // OK getUser(orderId); // Error! OrderId is not assignable to UserId // Real-world use: validated types type Email = Brand<string, "Email">; type PositiveNumber = Brand<number, "Positive">; function validateEmail(input: string): Email | null { return input.includes("@") ? input as Email : null; }
15. The Infer Keyword in Conditional Types
The infer keyword lets you extract types from within other types. Think of it as pattern matching for types - essential for building advanced type utilities.
// Extract return type of a function type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never; function createUser() { return { id: 1, name: "Koeuk" }; } type User = ReturnOf<typeof createUser>; // Result: { id: number; name: string } // Extract Promise value type type Unwrap<T> = T extends Promise<infer U> ? U : T; type A = Unwrap<Promise<string>>; // string type B = Unwrap<number>; // number // Extract array element type type ElementOf<T> = T extends (infer E)[] ? E : never; type C = ElementOf<string[]>; // string type D = ElementOf<number[]>; // number
Common Mistakes to Avoid
Using any to silence errors
Use unknown and narrow the type, or use // @ts-expect-error with a comment explaining why.
Non-null assertions (!) everywhere
Each ! is a potential runtime crash. Use optional chaining (?.) or proper null checks instead.
Type assertions (as) to force types
Assertions bypass the type checker. Use type guards or satisfies for safe validation instead.
Ignoring strictNullChecks
This is the single most valuable strict flag. Without it, null and undefined silently pass through every type.
Over-engineering types
If your type takes 20 lines to write and is hard to read, simplify it. Types should help your team, not intimidate them.
Quick Tips
Avoid any - Use unknown instead when the type is truly unknown. It forces you to narrow the type before using it.
Use satisfies - The satisfies operator validates a type without widening it: const config = { ... } satisfies Config.
Prefer Map over objects - When keys are dynamic, Map<K, V> gives better type safety than index signatures.
Use readonly - Mark arrays and properties as readonly when they shouldn't be mutated to catch accidental modifications.
Avoid enums - Prefer union types or as const objects. Enums add runtime code and have quirky behavior with reverse mappings.
Return types on public APIs - While inference is great internally, always annotate return types on exported functions to prevent accidental breaking changes.
Summary
- β Enable strict mode for maximum type safety
- β Let TypeScript infer types when possible
- β Use interfaces for objects, types for unions
- β Master utility types like Pick, Omit, Partial, Record
- β Use discriminated unions for state management
- β Write reusable code with generics
- β Use const assertions for literal types
- β Build dynamic types with template literal types
- β Use exhaustive checking with
never - β Create custom type guards for runtime narrowing
- β Use satisfies for validation without type widening
- β Transform types with mapped types
- β Use the Result pattern for type-safe error handling
- β Prevent ID mix-ups with branded types
- β Extract types with the infer keyword