Chapter 39: TypeScript Best Practices
TypeScript Best Practices (2025–2026 realistic edition – what actually works in production codebases)
Imagine we are sitting together for a 2-hour code-review + refactoring session. I will not give you a list of 50 rules from some blog post. Instead, I will show you the ~15–20 practices that separate “it works” codebases from “it scales, it’s pleasant to work on, bugs are rare, refactoring is fast” codebases in late 2025 / early 2026.
We’ll go from most important → less critical, with real examples.
1. Use strict: true (or at least 90% of strict flags)
This is non-negotiable in almost every healthy TypeScript project in 2026.
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// tsconfig.json – minimal strict set that almost everyone enables { "compilerOptions": { "strict": true, // or granular (many teams do this during migration) "noImplicitAny": true, "strictNullChecks": true, "noImplicitThis": true, "useUnknownInCatchVariables": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "exactOptionalPropertyTypes": true, // very important since ~2023 "noPropertyAccessFromIndexSignature": true, "noUncheckedIndexedAccess": true } } |
Why so strict? Because after ~6–12 months of using strict mode, teams report 60–85% fewer runtime errors and much faster onboarding.
2. Prefer interfaces over type aliases for object shapes (most teams)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// 👍 Most teams in 2026 interface User { id: number; name: string; email?: string; } // 👎 Less common for plain objects type User = { id: number; name: string; email?: string; }; |
When to use type instead
- Union / intersection / primitive / tuple / mapped / conditional types
- When you need & / / extends in complex ways
|
0 1 2 3 4 5 6 7 8 |
type ID = string | number; type Status = "idle" | "loading" | "success" | "error"; type ApiResponse<T> = { success: true; data: T } | { success: false; error: string }; |
3. Use satisfies operator everywhere it makes sense (TS 4.9+ killer feature)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
const theme = { colors: { primary: "#3b82f6", primaryHover: "#2563eb", danger: "#ef4444", }, spacing: { sm: "0.5rem", md: "1rem", lg: "1.5rem", } } satisfies { colors: Record<string, string>; spacing: Record<string, string>; }; // Now theme.colors.primary → "#3b82f6" (literal preserved) // theme.colors.oops → error (nice!) |
Before satisfies people used as const → lost a lot of flexibility. Now → satisfies is the preferred way for configs, themes, button variants, routes, i18n keys, etc.
4. Prefer as const + keyof typeof for enum-like constants
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 |
const UserRole = { admin: "ADMIN", editor: "EDITOR", viewer: "VIEWER", } as const; type UserRoleKey = keyof typeof UserRole; // "admin" | "editor" | "viewer" type UserRoleValue = (typeof UserRole)[UserRoleKey]; // "ADMIN" | "EDITOR" | "VIEWER" |
This pattern replaced 95% of enum usages after ~2022.
5. Use unknown in catch blocks (never any)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
try { // ... } catch (err: unknown) { if (err instanceof Error) { console.error(err.message); } else if (typeof err === "string") { console.error(err); } else { console.error("Unknown error", err); } } |
6. Prefer Result<T> / Either style over throwing for expected failures
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
type Result<T, E = Error> = | { success: true; data: T } | { success: false; error: E }; async function fetchUser(id: number): Promise<Result<User>> { try { // ... } catch (err) { return { success: false, error: err instanceof Error ? err : new Error("Unknown") }; } } |
Very common in API layers, React Query wrappers, form submissions.
7. Use branded types / nominal typing for IDs, emails, etc.
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 |
type UserId = string & { __brand: "UserId" }; type Email = string & { __brand: "Email" }; function getUser(id: UserId) { /* ... */ } // Prevents accidental swapping getUser("user-123" as UserId); // ok getUser("user@example.com" as UserId); // error |
8. Avoid any like fire – use unknown or generics
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 |
// Bad function process(data: any) {} // Better function process<T>(data: T) {} // Even better – constrain function process<T extends { id: number }>(data: T) {} |
9. Prefer function declarations / arrow functions with explicit types
|
0 1 2 3 4 5 6 7 8 9 10 11 12 |
// Very clear function add(a: number, b: number): number { return a + b; } // Also good (especially in React) const multiply = (a: number, b: number): number => a * b; |
Avoid const add = (a, b) => a + b in public APIs – parameters become any.
10. Use exhaustive switch / discriminated unions
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
type Action = | { type: "FETCH_START" } | { type: "FETCH_SUCCESS"; payload: User[] } | { type: "FETCH_ERROR"; error: string }; function reducer(state: State, action: Action): State { switch (action.type) { case "FETCH_START": return { ...state, loading: true }; case "FETCH_SUCCESS": return { ...state, loading: false, data: action.payload }; case "FETCH_ERROR": return { ...state, loading: false, error: action.error }; default: // TypeScript error if you forget a case! const _exhaustiveCheck: never = action; return state; } } |
11. Prefer readonly + as const for configs / constants
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
const CONFIG = { apiUrl: "https://api.example.com", timeout: 10000, retryCount: 3, } as const satisfies { apiUrl: string; timeout: number; retryCount: number; }; |
Quick 2026 checklist – what good TS code usually has
- strict: true or close to it
- satisfies on literal objects
- as const + keyof typeof for constants
- Result<T> or similar for fallible operations
- Custom branded types for IDs, slugs, emails
- unknown in catch
- Discriminated unions + exhaustive checks
- Almost no any (only in very rare legacy interop)
- Explicit return types on public functions
- noUncheckedIndexedAccess + noPropertyAccessFromIndexSignature
Which of these practices feels most useful / most painful in your current code right now?
Want to focus deeper on any one of them?
- How to gradually enable strict mode without dying
- Real Result<T> + React Query / tRPC integration
- Branded types vs string literal unions
- Common ESLint + typescript-eslint rules that enforce these
- Anti-patterns that still hurt teams in 2026
Just say the word — we’ll zoom in right there 😄
