Back to Blog

TypeScript Best Practices for Scalable Applications

TypeScript adoption has exploded for good reason—it catches bugs before they hit production. But I've seen teams struggle with overly complex types, slow build times, and type definitions that don't actually provide safety. Here's what actually works at scale.

1. Start Strict, Stay Strict

Enable these in your tsconfig.json from day one:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true
  }
}

These settings feel restrictive initially but save countless hours debugging by catching potential runtime errors at compile time.

2. Avoid 'any' Like the Plague

Using any defeats the entire purpose of TypeScript. If you don't know the type:

  • Use unknown and narrow with type guards
  • Create a proper interface, even if it's incomplete
  • Use generics to preserve type information
// Bad
function processData(data: any) {
  return data.map(x => x.value); // No safety
}

// Good
function processData<T extends { value: unknown }>(data: T[]) {
  return data.map(x => x.value); // Type-safe
}

3. Leverage Type Inference

Don't over-annotate. TypeScript's inference is powerful—use it:

// Overly verbose
const user: User = getUserById(id);
const userName: string = user.name;
const isActive: boolean = user.isActive;

// Better - let TypeScript infer
const user = getUserById(id); // TypeScript knows it's User
const userName = user.name;   // Inferred as string
const isActive = user.isActive; // Inferred as boolean

Explicit types are useful for function parameters, return types, and complex data structures. For simple assignments, inference keeps code clean.

4. Use Discriminated Unions for State Management

This pattern is incredibly powerful for reducers, API responses, and state machines:

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

function handleState<T>(state: AsyncState<T>) {
  switch (state.status) {
    case 'idle':
      return <div>Not started</div>;
    case 'loading':
      return <div>Loading...</div>;
    case 'success':
      return <div>{state.data}</div>; // TypeScript knows data exists
    case 'error':
      return <div>{state.error.message}</div>; // TypeScript knows error exists
  }
}

This eliminates entire classes of bugs where you access data that doesn't exist in certain states.

5. Create Domain-Specific Types

Primitive obsession is a code smell. Create types that represent your domain:

// Weak typing
function sendEmail(email: string, subject: string, body: string) {
  // Easy to mix up parameters
  sendEmail(subject, email, body); // Compiles but wrong
}

// Strong domain types
type EmailAddress = string & { __brand: 'EmailAddress' };
type Subject = string & { __brand: 'Subject' };
type EmailBody = string & { __brand: 'EmailBody' };

function sendEmail(
  email: EmailAddress,
  subject: Subject,
  body: EmailBody
) {
  // Can't mix up parameters - type error
}

6. Organize Types by Feature, Not by Kind

Don't create a massive types.ts file. Organize by feature:

// Bad structure
/types
  interfaces.ts    // All interfaces
  types.ts         // All types
  enums.ts         // All enums

// Good structure
/features
  /user
    user.types.ts
    user.service.ts
    user.component.tsx
  /auth
    auth.types.ts
    auth.service.ts
    auth.component.tsx

This keeps related code together and makes refactoring easier.

7. Use Utility Types for Transformation

TypeScript's built-in utility types are incredibly useful:

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

// Public-facing user (omit sensitive fields)
type PublicUser = Omit<User, 'password'>;

// User creation payload (no ID or createdAt yet)
type CreateUserDto = Omit<User, 'id' | 'createdAt'>;

// Partial update (all fields optional)
type UpdateUserDto = Partial<User>;

// Make specific fields required
type UserWithId = Required<Pick<User, 'id'>> & Partial<User>;

8. Handle API Responses Properly

Never trust external data. Validate and transform at the boundary:

import { z } from 'zod'; // or use io-ts, yup, etc.

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  createdAt: z.string().transform(s => new Date(s))
});

async function fetchUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();

  // Validate and transform
  return UserSchema.parse(data); // Throws if invalid
}

This catches API contract violations at runtime before they corrupt your application state.

9. Avoid Enum, Use Union Types

TypeScript enums have weird runtime behavior. Use const objects or union types instead:

// Avoid enums
enum Status {
  Active,
  Inactive,
  Pending
}

// Better: const object with 'as const'
const Status = {
  Active: 'active',
  Inactive: 'inactive',
  Pending: 'pending'
} as const;

type Status = typeof Status[keyof typeof Status];

// Or just a union type
type Status = 'active' | 'inactive' | 'pending';

10. Configure Path Aliases

Stop writing '../../../components'. Use path aliases:

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@components/*": ["src/components/*"],
      "@utils/*": ["src/utils/*"],
      "@types/*": ["src/types/*"]
    }
  }
}

// Now import like this
import { Button } from '@components/Button';
import { formatDate } from '@utils/date';

Common Pitfalls to Avoid

  • Type assertions everywhere: If you're using 'as' constantly, your types are wrong
  • Overly generic types: Record<string, any> is just 'any' with extra steps
  • Ignoring null/undefined: Always handle these cases explicitly
  • Complex mapped types: If your type takes 20 lines, rethink your approach
  • Not updating types: When your API changes, update types immediately

Build Time Optimization

Large TypeScript projects can have slow builds. Speed them up:

  • Enable incremental compilation: "incremental": true
  • Use project references for monorepos
  • Skip lib checks: "skipLibCheck": true
  • Use SWC or esbuild instead of tsc for builds (keep tsc for type checking)

The Payoff

Done right, TypeScript provides:

  • Confidence: Refactor without fear
  • Documentation: Types are always up-to-date docs
  • Velocity: Catch bugs at compile time, not in production
  • Developer Experience: Autocomplete and inline docs in your IDE

The upfront investment in proper types pays dividends through reduced bugs, faster development cycles, and improved code maintainability.

Final Advice

Start with strict mode enabled. Make your types as specific as possible without over-engineering. Trust the compiler—if TypeScript complains, there's usually a legitimate edge case you haven't considered.

And remember: the goal isn't perfect types, it's correct runtime behavior. Types are a tool to help you achieve that, not an end in themselves.

Need help migrating to TypeScript or improving your existing type system? Reach out for a codebase assessment and migration strategy.