DEV Community

Cover image for TypeScript Best Practices for Large-Scale Web Applications
Waqar Habib
Waqar Habib Subscriber

Posted on

TypeScript Best Practices for Large-Scale Web Applications

TypeScript adoption has crossed the tipping point. Most serious web applications being built in the US market today start with TypeScript, not JavaScript. The question is no longer "should we use TypeScript" but "are we using it well."

After working on large-scale TypeScript codebases, SaaS platforms, FinTech APIs and healthcare applications, the patterns that separate a maintainable codebase from a messy one are consistent. Here's what actually matters at scale.


1. Stop Using any. Use unknown Instead

any is TypeScript's escape hatch, and like all escape hatches it gets overused. The problem: any disables type checking entirely. You get none of TypeScript's safety guarantees on that value or anything it touches.

unknown is the type-safe alternative. It forces you to narrow the type before using it:

// Bad: any disables all type checking downstream
function parseConfig(raw: any) {
  return raw.database.host; // No error, but crashes if shape is wrong
}

// Good: unknown forces you to validate before using
function parseConfig(raw: unknown): AppConfig {
  if (!isAppConfig(raw)) {
    throw new Error('Invalid config shape');
  }
  return raw; // TypeScript now knows this is AppConfig
}

function isAppConfig(value: unknown): value is AppConfig {
  return (
    typeof value === 'object' &&
    value !== null &&
    'database' in value &&
    typeof (value as any).database.host === 'string'
  );
}
Enter fullscreen mode Exit fullscreen mode

At scale, every any you write is a bug waiting to happen during a refactor. Search your codebase for any usages and replace them systematically.


2. Use Discriminated Unions for State Management

One of TypeScript's most powerful features is discriminated unions and most teams underuse them. They're especially valuable for representing states that are mutually exclusive:

// Without discriminated unions: unclear what properties exist in each state
interface RequestState {
  status: 'idle' | 'loading' | 'success' | 'error';
  data?: User[];
  error?: Error;
}
// Problem: TypeScript can't tell you that data only exists when status is 'success'

// With discriminated unions: crystal clear, impossible to access wrong properties
type RequestState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User[] }
  | { status: 'error'; error: Error };

function renderState(state: RequestState) {
  switch (state.status) {
    case 'success':
      return state.data.map(u => u.name); // TypeScript knows data exists here
    case 'error':
      return state.error.message; // TypeScript knows error exists here
    case 'loading':
      return 'Loading...';
    case 'idle':
      return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

This pattern eliminates entire categories of "cannot read property of undefined" errors, which are the most common runtime errors in JavaScript applications.


3. Strict Mode Is Non-Negotiable

If you're not running TypeScript in strict mode, you're using TypeScript lite. Enable these in your tsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "noImplicitAny": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}
Enter fullscreen mode Exit fullscreen mode

noUncheckedIndexedAccess is especially valuable. It forces you to handle the possibility that an array index might be undefined:

const users: User[] = getUsers();

// Without noUncheckedIndexedAccess: TypeScript says users[0] is User
// With noUncheckedIndexedAccess: TypeScript correctly says users[0] is User | undefined
const first = users[0];
if (first) {
  console.log(first.name); // Safe
}
Enter fullscreen mode Exit fullscreen mode

Yes, strict mode surfaces a lot of existing errors when you turn it on in an existing codebase. That's the point, those were already bugs.


4. Zod for Runtime Validation at System Boundaries

TypeScript only gives you compile-time safety. The moment data crosses a system boundary: API response, form input, database query, webhook payload, you're back to runtime land where types are promises, not guarantees.

Zod lets you define a schema once and get both runtime validation and TypeScript type inference:

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  role: z.enum(['admin', 'member', 'viewer']),
  createdAt: z.coerce.date(),
});

type User = z.infer<typeof UserSchema>; // TypeScript type, inferred automatically

// At API boundary: validate before trusting
async function getUser(id: string): Promise<User> {
  const raw = await fetch(`/api/users/${id}`).then(r => r.json());
  return UserSchema.parse(raw); // Throws if shape doesn't match
}
Enter fullscreen mode Exit fullscreen mode

I use Zod on every API route handler in Node.js to validate incoming request bodies, and on every API call in React to validate responses before they touch application state. The runtime errors it catches in staging would have been production incidents without it.


5. Generic Utility Types You Should Know Cold

TypeScript's built-in utility types eliminate enormous amounts of boilerplate in large codebases. The ones I use most:

interface User {
  id: string;
  email: string;
  name: string;
  passwordHash: string;
  createdAt: Date;
}

// Partial: all fields optional — useful for update payloads
type UserUpdatePayload = Partial<User>;

// Pick: select specific fields — useful for API response shapes
type PublicUser = Pick<User, 'id' | 'email' | 'name'>;

// Omit: exclude specific fields — useful to strip sensitive data
type SafeUser = Omit<User, 'passwordHash'>;

// Required: make all fields required — useful to enforce completion
type CompleteUser = Required<User>;

// Record: typed key-value map
type UsersByRole = Record<User['role'], User[]>;

// ReturnType: infer return type of a function
async function fetchUser() { return { id: '1', name: 'Waqar' }; }
type FetchedUser = Awaited<ReturnType<typeof fetchUser>>;
Enter fullscreen mode Exit fullscreen mode

Knowing these well means you never manually redeclare types that can be derived which is one of the biggest sources of type drift in large codebases.


6. Path Aliases for Clean Imports

In large codebases, relative imports become unreadable:

// Nightmare imports in a deep module
import { UserService } from '../../../../services/user/UserService';
import { validateEmail } from '../../../utils/validators';
Enter fullscreen mode Exit fullscreen mode

Configure path aliases in tsconfig.json:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@services/*": ["src/services/*"],
      "@utils/*":    ["src/utils/*"],
      "@types/*":    ["src/types/*"],
      "@hooks/*":    ["src/hooks/*"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Now imports are clean and refactor-safe:

import { UserService } from '@services/user/UserService';
import { validateEmail } from '@utils/validators';
Enter fullscreen mode Exit fullscreen mode

When you move a file, you update one alias definition, not every file that imports from it.


7. Type-Safe Environment Variables

This one is small but eliminates an entire class of runtime errors, accessing an environment variable that was never set:

// Bad: process.env.DATABASE_URL is string | undefined
// This silently passes undefined to your database client
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

// Good: validate all env vars at startup, fail loudly if missing
const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  PORT: z.coerce.number().default(3000),
});

const env = EnvSchema.parse(process.env);
// env.DATABASE_URL is now string — guaranteed, never undefined
Enter fullscreen mode Exit fullscreen mode

If a required environment variable is missing, the app crashes on startup with a clear error. Not 20 minutes later with a confusing database error.


The Mental Model That Makes TypeScript Scale

TypeScript's value multiplies with team size and codebase age. On day one, types feel like overhead. In year two, when you're refactoring a shared utility used in 80 places, types are the thing that tells you every call site that needs to change.

The teams I've seen get the most from TypeScript treat types as documentation that the compiler enforces. Every function signature is a contract. Every discriminated union is a state machine. Every Zod schema is a trust boundary.

Treat it that way and TypeScript pays for itself within months.


Building a large-scale TypeScript application for a US business? I specialize in full-stack development with TypeScript across React, Node.js, and cloud infrastructure. See my approach at waqarhabib.com/services/full-stack-development.


Originally published at waqarhabib.com

Top comments (0)