TypeScript

TypeScript Best Practices for Large Applications

TypeScript patterns that actually hold up as a codebase grows — strict mode, discriminated unions, and avoiding type soup.

Tayyab Roy9 min read
Share:

Most teams adopt TypeScript for the autocomplete and never get past that. The real value shows up later, when a codebase has grown past the point where any one person holds the whole data model in their head — that’s when strict typing, discriminated unions, and disciplined type organization stop being nice-to-haves and start being the difference between a confident refactor and a weekend of debugging. These are the practices that have consistently paid off across the large React, Angular, and Node codebases I’ve worked in.

Turn On strict Mode — All of It

"strict": true in tsconfig.json isn’t one setting, it’s a bundle of them: strictNullChecks, noImplicitAny, strictFunctionTypes, strictPropertyInitialization, and a few more. Teams often avoid it on existing codebases because turning it on surfaces a wall of errors immediately. That wall is the point — those are real gaps where null, undefined, or an implicit any was already capable of reaching production, you just didn’t have a compiler telling you about it.

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}

noUncheckedIndexedAccess is worth calling out specifically — without it, arr[i] and record[key] are typed as if they always succeed, even though array and object access can return undefined at runtime. Turning it on forces you to handle the undefined case explicitly wherever you index into a collection, which catches an entire category of “cannot read property of undefined” runtime errors at compile time instead.

If you’re migrating an existing large codebase, you don’t have to flip strict all at once — enable it in tsconfig.json but use // @ts-expect-error comments (not @ts-ignore, which silently swallows an error that may later stop existing) or a per-file opt-out strategy to work through the errors incrementally without blocking the whole team.

Discriminated Unions Over Optional-Field Soup

This is the single highest-leverage pattern in the language, and it’s the one most JavaScript developers coming to TypeScript don’t reach for by default. The instinct when modeling a value that can be “one of several shapes” is usually to cram every possible field onto one interface and mark most of them optional:

// Before: optional-field soup
interface ApiResponse {
  status: 'loading' | 'success' | 'error';
  data?: Product[];
  error?: string;
}

function render(response: ApiResponse) {
  if (response.status === 'success') {
    // TypeScript still thinks `data` might be undefined here —
    // the type doesn't actually guarantee it exists when status is 'success'
    return response.data!.map((p) => p.name); // forced to use `!`
  }
}

The problem isn’t just the ! assertion — it’s that nothing in the type system actually enforces that data exists when status is 'success' and error exists when status is 'error'. Someone can construct an invalid combination (status: 'success', data: undefined) and the compiler won’t stop them.

A discriminated union fixes this by giving each variant its own exact shape, tied together by a shared literal “tag” field:

// After: a discriminated union
type ApiResponse =
  | { status: 'loading' }
  | { status: 'success'; data: Product[] }
  | { status: 'error'; error: string };

function render(response: ApiResponse) {
  switch (response.status) {
    case 'loading':
      return <Spinner />;
    case 'success':
      return response.data.map((p) => p.name); // no `!`, `data` is guaranteed
    case 'error':
      return <ErrorMessage message={response.error} />;
  }
}

Once you switch (or if) on status, TypeScript narrows response to the exact matching variant inside that branch — data is simply known to exist in the 'success' case, with zero runtime checks or assertions needed. This pattern applies everywhere you’d otherwise reach for a pile of optional fields: form state (idle / submitting / submitted / failed), WebSocket connection state, async resource loading, even domain models like payment methods (card vs paypal vs bank_transfer, each with genuinely different required fields).

Avoid any — Reach for unknown Instead

any disables type checking entirely for that value and everything derived from it — it’s a trapdoor out of the type system, not a type. unknown is the type-safe alternative for “I don’t know this value’s shape yet”: you can assign anything to it, but you can’t do anything with it until you narrow it.

// Bad: any lets anything through silently
function parseConfig(input: any) {
  return input.settings.theme; // no error, even if input is a string
}

// Good: unknown forces you to check first
function parseConfig(input: unknown): string {
  if (
    typeof input === 'object' &&
    input !== null &&
    'settings' in input &&
    typeof (input as { settings: unknown }).settings === 'object'
  ) {
    const settings = (input as { settings: { theme?: unknown } }).settings;
    if (typeof settings.theme === 'string') {
      return settings.theme;
    }
  }
  throw new Error('Invalid config shape');
}

In practice, for anything beyond a trivial check, reach for a runtime validation library (Zod, Valibot) instead of hand-rolled narrowing — it gives you both the runtime check and the inferred TypeScript type from a single schema definition:

import { z } from 'zod';

const ConfigSchema = z.object({
  settings: z.object({ theme: z.string() }),
});

function parseConfig(input: unknown) {
  return ConfigSchema.parse(input).settings.theme; // throws if invalid, typed if valid
}

Utility Types: Partial, Pick, Omit, Record

These stop you from re-declaring slightly-different variants of the same interface by hand, which is both less code and keeps the variants in sync automatically when the base type changes.

interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'member';
}

// For an update payload — every field optional except id stays required
type UserUpdate = Partial<Omit<User, 'id'>> & { id: string };

// For a public-facing shape that drops sensitive fields
type PublicUser = Omit<User, 'email'>;

// For a lookup table keyed by role
type UsersByRole = Record<User['role'], User[]>;

// For a form that only needs a subset of fields
type UserNameFields = Pick<User, 'name' | 'email'>;

The payoff compounds as the base User interface evolves — add a field to User and UserUpdate, PublicUser, and UserNameFields all stay correctly derived without anyone touching them.

Type-Only Imports

When you’re importing something purely for its type (an interface, a type alias) and never using it as a runtime value, mark the import as type-only. This keeps the compiled JavaScript free of unnecessary imports and makes the intent explicit to anyone reading the file.

import type { User } from './types';
import { formatUserName } from './utils';

With verbatimModuleSyntax enabled (recommended for anything targeting bundlers), this stops being just a style preference — the compiler enforces it, which also prevents a subtle class of circular-import bugs where a type-only import accidentally created a runtime dependency cycle.

Avoid Deep Generic Gymnastics

TypeScript’s type system is Turing-complete, which means you can build conditional types that recursively unwrap nested promises, infer tuple lengths, or implement string-template parsing at the type level. You usually shouldn’t, at least not in application code. A generic utility type that takes three people on the team to read is a liability, regardless of how elegant it looks to its author.

// Clever, but a maintenance trap in regular application code
type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: T[K] extends (infer U)[] ? DeepPartial<U>[] : DeepPartial<T[K]> }
  : T;

Reserve this level of generic machinery for shared library code where the complexity is centralized in one well-tested place and the rest of the team only ever consumes the result. In everyday application code, prefer explicit, slightly repetitive types over a clever generic that saves ten lines but costs everyone reading it five minutes of unwinding.

Organizing Shared Types

As an app grows, a single types.ts file becomes an unmaintainable dumping ground. A pattern that scales reasonably well: colocate types with the feature that owns them, and only promote a type to a shared location once a second, genuinely unrelated feature needs it.

src/
  features/
    orders/
      types.ts        // OrderStatus, Order, OrderLineItem — owned here
      api.ts
    products/
      types.ts        // Product, ProductVariant — owned here
  shared/
    types/
      money.ts         // Money, Currency — genuinely cross-cutting
      pagination.ts    // PaginatedResponse<T> — genuinely cross-cutting

Resist the urge to preemptively centralize everything “in case it’s reused later” — premature sharing creates coupling between features that didn’t need it, and it’s much easier to move a type out to shared/ when a second consumer actually appears than to untangle a shared type that three unrelated features have silently drifted into depending on in incompatible ways.

This kind of typing discipline matters most at architectural boundaries — see Scalable Frontend Architecture for how these patterns fit into larger-scale app structure. If you’re working across an Angular codebase specifically, the same discriminated-union thinking applies directly to component inputs and state, which Angular Standalone Components touches on from the component-architecture side. And on the backend, consistent, strict typing is just as important crossing the API boundary — see Node.js vs NestJS for how a structured framework like NestJS leans on TypeScript decorators and interfaces more heavily than plain Node.

Common Mistakes

Using as casts to silence errors instead of fixing the underlying type. A cast is a promise to the compiler that you know better — if it’s wrong, you get a runtime error with none of the safety TypeScript was supposed to provide. Reach for proper narrowing or a schema validator instead.

Letting function return types be inferred on public APIs. Inference is fine for implementation details, but on exported functions, an explicit return type acts as a contract check — if the implementation accidentally returns something wider than intended, you get an error at the function definition instead of a confusing one at some distant call site.

Mixing interface and type inconsistently with no team convention. Functionally they overlap heavily; what matters is picking one convention (commonly: interface for object shapes that might be extended, type for unions and aliases) and applying it consistently so the codebase doesn’t look arbitrary.

FAQ

Is adopting strict mode worth it on a large legacy codebase? Yes, but incrementally — turn it on, triage the errors by directory or feature, and work through them in manageable batches rather than trying to fix everything before shipping anything else.

Should I use enum or a union of string literals? Prefer string literal unions (type Role = 'admin' | 'member') in most cases — they have no runtime footprint, work naturally with discriminated unions, and avoid some of TypeScript’s enum quirks around numeric enums and reverse mappings. Reach for const enum or real enums only when you have a specific reason to.

Does strict typing slow down development speed? It slows down the initial writing of a function slightly, in exchange for catching a category of bugs before they reach code review or production. On any codebase more than a few weeks old, that trade consistently favors strictness.

#TypeScript#Best Practices#Architecture

Written by

Tayyab Roy

Senior Frontend / Full-Stack Engineer

Senior Frontend & Full-Stack Engineer specializing in Angular, React, Next.js and enterprise eCommerce (Shopify, SAP Commerce / Hybris, Spartacus). I write about practical, production-tested frontend engineering.