TypeScript Best Practices for Production Codebases
Engineering9 min read·2025-09-10

TypeScript Best Practices for Production Codebases

A concise set of TypeScript practices that reduce bugs and improve developer productivity in large codebases.

BTLE

Binary Tech Lab Engineering

Mobile Engineering Lead

TypeScript is one of those tools that's easy to adopt and hard to use well. Adding any everywhere isn't TypeScript — it's JavaScript with extra steps. The teams that get real value from it treat the type system as a design tool, not a compiler requirement to satisfy.

These are the practices we enforce on every TypeScript project — the ones that separate codebases that age well from ones that become type annotation graveyards.

Never use any. Use unknown instead.

any is a trapdoor — TypeScript stops checking that value everywhere it flows. Bugs the type system would have caught in development reach production instead.

unknown is the honest alternative: "I don't know the type of this yet" without disabling safety. You're forced to narrow the type before using it — exactly the discipline you want at trust boundaries like API responses, user input, and third-party data.

Model your domain in the type system

The most underused TypeScript feature: discriminated unions for domain states.

Instead of a status: string field, model every meaningful state explicitly: type OrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled'. TypeScript will exhaustively check every switch statement against it. Add a new status and forget to handle it somewhere — the compiler finds it before your users do.

For complex domain objects, discriminated unions go further: each state variant carries only the fields valid in that state. A shipped order has a trackingNumber. A pending order doesn't. The type system encodes that constraint — you can't accidentally access trackingNumber on a pending order without narrowing first.

Validate at the boundary, trust inside

TypeScript's type safety only holds within your codebase. The moment data crosses a boundary — API call, database query, user input — you're dealing with runtime values the compiler cannot verify.

Parse and validate all external data at the point of entry. We use Zod on every project. Define a schema at the boundary, parse incoming data against it, and from that point the data carries a fully typed, verified shape. Anything that fails validation is caught immediately — not discovered as a mysterious null reference three function calls later.

Strict mode is not optional

strict: true in your tsconfig. Always. It enables strictNullChecks, noImplicitAny, and checks that catch the most common runtime bug classes at compile time.

The objection: "it's too many errors to fix at once." Enable it at the start of a project when the cost is zero, not 18 months in when you're staring at 400 type errors. For existing codebases, enable incrementally — file by file using // @ts-check as a migration path.

Generics: the right level of abstraction

Use generics to capture relationships between types, not to avoid writing types. A function that takes an array and returns the first element should be generic. A function that takes a user ID and returns a user record should not — there's one input type and one output type. Reaching for generics where a concrete type works is the most common form of TypeScript over-engineering.

// Good: generic captures the relationship
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
const name = getProperty(user, "name"); // type: string
const age  = getProperty(user, "age");  // type: number

// Bad: unnecessary generic, adds noise without safety
function identity<T>(value: T): T { return value; }
// Just write: function identity(value: unknown): unknown

For API response wrappers, pagination envelopes, and Result types, generics earn their complexity. For most application code, concrete types are clearer. If you can't read the signature and immediately understand what types flow through it — the abstraction level is wrong.

Use satisfies over type assertions

Type assertions (value as SomeType) tell the compiler to trust you. The compiler does. If you're wrong, the bug is yours at runtime.

The satisfies operator (TypeScript 4.9+) checks that a value conforms to a type without widening it. You get the safety of a type check without losing the narrower inferred type. Use it for config objects, record definitions, and anywhere you'd previously have reached for an assertion.

Type your errors

JavaScript's try/catch gives you an unknown error. Most codebases cast it immediately to any and hope for the best.

A better pattern: use a Result type — typed success value or typed error — instead of exceptions for expected failure modes. API failures, validation errors, and not-found cases are expected. Model them as data. Your calling code becomes explicit about handling both cases, and you stop losing typed context in catch blocks.

Utility types worth actually knowing

TypeScript's built-in utility types eliminate entire categories of type duplication. Most engineers know Partial<T>. These are the ones that change how you design types:

Utility type What it does Where we use it
Pick<T, K> Creates a type with only the specified keys API response shapes — expose only what a client needs
Omit<T, K> Creates a type without the specified keys DTOs from domain models — strip internal fields
Record<K, V> Object type with keys K and values V Lookup tables, config maps, indexed collections
ReturnType<T> Extracts the return type of a function Type factory output without repeating the type
NonNullable<T> Removes null and undefined from T Post-validation types — data confirmed as present
Awaited<T> Unwraps the resolved type of a Promise Type async results without verbose Promise nesting

The pattern: define your canonical types once (database schema, API responses, domain models), then derive all related types using utilities. Changing the canonical type propagates everywhere automatically.

ESLint rules that enforce type safety

typescript-eslint extends ESLint with rules that catch type-level problems beyond what the compiler checks:

🚫 @typescript-eslint/no-explicit-any

Bans explicit any annotations. The compiler won't stop you — this rule will. Allows narrowly scoped eslint-disable overrides for genuine escape hatches.

✅ @typescript-eslint/no-floating-promises

Catches async functions whose return values are not awaited. Unhandled promises are a common source of silent failures — errors swallowed without being caught or logged.

🔍 @typescript-eslint/strict-boolean-expressions

Disallows implicit boolean coercion of non-boolean types. Prevents the bug class where null, undefined, 0, and empty string are accidentally treated as equivalent in conditionals.

⚠️ @typescript-eslint/switch-exhaustiveness-check

Errors when a switch doesn't handle all members of a union type. Add a new variant, get a compile-time error at every switch that needs updating.

📋 @typescript-eslint/consistent-type-imports

Enforces import type for type-only imports. Eliminates circular dependency issues and makes bundle analysis cleaner.

Project structure and path aliases

Import paths are a proxy for architecture quality. A codebase full of ../../../../shared/utils/format is one where moving files is painful and module boundaries are unclear.

Configure path aliases in tsconfig.json:

"compilerOptions": {
  "baseUrl": ".",
  "paths": {
    "@/components/*": ["src/components/*"],
    "@/lib/*":        ["src/lib/*"],
    "@/types/*":      ["src/types/*"],
    "@/server/*":     ["src/server/*"]
  }
}

Now imports read as import { formatCurrency } from "@/lib/format" regardless of where the importing file lives. The alias signals you're crossing a module boundary — within the same module, relative imports remain correct and clearer.

The principle underneath all of this

Good TypeScript makes impossible states unrepresentable. If a combination of values can't exist in a valid system, the type system should prevent it from being constructed — not just document that it shouldn't happen.

That's a higher bar than 'the compiler doesn't complain.' It takes deliberate design. But codebases that meet it are dramatically easier to refactor, extend, and hand off — because the type system carries context that would otherwise live only in the original author's head.

Have a project in mind?

We'd love to hear about what you're building. Let's talk about how we can help bring it to life.

Start a Conversation