TypeScript

TypeScript Best Practices for Maintainable Codebases

Strict compiler settings, discriminated unions, branded types, and API boundaries that keep TypeScript helping your team instead of fighting it.

May 8, 20258 min read
TS
TypeScript

TypeScript Best Practices for Maintainable Codebases

DevPulse AI
Share:

TypeScript's promise is not "JavaScript with autocomplete." It is a design feedback loop: illegal states become compile errors, refactors stay bounded, and APIs document themselves at the type level. Teams that treat TypeScript as a linter with extra steps miss that promise. Teams that crank strictness without conventions drown in as assertions and utility type golf.

These best practices reflect what holds up in long-lived products: strict foundations, honest boundaries, types that model domain rules, and review habits that prevent gradual erosion into "typed JavaScript."

Start with compiler settings that matter

In tsconfig.json, enable:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "verbatimModuleSyntax": true,
    "moduleResolution": "bundler",
    "skipLibCheck": true
  }
}

noUncheckedIndexedAccess turns arr[i] into T | undefined, forcing you to handle missing elements—one of the highest ROI flags after strict.

exactOptionalPropertyTypes distinguishes "property missing" from "property explicitly undefined," which matters for config objects and API payloads.

Use project references or path aliases consistently; avoid deep relative imports (../../../) that break when folders move.

Prefer unknown over any at boundaries

External data—HTTP responses, webhook bodies, localStorage—is unknown until validated:

function parseUser(input: unknown): User {
  if (!isRecord(input)) throw new Error("Invalid payload");
  if (typeof input.id !== "string" || typeof input.email !== "string") {
    throw new Error("Invalid user shape");
  }
  return { id: input.id, email: input.email };
}

Pair with Zod, Valibot, or hand-rolled type guards at the edges; keep interior code fully typed. One validation layer beats fifty defensive typeof checks scattered in business logic.

Model states with discriminated unions

Boolean flags (isLoading, isError, data) invite impossible states: loaded and error simultaneously. Unions encode legal states only:

type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function render<T>(state: RequestState<T>) {
  switch (state.status) {
    case "idle":
      return null;
    case "loading":
      return <Spinner />;
    case "success":
      return <View data={state.data} />;
    case "error":
      return <ErrorMessage error={state.error} />;
  }
}

Exhaustive switch with never checks catches new statuses at compile time:

default: {
  const _exhaustive: never = state;
  return _exhaustive;
}

Branded types for domain primitives

Stringly typed IDs cause swapped arguments: sendEmail(userId, orderId) silently wrong when both are strings. Brands add zero runtime cost:

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

function sendReceipt(userId: UserId, orderId: OrderId) { /* ... */ }

Create values through constructors toUserId(s: string) that validate format once.

Functions: explicit returns and narrow parameters

Enable noImplicitReturns and prefer explicit return types on exported functions—they stabilize public APIs and improve error messages when implementations drift.

Use readonly for data that should not mutate in place:

type LineItem = Readonly<{ sku: string; qty: number }>;

Prefer small, pure functions over classes unless you need true encapsulation or lifecycle hooks.

Avoid type assertion addiction

as Foo bypasses the checker. Legitimate uses:

  • Narrowing after validation (prefer type guards)
  • DOM APIs with incomplete lib definitions
  • Test doubles

If you need more than occasional assertions, fix the types at the source or improve library typings.

Non-null assertion (!) is a code smell on optional chains—handle undefined explicitly.

Generics with constraints, not defaults everywhere

Use generics when the relationship between types must be preserved:

function groupBy<T, K extends string | number>(
  items: T[],
  keyFn: (item: T) => K
): Record<K, T[]> { /* ... */ }

Do not genericize functions that only ever accept one shape—it adds noise without safety.

Enums vs unions

Const object + union often beats numeric enums:

const Role = { Admin: "admin", Member: "member" } as const;
type Role = (typeof Role)[keyof typeof Role];

String unions tree-shake better and align with JSON APIs. Reach for enum when you need reverse mapping or consistent runtime object iteration with a stable name.

Module boundaries and barrel files

Barrel index.ts re-exports can slow builds and obscure import graphs. Export from feature modules intentionally; use barrels only at package public API roots.

Mark server-only code with environment checks or server-only in Next.js apps to prevent client leakage.

Error handling types

Throwing Error subclasses helps instanceof checks. For Result-style APIs:

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

Callers must branch—no forgotten .catch.

Testing types

expectTypeOf from Vitest or type-level tests with tsd lock in utility types. They complement runtime tests, not replace them.

Code review checklist

  • New any or eslint-disable requires justification
  • Public exports have explicit types
  • External input validated once at boundary
  • Unions used for async/UI state machines
  • No duplicate overlapping interfaces for the same entity

Migration strategy for legacy JS

Enable allowJs temporarily, prioritize strict on src/new-feature, and block new files without types in CI. Track any count trending down monthly. Big-bang rewrites fail; incremental strict islands succeed.

satisfies, const assertions, and modern inference

TypeScript 5+ features reduce boilerplate without sacrificing safety. as const narrows literals:

const routes = ["/", "/blog", "/about"] as const;
type Route = (typeof routes)[number]; // union of string literals

satisfies checks a value against a type while preserving the narrower inferred type of the value:

type Theme = Record<string, { fg: string; bg: string }>;
const dark = {
  primary: { fg: "#fff", bg: "#000" },
} satisfies Theme;
// dark.primary is known; Theme alone would widen keys

Use these for config objects and design tokens instead of casting.

API design with types end-to-end

Share types between client and server via a package or OpenAPI codegen—hand-written duplicate interfaces drift. When using tRPC or similar, let inference propagate; when using REST, generate clients from the OpenAPI spec in CI.

Document error codes in the type system:

type ApiErrorCode = "UNAUTHORIZED" | "VALIDATION_FAILED" | "RATE_LIMITED";

Frontend switches stay exhaustive; analytics can group failures meaningfully.

Performance and compile-time cost

Large unions and heavy generics can slow tsc. Mitigations:

  • Project references splitting app vs tests
  • skipLibCheck for node_modules
  • Avoid mega barrel files that pull the world into every compilation unit

Runtime performance is unaffected by types—they erase—so do not micro-optimize TypeScript syntax for speed. Optimize bundle size separately.

Teaching types on your team

Run monthly type clinics: bring a real PR, discuss alternative typings, agree on patterns. Maintain a short TYPESCRIPT.md in the repo with team decisions (brands for IDs, Result vs throw, enum policy). Consistency beats individual cleverness.

Narrowing patterns worth mastering

in operator, typeof, Array.isArray, and user-defined type guards form the narrowing toolkit. asserts predicates document intent:

function assertIsUser(v: unknown): asserts v is User {
  if (!isUser(v)) throw new Error("Not a user");
}

Discriminated unions plus switch remain more readable than long chains of if ('foo' in x).

Avoiding duplicate domain models

One User type should exist per bounded context—do not map DB row → DTO → view model with copy-paste fields unless each layer truly differs. Use Pick/Omit from a canonical type when projections differ.

Lint and format as enforcement

typescript-eslint strict configs encode many practices automatically. Pair with Prettier so formatting never appears in review. CI should fail on tsc --noEmit and eslint—local pre-commit hooks optional but appreciated.

Prefer unknown over any in catch blocks—narrow with instanceof Error before reading .message. Small habits prevent error-handling regressions in production logs.

Conclusion

TypeScript pays off when types express business rules and illegal states, not when they duplicate obvious implementation details. Strict compiler options, discriminated unions, branded IDs, and validated boundaries form a foundation most teams can adopt in a sprint.

Invest in types where mistakes are expensive—API contracts, auth roles, payment flows—and stay pragmatic elsewhere. The codebase should feel harder to break, not harder to read. When in doubt, add a type guard at the boundary and a test that exercises it.

Typing rollout strategy

Enable strict in tsconfig for new packages first, then ratchet legacy folders with // @ts-expect-error tickets filed. Prefer satisfies for config objects to keep literal types without widening. Add type tests with expect-type utilities for public API packages. In reviews, reject any unless paired with a tracking issue. TypeScript pays compound interest when types model real domain invariants, not only syntax coloring.

Typing rollout strategy

Enable strict in tsconfig for new packages first, then ratchet legacy folders with // @ts-expect-error tickets filed. Prefer satisfies for config objects to keep literal types without widening. Add type tests with expect-type utilities for public API packages. In reviews, reject any unless paired with a tracking issue. TypeScript pays compound interest when types model real domain invariants, not only syntax coloring.

Workshop: apply this week

Pick one idea from this article and ship it before Friday. Write a short internal note explaining what changed, what metric you expect to move, and how you will verify the result. Share the note with your team so the learning compounds. If the experiment fails, document the failure mode—it is as valuable as success for the next engineer reading this guide.

Frequently asked questions

Should I enable strict mode in TypeScript?
Yes for new projects. strict enables null checks, implicit any bans, and stricter function types. Migrating legacy code can be incremental with tsconfig overrides per folder, but the end state should be full strict for maximum value.
When is any acceptable?
Rarely at application boundaries you control. Prefer unknown plus narrowing for external data. any is a temporary escape hatch with a ticket to remove—not a permanent typing strategy.
How do I type third-party libraries without types?
Check DefinitelyTyped (@types/package). If missing, write a minimal ambient declaration for the APIs you use, or wrap the library in a typed facade module rather than sprinkling any across the codebase.

Comments

Discussion is coming soon. Share this article and join the conversation on social media.

Enjoyed this article?

Get weekly engineering guides delivered to your inbox.