TypeScript — Best Practices & Patterns
TypeScript scales JavaScript by adding a static type system, but the value you get depends heavily on how you use it. A permissive configuration and careless use of any can leave you with all the friction of types and none of the safety. This guide covers the practices that separate robust TypeScript codebases from fragile ones.
The recommendations here span configuration, type design, project structure, performance, security, testing, and tooling. They are aimed at teams that want compile-time confidence without sacrificing readability or delivery speed.
| 1 | // The heart of TypeScript best practice: let the compiler help you. |
| 2 | // Strict mode catches null dereferences, implicit any, and unchecked errors. |
| 3 | |
| 4 | function greet(name: string | null): string { |
| 5 | if (name === null) { |
| 6 | return "Hello, stranger"; |
| 7 | } |
| 8 | return `Hello, ${name.toUpperCase()}`; // safe: name is narrowed to string |
| 9 | } |
These rules are the fastest way to improve TypeScript quality. Most of them are enforced by strict or by linter rules, but understanding the intent prevents you from working around the guardrails.
| Avoid | Prefer | Why |
|---|---|---|
| any | unknown or precise unions | any disables type checking for downstream code |
| ts-ignore | ts-expect-error with a comment | Expect-error fails if the error disappears; ignore hides regressions |
| Implicit returns | Explicit return types on public APIs | Protects consumers from leaked inference changes |
| Deeply nested ternaries | Discriminated unions + switch | Easier narrowing and exhaustiveness checking |
| Enums for string sets | Literal union types | Smaller bundle, simpler inference, no reverse mapping |
| Barrel export everything | Direct imports or selective barrels | Avoids circular dependencies and accidental public APIs |
| Type assertions | Type guards and narrowing | Assertions override the compiler without runtime validation |
| Non-null assertions (!) | Null checks or optional chaining | ! is a silent runtime crash risk |
| 1 | // Bad — any propagates and hides errors |
| 2 | function loadUser_bad(id: any) { |
| 3 | return fetch(`/users/${id}`).then((r: any) => r.json()); |
| 4 | } |
| 5 | |
| 6 | // Good — unknown forces explicit validation |
| 7 | async function loadUser_good(id: string): Promise<User> { |
| 8 | const res = await fetch(`/users/${id}`); |
| 9 | const data: unknown = await res.json(); |
| 10 | if (!isUser(data)) { |
| 11 | throw new Error("Invalid user payload"); |
| 12 | } |
| 13 | return data; |
| 14 | } |
| 15 | |
| 16 | function isUser(value: unknown): value is User { |
| 17 | return ( |
| 18 | typeof value === "object" && |
| 19 | value !== null && |
| 20 | "id" in value && |
| 21 | typeof (value as Record<string, unknown>).id === "string" |
| 22 | ); |
| 23 | } |
warning
Consistent style reduces cognitive load. Use a formatter and linter, then focus on type-level conventions: when to use type versus interface, how to name generics, and where return-type annotations add value.
| 1 | // ——— type vs interface ——— |
| 2 | // Use interface when you expect declaration merging or describe object shape. |
| 3 | interface User { |
| 4 | id: string; |
| 5 | email: string; |
| 6 | } |
| 7 | |
| 8 | // Use type for unions, tuples, mapped types, or when you need a one-off alias. |
| 9 | type Status = "pending" | "active" | "archived"; |
| 10 | type UserTuple = [string, string]; |
| 11 | |
| 12 | // ——— Naming conventions ——— |
| 13 | // PascalCase for types and interfaces; T, K, V for simple generics. |
| 14 | interface ApiResponse<TData> { |
| 15 | data: TData; |
| 16 | error?: ApiError; |
| 17 | } |
| 18 | |
| 19 | type ApiError = { code: string; message: string }; |
| 20 | |
| 21 | // Descriptive generic names when there are multiple constraints. |
| 22 | interface Repository<TEntity, TId> { |
| 23 | findById(id: TId): Promise<TEntity | null>; |
| 24 | save(entity: TEntity): Promise<void>; |
| 25 | } |
| 26 | |
| 27 | // ——— Explicit return types on public APIs ——— |
| 28 | // This prevents implementation details from leaking into the public contract. |
| 29 | export function formatCurrency(amount: number): string { |
| 30 | return new Intl.NumberFormat("en-US", { |
| 31 | style: "currency", |
| 32 | currency: "USD", |
| 33 | }).format(amount); |
| 34 | } |
| 35 | |
| 36 | // Inside private helpers, inference is usually fine. |
| 37 | function double(x: number) { |
| 38 | return x * 2; |
| 39 | } |
best practice
The single highest-leverage TypeScript decision is enabling strict: true. It activates noImplicitAny, strictNullChecks, strictFunctionTypes, and other checks that catch the most expensive bugs at compile time.
| 1 | { |
| 2 | "compilerOptions": { |
| 3 | "target": "ES2022", |
| 4 | "module": "NodeNext", |
| 5 | "moduleResolution": "NodeNext", |
| 6 | "lib": ["ES2022", "DOM"], |
| 7 | "strict": true, |
| 8 | "noImplicitAny": true, |
| 9 | "strictNullChecks": true, |
| 10 | "strictFunctionTypes": true, |
| 11 | "noUncheckedIndexedAccess": true, |
| 12 | "exactOptionalPropertyTypes": true, |
| 13 | "noImplicitReturns": true, |
| 14 | "noFallthroughCasesInSwitch": true, |
| 15 | "noUnusedLocals": true, |
| 16 | "noUnusedParameters": true, |
| 17 | "forceConsistentCasingInFileNames": true, |
| 18 | "esModuleInterop": true, |
| 19 | "skipLibCheck": true, |
| 20 | "declaration": true, |
| 21 | "declarationMap": true, |
| 22 | "sourceMap": true, |
| 23 | "outDir": "./dist", |
| 24 | "rootDir": "./src" |
| 25 | }, |
| 26 | "include": ["src/**/*"], |
| 27 | "exclude": ["node_modules", "dist", "**/*.test.ts"] |
| 28 | } |
Two flags deserve special attention. noUncheckedIndexedAccess forces you to handle the possibility that an array or record lookup returns undefined. exactOptionalPropertyTypes distinguishes between a missing property and an explicit undefined value.
| 1 | // With noUncheckedIndexedAccess enabled: |
| 2 | const users: User[] = []; |
| 3 | const first = users[0]; // User | undefined |
| 4 | first.name; // Error: first is possibly undefined |
| 5 | |
| 6 | // With exactOptionalPropertyTypes enabled: |
| 7 | interface Config { |
| 8 | timeout?: number; |
| 9 | } |
| 10 | const a: Config = {}; // OK |
| 11 | const b: Config = { timeout: 10 }; // OK |
| 12 | const c: Config = { timeout: undefined }; // Error: undefined is not assignable |
Generics make types reusable without losing precision. Use constraints to express requirements, provide defaults for convenience, and leverage built-in utility types to derive new types rather than duplicating shape definitions.
| 1 | // ——— Generic with constraints ——— |
| 2 | function hasId<T extends { id: string }>(value: T): T { |
| 3 | return value; |
| 4 | } |
| 5 | |
| 6 | // ——— Default generic parameters ——— |
| 7 | interface Paginated<TItem = unknown> { |
| 8 | items: TItem[]; |
| 9 | total: number; |
| 10 | page: number; |
| 11 | } |
| 12 | |
| 13 | // ——— Built-in utility types ——— |
| 14 | interface User { |
| 15 | id: string; |
| 16 | name: string; |
| 17 | email: string; |
| 18 | role: "admin" | "editor" | "viewer"; |
| 19 | } |
| 20 | |
| 21 | type UserPreview = Pick<User, "id" | "name">; |
| 22 | type UserDraft = Omit<User, "id">; |
| 23 | type PartialUser = Partial<User>; |
| 24 | type ReadonlyUser = Readonly<User>; |
| 25 | |
| 26 | // ——— Mapped type for derived state ——— |
| 27 | type LoadingState<T> = { |
| 28 | [K in keyof T]: { |
| 29 | data: T[K] | null; |
| 30 | loading: boolean; |
| 31 | error: Error | null; |
| 32 | }; |
| 33 | }; |
| 34 | |
| 35 | // Usage: |
| 36 | type UserState = LoadingState<{ profile: User; preferences: UserPreferences }>; |
| 37 | // { profile: { data: User | null; loading: boolean; error: Error | null }; ... } |
info
Type narrowing is how you move from a broad type to a specific one. Use typeof, instanceof, in, discriminated unions, and custom type guards to avoid unsafe assertions.
| 1 | // ——— Discriminated unions ——— |
| 2 | type ApiEvent = |
| 3 | | { type: "user:created"; payload: { id: string; email: string } } |
| 4 | | { type: "user:deleted"; payload: { id: string } } |
| 5 | | { type: "user:updated"; payload: { id: string; changes: Partial<User> } }; |
| 6 | |
| 7 | function handleEvent(event: ApiEvent): string { |
| 8 | switch (event.type) { |
| 9 | case "user:created": |
| 10 | return event.payload.email; // narrowed to created payload |
| 11 | case "user:deleted": |
| 12 | return event.payload.id; |
| 13 | case "user:updated": |
| 14 | return event.payload.id; |
| 15 | default: |
| 16 | // Exhaustiveness check — compiler errors if a case is missing |
| 17 | return assertNever(event); |
| 18 | } |
| 19 | } |
| 20 | |
| 21 | function assertNever(value: never): never { |
| 22 | throw new Error(`Unhandled value: ${JSON.stringify(value)}`); |
| 23 | } |
| 24 | |
| 25 | // ——— Custom type guard ——— |
| 26 | function isStringArray(value: unknown): value is string[] { |
| 27 | return Array.isArray(value) && value.every((v) => typeof v === "string"); |
| 28 | } |
| 29 | |
| 30 | function processInput(input: unknown): string[] { |
| 31 | if (isStringArray(input)) { |
| 32 | return input.map((s) => s.trim()); |
| 33 | } |
| 34 | return []; |
| 35 | } |
best practice
A well-organized TypeScript project separates source, generated output, tests, and type declarations. Use path mapping to avoid brittle relative imports, and keep barrel files focused to prevent circular dependencies.
| 1 | // Recommended project layout |
| 2 | // my-app/ |
| 3 | // src/ |
| 4 | // components/ # React or UI components |
| 5 | // lib/ # Shared utilities and helpers |
| 6 | // hooks/ # Custom React hooks |
| 7 | // types/ # Shared domain types |
| 8 | // services/ # API clients and external integrations |
| 9 | // utils/ # Pure helper functions |
| 10 | // tests/ |
| 11 | // dist/ # Compiled output |
| 12 | // tsconfig.json |
| 13 | // package.json |
| 14 | |
| 15 | // tsconfig path mapping example |
| 16 | { |
| 17 | "compilerOptions": { |
| 18 | "baseUrl": ".", |
| 19 | "paths": { |
| 20 | "@/*": ["./src/*"], |
| 21 | "@/components/*": ["./src/components/*"], |
| 22 | "@/lib/*": ["./src/lib/*"], |
| 23 | "@/types/*": ["./src/types/*"] |
| 24 | } |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | // Usage: import { Button } from "@/components/Button"; |
| 29 | // import { formatDate } from "@/lib/format"; |
Keep domain types in a central location when they are shared across features. Co-locate types with implementation when they are private to a single module. Avoid creating a types.ts dump file that every module imports.
| 1 | // Good — shared domain model |
| 2 | types/user.ts |
| 3 | export interface User { ... } |
| 4 | export type UserRole = "admin" | "editor" | "viewer"; |
| 5 | |
| 6 | // Good — co-located private type |
| 7 | components/Avatar.tsx |
| 8 | interface AvatarProps { |
| 9 | src: string; |
| 10 | alt: string; |
| 11 | size?: "sm" | "md" | "lg"; |
| 12 | } |
| 13 | export function Avatar(props: AvatarProps) { ... } |
| 14 | |
| 15 | // Bad — global types.ts with unrelated shapes |
| 16 | // types/index.ts contains User, ButtonProps, API config, and theme tokens. |
Declaration files describe the types of JavaScript code or global APIs. Use them for third-party libraries without types, for ambient module declarations, and for global configuration objects injected at build time.
| 1 | // src/types/global.d.ts |
| 2 | // Ambient declarations for values injected by the build environment. |
| 3 | |
| 4 | declare const __APP_VERSION__: string; |
| 5 | declare const __API_BASE_URL__: string; |
| 6 | |
| 7 | // src/types/env.d.ts |
| 8 | // ProcessEnv is declared in @types/node; extend it for custom variables. |
| 9 | declare global { |
| 10 | namespace NodeJS { |
| 11 | interface ProcessEnv { |
| 12 | NODE_ENV: "development" | "production" | "test"; |
| 13 | DATABASE_URL: string; |
| 14 | SESSION_SECRET: string; |
| 15 | } |
| 16 | } |
| 17 | } |
| 18 | |
| 19 | export {}; // make this a module so the global augmentation applies |
| 20 | |
| 21 | // src/types/legacy-lib.d.ts |
| 22 | // Type a third-party JS module that ships without declarations. |
| 23 | declare module "legacy-csv-parser" { |
| 24 | export interface CsvOptions { |
| 25 | delimiter?: string; |
| 26 | header?: boolean; |
| 27 | } |
| 28 | export function parse(input: string, options?: CsvOptions): Record<string, string>[]; |
| 29 | } |
info
TypeScript performance matters at compile time. Deeply nested conditional types, excessive generic inference, and huge union types can slow down type checking and IntelliSense. Keep type definitions shallow and explicit.
| 1 | // ——— Prefer const assertions for literal inference ——— |
| 2 | const ROLES = ["admin", "editor", "viewer"] as const; |
| 3 | type Role = (typeof ROLES)[number]; // "admin" | "editor" | "viewer" |
| 4 | |
| 5 | // ——— Avoid huge unions ——— |
| 6 | // Bad: a union of every possible CSS property value |
| 7 | // type AllCssValues = ... // thousands of members |
| 8 | |
| 9 | // Good: keep unions bounded by domain |
| 10 | interface ColorToken { |
| 11 | name: "primary" | "secondary" | "danger"; |
| 12 | shade: 50 | 100 | 200 | 300 | 400 | 500; |
| 13 | } |
| 14 | |
| 15 | // ——— Limit conditional type depth ——— |
| 16 | // Deeply nested conditionals explode compile time. |
| 17 | // Prefer helper types or runtime validation for complex mappings. |
| 18 | |
| 19 | type SimpleMap<T> = { |
| 20 | [K in keyof T]: T[K] extends string ? string : T[K]; |
| 21 | }; |
| 22 | |
| 23 | // ——— Use satisfies for better inference ——— |
| 24 | const config = { |
| 25 | api: "https://api.example.com", |
| 26 | retries: 3, |
| 27 | timeout: 5000, |
| 28 | } satisfies Record<string, string | number>; |
| 29 | |
| 30 | // config.api is inferred as the literal "https://api.example.com" |
| 31 | // but the object still satisfies the broader shape. |
warning
TypeScript does not provide runtime security. Types are erased at compile time, so every external boundary — HTTP bodies, query parameters, localStorage, URL fragments — must still be validated. Use types to encode validated state, not to assume it.
| 1 | // ——— Branded types for validated input ——— |
| 2 | type ValidatedEmail = string & { readonly __brand: "ValidatedEmail" }; |
| 3 | |
| 4 | function validateEmail(raw: string): ValidatedEmail | null { |
| 5 | if (/^[^s@]+@[^s@]+.[^s@]+$/.test(raw)) { |
| 6 | return raw as ValidatedEmail; |
| 7 | } |
| 8 | return null; |
| 9 | } |
| 10 | |
| 11 | function sendEmail(to: ValidatedEmail, subject: string): void { |
| 12 | // Compiler guarantees this was validated. |
| 13 | } |
| 14 | |
| 15 | const raw = "user@example.com"; |
| 16 | const email = validateEmail(raw); |
| 17 | if (email) { |
| 18 | sendEmail(email, "Welcome"); // OK |
| 19 | } |
| 20 | // sendEmail(raw, "Welcome"); // Error: raw is not ValidatedEmail |
| 21 | |
| 22 | // ——— Never trust DOM insertion with dynamic strings ——— |
| 23 | function setInnerHtml_bad(element: HTMLElement, html: string) { |
| 24 | element.innerHTML = html; // XSS risk |
| 25 | } |
| 26 | |
| 27 | function setTextContent_safe(element: HTMLElement, text: string) { |
| 28 | element.textContent = text; // escapes automatically |
| 29 | } |
danger
TypeScript complements runtime tests but does not replace them. Write type-level tests for complex generic utilities, and use typed test runners to catch mismatches between implementation and fixtures.
| 1 | // ——— Type-level tests with expect-type ——— |
| 2 | import { expectTypeOf } from "expect-type"; |
| 3 | |
| 4 | type DeepReadonly<T> = { |
| 5 | readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]; |
| 6 | }; |
| 7 | |
| 8 | interface MutableUser { |
| 9 | name: string; |
| 10 | settings: { theme: "dark" | "light" }; |
| 11 | } |
| 12 | |
| 13 | expectTypeOf<DeepReadonly<MutableUser>>().toEqualTypeOf<{ |
| 14 | readonly name: string; |
| 15 | readonly settings: { readonly theme: "dark" | "light" }; |
| 16 | }>(); |
| 17 | |
| 18 | // ——— Typed test fixtures ——— |
| 19 | import { describe, it, expect } from "vitest"; |
| 20 | import { calculateTotal } from "./cart"; |
| 21 | |
| 22 | describe("calculateTotal", () => { |
| 23 | it("sums line items", () => { |
| 24 | const items: LineItem[] = [ |
| 25 | { id: "1", price: 10, quantity: 2 }, |
| 26 | { id: "2", price: 5, quantity: 3 }, |
| 27 | ]; |
| 28 | expect(calculateTotal(items)).toBe(35); |
| 29 | }); |
| 30 | }); |
| 31 | |
| 32 | // ——— Compile tests as a gate ——— |
| 33 | // package.json script: |
| 34 | // "typecheck": "tsc --noEmit" |
| 35 | // Run it in CI before tests run. |
A modern TypeScript toolchain includes a formatter, linter, type checker, and editor integration. Configure them in one place and run them in CI before any code merges.
| 1 | { |
| 2 | "scripts": { |
| 3 | "dev": "next dev", |
| 4 | "build": "next build", |
| 5 | "typecheck": "tsc --noEmit", |
| 6 | "lint": "next lint", |
| 7 | "format": "prettier --write .", |
| 8 | "format:check": "prettier --check ." |
| 9 | }, |
| 10 | "devDependencies": { |
| 11 | "typescript": "^5.5.0", |
| 12 | "@types/node": "^20.0.0", |
| 13 | "@types/react": "^18.3.0", |
| 14 | "eslint": "^8.57.0", |
| 15 | "eslint-config-next": "^14.2.0", |
| 16 | "prettier": "^3.3.0" |
| 17 | } |
| 18 | } |
For monorepos, use project references to split the codebase into independent compilation units. This reduces incremental build times and enforces clear dependency boundaries between packages.
| 1 | // packages/tsconfig.base.json |
| 2 | { |
| 3 | "compilerOptions": { |
| 4 | "composite": true, |
| 5 | "declaration": true, |
| 6 | "declarationMap": true, |
| 7 | "sourceMap": true, |
| 8 | "strict": true |
| 9 | } |
| 10 | } |
| 11 | |
| 12 | // packages/app/tsconfig.json |
| 13 | { |
| 14 | "extends": "../tsconfig.base.json", |
| 15 | "compilerOptions": { |
| 16 | "outDir": "./dist" |
| 17 | }, |
| 18 | "references": [ |
| 19 | { "path": "../shared" } |
| 20 | ] |
| 21 | } |
| 22 | |
| 23 | // Build order is enforced: shared must compile before app. |
info
Use this checklist for every TypeScript pull request. Automate the mechanical checks so reviewers can focus on architecture and correctness.
| Check | Automated? | Details |
|---|---|---|
| Type checks | tsc --noEmit | Zero errors, strict mode enabled |
| No implicit any | tsc / eslint | Every public API is explicitly typed |
| No ts-ignore | eslint | Use ts-expect-error with justification |
| Narrowing | Review | Assertions replaced by guards or narrowing |
| Discriminated unions | Review | Exhaustive switch with assertNever |
| Runtime validation | Review | External data validated at boundaries |
| Unused code | tsc / eslint | No unused locals, params, or imports |
| Formatting | prettier --check | Consistent style across the codebase |
| Lint | eslint | No banned types or unsafe patterns |
| Dependencies | npm audit | No known vulnerabilities in added packages |
| 1 | # Run the full TypeScript gate locally before pushing |
| 2 | npx tsc --noEmit |
| 3 | npx eslint src/ --ext .ts,.tsx |
| 4 | npx prettier --check "src/**/*.{ts,tsx}" |
| 5 | npm audit --audit-level=moderate |
These references are the authoritative sources for TypeScript behavior and ecosystem conventions. Bookmark them and consult them when the compiler surprises you.
- TypeScript Handbook — official language documentation
- tsconfig Reference — every compiler option explained
- TypeScript Performance Wiki — how to keep compile times low
- Total TypeScript — patterns and generic mastery
- typescript-eslint — lint rules for type-aware checks
- DefinitelyTyped — community type declarations