Back to Blog
Development

TypeScript Best Practices for Large-Scale Applications in 2025

Calimatic Team
8 min read

After building 50+ production TypeScript applications, we've learned what actually matters for large codebases. Here are the patterns that make the difference.

1. Strict Configuration

Start with the strictest possible tsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,
    "exactOptionalPropertyTypes": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitReturns": true,
    "forceConsistentCasingInFileNames": true
  }
}

2. Type-Safe API Responses

Never trust external data. Use Zod for runtime validation:

import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  role: z.enum(['admin', 'user']),
});

type User = z.infer;

async function fetchUser(id: number): Promise {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();
  return UserSchema.parse(data); // Runtime validation
}

3. Branded Types for Type Safety

Prevent mixing up similar primitive types:

// Bad: Easy to mix up IDs
function getUser(userId: number) { }
function getOrder(orderId: number) { }

getUser(123);   // User ID
getOrder(123);  // Order ID - both accept numbers!

// Good: Branded types
type UserId = number & { readonly __brand: 'UserId' };
type OrderId = number & { readonly __brand: 'OrderId' };

function createUserId(id: number): UserId {
  return id as UserId;
}

function getUser(userId: UserId) { }
function getOrder(orderId: OrderId) { }

const userId = createUserId(123);
const orderId = createOrderId(456);

getUser(userId);    // ✓ Works
getUser(orderId);   // ✗ Type error!

4. Discriminated Unions for State

Model exclusive states with discriminated unions:

// Bad: Optional fields create invalid states
interface Request {
  status: 'idle' | 'loading' | 'success' | 'error';
  data?: User;
  error?: Error;
}

// Bad: Can be { status: 'success', error: Error }

// Good: Discriminated union
type RequestState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; error: Error };

function handleRequest(state: RequestState) {
  switch (state.status) {
    case 'success':
      return state.data.name; // TypeScript knows data exists
    case 'error':
      return state.error.message; // TypeScript knows error exists
  }
}

5. Utility Types for DRY Code

// Extract function parameters
type Params = T extends (...args: infer P) => any ? P : never;

function createUser(name: string, email: string) {}
type CreateUserParams = Params;
// [string, string]

// Deep Partial
type DeepPartial = {
  [P in keyof T]?: T[P] extends object ? DeepPartial : T[P];
};

// Required keys only
type RequiredKeys = {
  [K in keyof T]-?: {} extends Pick ? never : K;
}[keyof T];

6. Type Guards for Narrowing

interface User {
  type: 'user';
  name: string;
}

interface Admin {
  type: 'admin';
  name: string;
  permissions: string[];
}

type Person = User | Admin;

// Type guard
function isAdmin(person: Person): person is Admin {
  return person.type === 'admin';
}

function greet(person: Person) {
  if (isAdmin(person)) {
    console.log(person.permissions); // TypeScript knows it's Admin
  }
}

7. Generic Constraints

// Ensure objects have specific keys
function getProperty(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: 'John', age: 30 };
getProperty(user, 'name');  // ✓ 'John'
getProperty(user, 'email'); // ✗ Type error

// Extend interfaces
interface HasId {
  id: number;
}

function findById(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id);
}

8. Const Assertions

// Without const assertion
const colors = ['red', 'blue', 'green'];
// Type: string[]

// With const assertion
const colors = ['red', 'blue', 'green'] as const;
// Type: readonly ['red', 'blue', 'green']

type Color = typeof colors[number];
// Type: 'red' | 'blue' | 'green'

Common Pitfalls to Avoid

  • ❌ Using `any` (use `unknown` instead)
  • ❌ Type assertions without validation (`as User`)
  • ❌ Non-null assertions (`user!`) without checks
  • ❌ Ignoring TypeScript errors with `@ts-ignore`
  • ❌ Optional chaining everywhere (handle nulls explicitly)
  • ❌ Overly complex generic types (keep it simple)

Performance Tips

  • Use `skipLibCheck: true` in tsconfig (faster compilation)
  • Enable incremental compilation
  • Use project references for monorepos
  • Keep types in separate `.d.ts` files for large projects
  • Use type-only imports: `import type { User }`

Ready to Start Your Success Story?

Let's discuss how Calimatic can help you achieve similar results.