|$ curl https://forge-ai.dev/api/markdown?path=docs/typescript/best-practices
$cat docs/typescript-—-best-practices-&-patterns.md
updated Recently·22 min read·published

TypeScript — Best Practices & Patterns

TypeScriptAdvanced🎯Free Tools
Introduction

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.

strict_intro.ts
TypeScript
1// The heart of TypeScript best practice: let the compiler help you.
2// Strict mode catches null dereferences, implicit any, and unchecked errors.
3
4function 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}
Dos and Don'ts

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.

AvoidPreferWhy
anyunknown or precise unionsany disables type checking for downstream code
ts-ignorets-expect-error with a commentExpect-error fails if the error disappears; ignore hides regressions
Implicit returnsExplicit return types on public APIsProtects consumers from leaked inference changes
Deeply nested ternariesDiscriminated unions + switchEasier narrowing and exhaustiveness checking
Enums for string setsLiteral union typesSmaller bundle, simpler inference, no reverse mapping
Barrel export everythingDirect imports or selective barrelsAvoids circular dependencies and accidental public APIs
Type assertionsType guards and narrowingAssertions override the compiler without runtime validation
Non-null assertions (!)Null checks or optional chaining! is a silent runtime crash risk
dos_donts.ts
TypeScript
1// Bad — any propagates and hides errors
2function loadUser_bad(id: any) {
3 return fetch(`/users/${id}`).then((r: any) => r.json());
4}
5
6// Good — unknown forces explicit validation
7async 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
16function 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

Treat as and ! as compiler overrides, not type conversions. They do not change runtime values. If the data comes from a network boundary, validate with a type guard or schema library such as Zod, Valibot, or io-ts.
Code Style & Conventions

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.

style_conventions.ts
TypeScript
1// ——— type vs interface ———
2// Use interface when you expect declaration merging or describe object shape.
3interface 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.
9type Status = "pending" | "active" | "archived";
10type UserTuple = [string, string];
11
12// ——— Naming conventions ———
13// PascalCase for types and interfaces; T, K, V for simple generics.
14interface ApiResponse<TData> {
15 data: TData;
16 error?: ApiError;
17}
18
19type ApiError = { code: string; message: string };
20
21// Descriptive generic names when there are multiple constraints.
22interface 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.
29export 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.
37function double(x: number) {
38 return x * 2;
39}

best practice

Prefer interface for public object shapes that may evolve, because declaration merging lets consumers extend them in declaration files. Prefer type for unions and computed types where union behavior matters. Do not mix the two for the same identifier.
Strict Configuration

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.

tsconfig.json
JSON
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.

strict_flags.ts
TypeScript
1// With noUncheckedIndexedAccess enabled:
2const users: User[] = [];
3const first = users[0]; // User | undefined
4first.name; // Error: first is possibly undefined
5
6// With exactOptionalPropertyTypes enabled:
7interface Config {
8 timeout?: number;
9}
10const a: Config = {}; // OK
11const b: Config = { timeout: 10 }; // OK
12const c: Config = { timeout: undefined }; // Error: undefined is not assignable
Generics & Utility Types

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.

generics_utilities.ts
TypeScript
1// ——— Generic with constraints ———
2function hasId<T extends { id: string }>(value: T): T {
3 return value;
4}
5
6// ——— Default generic parameters ———
7interface Paginated<TItem = unknown> {
8 items: TItem[];
9 total: number;
10 page: number;
11}
12
13// ——— Built-in utility types ———
14interface User {
15 id: string;
16 name: string;
17 email: string;
18 role: "admin" | "editor" | "viewer";
19}
20
21type UserPreview = Pick<User, "id" | "name">;
22type UserDraft = Omit<User, "id">;
23type PartialUser = Partial<User>;
24type ReadonlyUser = Readonly<User>;
25
26// ——— Mapped type for derived state ———
27type LoadingState<T> = {
28 [K in keyof T]: {
29 data: T[K] | null;
30 loading: boolean;
31 error: Error | null;
32 };
33};
34
35// Usage:
36type UserState = LoadingState<{ profile: User; preferences: UserPreferences }>;
37// { profile: { data: User | null; loading: boolean; error: Error | null }; ... }

info

Do not over-constrain generics. If a function only needs .length, constrain to { length: number } rather than a full array type. The smaller the constraint, the more reusable the function.
Narrowing & Type Guards

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.

narrowing.ts
TypeScript
1// ——— Discriminated unions ———
2type 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
7function 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
21function assertNever(value: never): never {
22 throw new Error(`Unhandled value: ${JSON.stringify(value)}`);
23}
24
25// ——— Custom type guard ———
26function isStringArray(value: unknown): value is string[] {
27 return Array.isArray(value) && value.every((v) => typeof v === "string");
28}
29
30function processInput(input: unknown): string[] {
31 if (isStringArray(input)) {
32 return input.map((s) => s.trim());
33 }
34 return [];
35}

best practice

Always include an assertNever helper in switch statements over discriminated unions. It turns missing cases into compile-time errors, which is far cheaper than discovering them in production logs.
Project Structure

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.

project_structure.ts
TypeScript
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.

type_placement.ts
TypeScript
1// Good — shared domain model
2types/user.ts
3 export interface User { ... }
4 export type UserRole = "admin" | "editor" | "viewer";
5
6// Good — co-located private type
7components/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 (.d.ts)

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.

declarations.d.ts
TypeScript
1// src/types/global.d.ts
2// Ambient declarations for values injected by the build environment.
3
4declare const __APP_VERSION__: string;
5declare const __API_BASE_URL__: string;
6
7// src/types/env.d.ts
8// ProcessEnv is declared in @types/node; extend it for custom variables.
9declare 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
19export {}; // 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.
23declare 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

Prefer contributing types to DefinitelyTyped or using a typed wrapper over maintaining large .d.ts files for public libraries. Reserve local declarations for project-specific globals, build-time constants, and untyped internal modules.
Performance

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.

performance.ts
TypeScript
1// ——— Prefer const assertions for literal inference ———
2const ROLES = ["admin", "editor", "viewer"] as const;
3type 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
10interface 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
19type SimpleMap<T> = {
20 [K in keyof T]: T[K] extends string ? string : T[K];
21};
22
23// ——— Use satisfies for better inference ———
24const 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

Watch the tsc --extendedDiagnostics output for high instantiate or check times. If a single file dominates, simplify its types, split it, or replace inferred mega-unions with explicit interfaces.
Security

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.

security.ts
TypeScript
1// ——— Branded types for validated input ———
2type ValidatedEmail = string & { readonly __brand: "ValidatedEmail" };
3
4function validateEmail(raw: string): ValidatedEmail | null {
5 if (/^[^s@]+@[^s@]+.[^s@]+$/.test(raw)) {
6 return raw as ValidatedEmail;
7 }
8 return null;
9}
10
11function sendEmail(to: ValidatedEmail, subject: string): void {
12 // Compiler guarantees this was validated.
13}
14
15const raw = "user@example.com";
16const email = validateEmail(raw);
17if (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 ———
23function setInnerHtml_bad(element: HTMLElement, html: string) {
24 element.innerHTML = html; // XSS risk
25}
26
27function setTextContent_safe(element: HTMLElement, text: string) {
28 element.textContent = text; // escapes automatically
29}

danger

Never use JSON.parse results directly without validation. The compiler sees unknown or a guessed shape, but attackers can send payloads that match the type at runtime yet exploit application logic. Always validate at trust boundaries.
Testing

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.

testing.ts
TypeScript
1// ——— Type-level tests with expect-type ———
2import { expectTypeOf } from "expect-type";
3
4type DeepReadonly<T> = {
5 readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
6};
7
8interface MutableUser {
9 name: string;
10 settings: { theme: "dark" | "light" };
11}
12
13expectTypeOf<DeepReadonly<MutableUser>>().toEqualTypeOf<{
14 readonly name: string;
15 readonly settings: { readonly theme: "dark" | "light" };
16}>();
17
18// ——— Typed test fixtures ———
19import { describe, it, expect } from "vitest";
20import { calculateTotal } from "./cart";
21
22describe("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.
Tooling

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.

package.json
JSON
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.

project_refs.json
JSON
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

Keep skipLibCheck: true unless you are authoring a library. It avoids wasted time on type incompatibilities inside node_modules. If you publish a package, set declaration: true and generate declaration maps so consumers can navigate to your source.
Code Review Checklist

Use this checklist for every TypeScript pull request. Automate the mechanical checks so reviewers can focus on architecture and correctness.

CheckAutomated?Details
Type checkstsc --noEmitZero errors, strict mode enabled
No implicit anytsc / eslintEvery public API is explicitly typed
No ts-ignoreeslintUse ts-expect-error with justification
NarrowingReviewAssertions replaced by guards or narrowing
Discriminated unionsReviewExhaustive switch with assertNever
Runtime validationReviewExternal data validated at boundaries
Unused codetsc / eslintNo unused locals, params, or imports
Formattingprettier --checkConsistent style across the codebase
LinteslintNo banned types or unsafe patterns
Dependenciesnpm auditNo known vulnerabilities in added packages
ci_checklist.sh
Bash
1# Run the full TypeScript gate locally before pushing
2npx tsc --noEmit
3npx eslint src/ --ext .ts,.tsx
4npx prettier --check "src/**/*.{ts,tsx}"
5npm audit --audit-level=moderate
Resources

These references are the authoritative sources for TypeScript behavior and ecosystem conventions. Bookmark them and consult them when the compiler surprises you.

$Blueprint — Engineering Documentation·Section ID: TS-BP·Revision: 1.0