Contents

TypeScript Best Practices

Core Principles

TypeScript 6.0 should improve the design quality of a codebase, not merely add annotations. In production systems, the goal is to make invalid states hard to represent, make refactoring cheaper, and keep runtime behavior aligned with static assumptions.

A durable TypeScript style usually follows five principles:

  1. Model the domain first. Types should reflect business invariants, not UI convenience or transport noise.
  2. Push uncertainty to the edges. Treat inputs from HTTP, databases, queues, the filesystem, and third-party packages as unknown until validated.
  3. Prefer inference over ceremony. Let the compiler infer local details; write explicit types at boundaries and for important abstractions.
  4. Optimize for maintainability, not cleverness. Advanced type features are valuable when they reduce bugs and duplication, but harmful when they obscure intent.
  5. Keep compile-time and runtime contracts in sync. Static types do not validate production payloads; schema validation and tests still matter.

Installation and Versioning

Target the latest stable TypeScript 6.0.x as a project dependency, not a global tool. Pin the version in devDependencies, and upgrade deliberately with release-note review.

npm install -D typescript@~6.0.0
npx tsc --version

Recommended versioning practices:

  • Use a tilde range such as ~6.0.0 for predictable patch updates in production repositories.
  • Upgrade TypeScript independently from framework upgrades so regressions are easier to isolate.
  • Align editor, CI, and local CLI versions. A mismatch between VS Code’s language service and CI is a common source of confusion.
  • Record compiler upgrades in the changelog for shared libraries and platform repos.
  • For monorepos, centralize the TypeScript version unless there is a hard compatibility reason not to.

If you maintain libraries, test against both your pinned compiler and the lowest supported consumer environment.

tsconfig Strategy

A production repository should treat tsconfig as architecture, not boilerplate. Most teams benefit from a layered setup:

  • tsconfig.base.json: shared defaults
  • tsconfig.json: local editor experience and references
  • tsconfig.build.json: emit-focused production build
  • tsconfig.test.json: test-specific overrides when needed

A strong baseline for Node.js ESM projects:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2023"],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "useUnknownInCatchVariables": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "importsNotUsedAsValues": "error",
    "resolveJsonModule": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": false,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}

Guidance:

  • Default to skipLibCheck: false for libraries and platform code where type integrity matters. Relax only with a documented reason.
  • Use project references in monorepos or large applications to scale type checking.
  • Separate no-emit checking from production emit if your build tool is not tsc.
  • Keep environment-specific types explicit: browser, Node.js, workers, and tests should not leak globals into each other.

Strictness Profile

"strict": true is the floor, not the ceiling. High-signal teams usually enable additional checks because they catch the kinds of defects that survive code review.

Prioritize these flags:

  • noUncheckedIndexedAccess: forces you to handle missing dictionary and array entries.
  • exactOptionalPropertyTypes: distinguishes omission from explicit undefined.
  • noImplicitOverride: protects inheritance-based code during refactors.
  • useUnknownInCatchVariables: prevents accidental assumptions about thrown values.
  • noFallthroughCasesInSwitch: reduces subtle state bugs.
  • noPropertyAccessFromIndexSignature: useful when modeling untrusted maps.

Adoption rule: if a strict flag produces too much noise, do not disable it globally first. Measure the classes of issues it exposes, then decide whether to fix incrementally or isolate exceptions.

Type Design

Good type design starts with meaningful shapes and small, composable abstractions.

Prefer:

  • domain-focused aliases such as UserId, OrderStatus, IsoTimestamp
  • discriminated unions for state machines and workflow results
  • readonly data structures by default for immutable flows
  • explicit nullability rather than ambiguous optional chains everywhere

Avoid:

  • giant “god interfaces” reused across API, database, cache, and UI layers
  • encoding every runtime rule in exotic conditional types
  • broad Record<string, unknown> placeholders that never become concrete models

Example: use separate types for create, persistence, and view concerns.

type UserId = string & { readonly brand: unique symbol };

type CreateUserInput = {
  email: string;
  displayName: string;
};

type UserRecord = {
  id: UserId;
  email: string;
  displayName: string;
  createdAt: string;
  deletedAt: string | null;
};

type UserSummary = Pick<UserRecord, "id" | "displayName">;

unknown vs any

Use any only as a deliberate escape hatch with a comment or narrow wrapper. In most production code, unknown is the correct default for uncertain data.

  • any disables type safety and contaminates downstream inference.
  • unknown forces narrowing before use.
  • never documents impossible states and helps exhaustiveness checking.
function parseJson(text: string): unknown {
  return JSON.parse(text);
}

function isFeatureFlagMap(value: unknown): value is Record<string, boolean> {
  return typeof value === "object" && value !== null &&
    Object.values(value).every((item) => typeof item === "boolean");
}

When interoperability forces any—for example around legacy plugins—contain it in a single adapter module.

Unions and Intersections

Unions are ideal for modeling alternatives; intersections are best for composition. In production systems, discriminated unions are often the clearest way to encode workflow states.

type PaymentResult =
  | { kind: "authorized"; authorizationId: string }
  | { kind: "declined"; reason: string }
  | { kind: "failed"; retryable: boolean };

function handlePayment(result: PaymentResult): string {
  switch (result.kind) {
    case "authorized":
      return result.authorizationId;
    case "declined":
      return result.reason;
    case "failed":
      return result.retryable ? "retry" : "stop";
    default: {
      const exhaustive: never = result;
      return exhaustive;
    }
  }
}

Use intersections carefully. They work well when composing orthogonal capabilities such as timestamps, auditing, or pagination metadata. They are less effective when combining incompatible object shapes and hoping the result becomes meaningful.

Generics

Generics should preserve relationships between inputs and outputs. If they do not encode a real relationship, they are usually unnecessary.

Good uses of generics:

  • collection helpers
  • repository abstractions
  • reusable async result wrappers
  • schema-driven utilities
type Page<T> = {
  items: T[];
  nextCursor: string | null;
};

async function fetchPage<TDto>(url: string): Promise<Page<TDto>> {
  const response = await fetch(url);
  return response.json() as Promise<Page<TDto>>;
}

Guidelines:

  • Prefer descriptive names like TItem, TError, TContext when a single-letter name reduces clarity.
  • Constrain generics when the implementation depends on specific properties.
  • Avoid generic APIs that require callers to manually specify type arguments most of the time; inference should do the work.
  • Be suspicious of multi-layer conditional generics in app code unless they eliminate significant duplication.

satisfies

Use satisfies when you want to validate that a value conforms to a type without widening away useful literal information.

type RouteConfig = {
  path: `/${string}`;
  cache: "public" | "private";
  timeoutMs: number;
};

const routes = {
  health: { path: "/health", cache: "private", timeoutMs: 1_000 },
  docs: { path: "/docs", cache: "public", timeoutMs: 5_000 }
} satisfies Record<string, RouteConfig>;

This is especially useful for:

  • configuration objects
  • translation dictionaries
  • feature flag catalogs
  • route maps
  • test fixtures with precise literals

Prefer satisfies over broad as assertions whenever you want checking plus precise inference.

Template Literal Types

Template literal types are powerful for structured strings, but production use should stay pragmatic.

Effective use cases:

  • route patterns
  • event names
  • analytics keys
  • environment-variable naming conventions
  • CSS token or design-system identifiers
type Entity = "user" | "invoice";
type Action = "created" | "deleted";
type DomainEvent = `${Entity}.${Action}`;

Do not build type-level parsers for every string protocol. If runtime parsing is non-trivial, prefer validation code plus narrower branded results.

Utility Types

Built-in utility types are foundational, but they are often misused as shortcuts instead of deliberate models.

Use them to derive targeted views:

  • Pick and Omit for focused projections
  • Partial for patch semantics, not for “I do not know the shape yet”
  • Readonly for immutable APIs
  • Record for known key spaces
  • Exclude / Extract for union refinement
  • ReturnType, Parameters, and Awaited for integration layers

Example:

type User = {
  id: string;
  email: string;
  displayName: string;
  role: "admin" | "member";
};

type UserPatch = Partial<Pick<User, "displayName" | "role">>;
type PublicUser = Omit<User, "email">;

If utility-type chains become unreadable, stop and name the intermediate concept.

Module Boundaries

Most TypeScript defects in mature systems happen at boundaries, not inside tiny pure functions.

Best practices:

  • Export domain types and public interfaces, not internal helper types by default.
  • Keep file-level responsibilities narrow.
  • Avoid deep imports across feature boundaries.
  • Use import type for type-only dependencies to keep intent clear and ESM output clean.
  • Prefer named exports over default exports in shared libraries for refactorability and discoverability.

A healthy boundary often looks like this:

  • external data enters as unknown
  • validator converts it into a stable DTO
  • mapper transforms DTO into domain objects
  • application code consumes domain types only

Runtime Validation

TypeScript does not validate runtime input. Any data crossing a trust boundary must be parsed or validated.

Typical trust boundaries:

  • HTTP requests and responses
  • database rows
  • message queues
  • environment variables
  • CMS content
  • local storage or cookies
  • third-party SDK callbacks

Use a schema library such as Zod, Valibot, ArkType, or a JSON Schema pipeline, depending on your stack.

import { z } from "zod";

const UserDtoSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  displayName: z.string().min(1)
});

type UserDto = z.infer<typeof UserDtoSchema>;

function parseUserDto(value: unknown): UserDto {
  return UserDtoSchema.parse(value);
}

Static typing without runtime validation creates false confidence. Validation without typed outputs creates repetition. Production systems need both.

API and Client DTOs

Never reuse one type for every layer of a distributed system. DTOs exist because transport concerns differ from domain concerns.

Separate at least these concepts when complexity justifies it:

  • request DTOs
  • response DTOs
  • domain entities
  • persistence records
  • UI view models

Recommendations:

  • Treat API responses as validated DTOs first.
  • Map DTOs into domain types explicitly.
  • Version public API contracts intentionally.
  • Do not leak database column names into frontend types.
  • Prefer nullable fields over optional fields when the API distinguishes “present but empty” from “not sent”.

This separation increases code volume slightly but dramatically reduces coupling during migrations.

NodeNext and ESM Guidance

For modern Node.js projects, prefer module: "NodeNext" and moduleResolution: "NodeNext" unless you have a hard constraint to stay on CommonJS.

Key rules:

  • Use native ESM consistently if you choose it.
  • Include explicit file extensions in relative imports when your runtime requires them.
  • Prefer verbatimModuleSyntax: true to avoid surprising emit behavior.
  • Use import type where appropriate.
  • Understand package boundary fields: type, exports, imports, and types.
  • Avoid mixing CommonJS and ESM patterns casually in the same package.

For libraries:

  • decide whether you ship ESM only or dual packages
  • test the published package shape, not just source compilation
  • generate declarations that match actual entry points

For applications, keep runtime semantics simple; complexity at module boundaries compounds quickly.

Build and Typecheck Workflows

Treat transpilation and type-checking as separate concerns unless tsc is your only build tool.

A common production setup:

  • fast bundler/transpiler for build output (tsup, esbuild, swc, Vite, framework compiler)
  • tsc --noEmit for authoritative type checking
  • tsc -p tsconfig.build.json when declarations or compiler-driven emit are required

Example scripts:

{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "tsup src/index.ts --format esm --dts",
    "build:types": "tsc -p tsconfig.build.json"
  }
}

In CI:

  1. install with a frozen lockfile
  2. run lint
  3. run typecheck
  4. run tests
  5. run production build

Do not rely on “it builds” as proof that types are sound.

Testing

TypeScript tests should verify runtime behavior and type contracts separately.

Recommended layers:

  • unit tests for deterministic logic
  • integration tests for I/O boundaries and adapters
  • contract tests for API expectations
  • type tests for public libraries and advanced type utilities

Useful practices:

  • Run tests against compiled or realistic runtime paths when module behavior matters.
  • Use tsd, expect-type, or vitest type assertions for exported API guarantees.
  • Avoid over-mocking. Mock only the boundary you do not want to execute.
  • Keep fixtures typed with satisfies so they stay precise and maintainable.

Linting and Formatting

Use linting to catch semantic issues and formatting to remove style debate.

Recommended stack:

  • ESLint
  • typescript-eslint
  • Prettier
  • optionally eslint-plugin-import, eslint-plugin-unicorn, or framework-specific rules

Focus lint rules on signal:

  • ban unsafe any usage where practical
  • detect floating promises
  • prevent misused async functions
  • enforce consistent type imports
  • catch unnecessary assertions and redundant conditions

Formatting should be automated and mostly uncontroversial. Do not encode formatting preferences as heavy lint rules if Prettier already owns them.

Performance

TypeScript performance matters in large codebases. Slow editor feedback reduces engineering quality.

Ways to keep the compiler healthy:

  • use project references in monorepos
  • avoid enormous union explosions and deeply recursive conditional types
  • name complex intermediate types instead of recomputing them everywhere
  • reduce unnecessary .d.ts surface area in internal packages
  • profile pathological files before blaming the whole toolchain
  • upgrade dependencies with poor type performance when possible

A useful heuristic: if a type takes a paragraph to explain, it may also take too much effort for humans and tooling to maintain.

Migration Strategy

Adopting stronger TypeScript practices in an existing codebase requires sequencing.

A pragmatic migration plan:

  1. pin a modern compiler version
  2. centralize tsconfig and remove conflicting local overrides
  3. enable strict
  4. fix boundary types around API, database, and config loading
  5. replace broad assertions with narrowing or validation
  6. enable high-value flags such as noUncheckedIndexedAccess
  7. split oversized shared types into layer-specific models
  8. add lint rules for unsafe escape hatches

For very large repositories, track progress by directory or package. Avoid a big-bang rewrite unless the codebase is already undergoing a platform reset.

Anti-Patterns

Common TypeScript anti-patterns in production:

  • as assertions used as a substitute for design
  • shared “entity” types reused across backend, frontend, storage, and tests
  • exposing internal implementation types from package entry points
  • defaulting to classes where plain objects and functions are simpler
  • using enums when string literal unions are easier to evolve
  • generic helpers that erase intent instead of clarifying it
  • treating generated types as domain models without review
  • turning on skipLibCheck forever to hide dependency health issues
  • trusting response.json() as SomeType without validation

When in doubt, favor explicitness at boundaries and simplicity in the core.

Team Checklist

A production TypeScript team should be able to answer “yes” to most of the following:

  • Is TypeScript pinned at a current stable 6.0.x version?
  • Is strict enabled across the repository?
  • Are additional strictness flags reviewed intentionally rather than left at defaults?
  • Are external inputs treated as unknown until validated?
  • Are DTOs, domain types, and persistence models separated where needed?
  • Are any and as usage visible and justified?
  • Are module boundaries clear, with import type and named exports used consistently?
  • Does CI run lint, typecheck, tests, and production build separately?
  • Are public APIs covered by type tests or contract tests?
  • Is migration debt tracked rather than silently normalized?