TypeScript Best Practices for Large-Scale Applications

by Hamzah Ejaz, Software Engineer

After leading teams through multiple TypeScript migrations and building enterprise applications, these are the patterns that consistently deliver maintainable, type-safe code.

1. Strict Configuration

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true
  }
}

Why: Catch errors at compile time, not runtime.

2. Discriminated Unions for State Management

// ❌ Avoid
interface ApiState {
  loading: boolean
  data?: User[]
  error?: string
}

// ✅ Better
type ApiState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User[] }
  | { status: 'error'; error: string }

function handleState(state: ApiState) {
  switch (state.status) {
    case 'idle':
      return <div>Click to load</div>
    case 'loading':
      return <Spinner />
    case 'success':
      return <UserList users={state.data} /> // TypeScript knows data exists
    case 'error':
      return <Error message={state.error} /> // TypeScript knows error exists
  }
}

3. Utility Types for DRY Code

// Pick specific properties
type UserPreview = Pick<User, 'id' | 'name' | 'email'>

// Omit sensitive data
type PublicUser = Omit<User, 'password' | 'apiKey'>

// Partial for updates
type UserUpdate = Partial<User>

// Required (opposite of Partial)
type RequiredUser = Required<User>

// Custom utility for API responses
type ApiResponse<T> = {
  data: T
  timestamp: Date
  requestId: string
}

type UserResponse = ApiResponse<User>
type UsersResponse = ApiResponse<User[]>

4. Type Guards for Runtime Safety

// User-defined type guard
function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    'email' in value
  )
}

// Usage
async function fetchUser(id: string) {
  const response = await fetch(`/api/users/${id}`)
  const data = await response.json()

  if (isUser(data)) {
    return data // TypeScript knows it's User
  }

  throw new Error('Invalid user data')
}

5. Branded Types for ID Safety

// Prevent mixing different ID types
type UserId = string & { readonly __brand: 'UserId' }
type ProductId = string & { readonly __brand: 'ProductId' }

function createUserId(id: string): UserId {
  return id as UserId
}

function getUser(id: UserId) { /* ... */ }
function getProduct(id: ProductId) { /* ... */ }

const userId = createUserId('123')
const productId = '456' as ProductId

getUser(userId) // ✅ Works
getUser(productId) // ❌ Type error!

6. Const Assertions for Literal Types

// 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] // "red" | "blue" | "green"

function setColor(color: Color) { /* ... */ }
setColor('red') // ✅
setColor('yellow') // ❌ Type error

7. Generic Constraints

// Constrain generics for type safety
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]
}

const user = { id: 1, name: 'John', email: 'john@example.com' }

getProperty(user, 'name') // ✅ Returns string
getProperty(user, 'age') // ❌ Type error

// Multiple constraints
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
  return { ...obj1, ...obj2 }
}

8. Conditional Types

// Unwrap Promise types
type Awaited<T> = T extends Promise<infer U> ? U : T

type A = Awaited<Promise<string>> // string
type B = Awaited<string> // string

// Extract function return type
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never

function getUser() {
  return { id: 1, name: 'John' }
}

type User = ReturnType<typeof getUser> // { id: number; name: string }

9. Template Literal Types

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type Route = '/users' | '/products' | '/orders'

type ApiEndpoint = `${HttpMethod} ${Route}`
// "GET /users" | "POST /users" | "PUT /users" | ... (12 combinations)

// Event naming
type EventName = 'click' | 'focus' | 'blur'
type ElementId = 'button' | 'input'

type ElementEvent = `${ElementId}:${EventName}`
// "button:click" | "button:focus" | "input:blur" | ...

10. Strict Function Signatures

// ❌ Avoid any
function processData(data: any) {
  return data.map((item: any) => item.id)
}

// ✅ Better - Generic with constraints
function processData<T extends { id: number }>(data: T[]): number[] {
  return data.map(item => item.id)
}

// ✅ Even better - Specific type
interface Identifiable {
  id: number
}

function processData(data: Identifiable[]): number[] {
  return data.map(item => item.id)
}

11. Zod for Runtime Validation

import { z } from 'zod'

// Define schema
const UserSchema = z.object({
  id: z.number(),
  email: z.string().email(),
  name: z.string().min(1),
  age: z.number().min(18).optional(),
})

// Infer TypeScript type from schema
type User = z.infer<typeof UserSchema>

// Validate at runtime
async function createUser(data: unknown): Promise<User> {
  const validated = UserSchema.parse(data) // Throws if invalid
  return await db.users.create(validated)
}

12. Organizational Patterns

// Domain-driven folder structure
src/
├── features/
│   ├── auth/
│   │   ├── types.ts
│   │   ├── services.ts
│   │   └── hooks.ts
│   ├── users/
│   └── products/
├── shared/
│   ├── types/
│   ├── utils/
│   └── hooks/
└── types/
    └── global.d.ts

// Centralize shared types
// shared/types/api.ts
export interface ApiResponse<T> {
  data: T
  meta: ResponseMeta
}

export type ApiError = {
  message: string
  code: string
  details?: Record<string, string[]>
}

Real-World Impact

After implementing these practices:

  • 30% reduction in runtime errors
  • 50% faster code reviews
  • Better IDE autocomplete and refactoring
  • Easier onboarding for new developers

Key Takeaways

  1. Enable strict mode from day one
  2. Use discriminated unions for state
  3. Leverage utility types to stay DRY
  4. Write type guards for runtime safety
  5. Constrain generics appropriately
  6. Validate at boundaries with Zod
  7. Organize types by domain

TypeScript is more than syntax - it's a tool for building maintainable systems. Invest in type safety early, and your future self will thank you.

More articles

Building AI-Powered Meeting Intelligence: Lessons from EVA Meet

A deep dive into architecting an enterprise AI platform that combines GPT-4, Perplexity AI, and Deepgram for real-time meeting intelligence.

Read more

Full Stack MERN to AI Engineer: My Journey

How I transitioned from traditional full-stack development to AI engineering, the skills I acquired, and lessons for developers making the same journey.

Read more

Ready to Transform Your Business?

Get in touch today to learn how technology can revolutionize your operations!