Back to Blog

TypeScript Best Practices for 2024

Master TypeScript with modern best practices, advanced types, and patterns that will make your code more maintainable, type-safe, and developer-friendly.

Akash Aman
July 25, 2024
15 min read
TypeScript
Web Development
JavaScript
Frontend

TypeScript Best Practices for 2024

TypeScript continues to evolve rapidly, and staying current with best practices is crucial for writing maintainable, type-safe code.

Modern TypeScript Features

1. Satisfies Operator

type Colors = 'red' | 'green' | 'blue'
 
const palette = {
  red: '#ff0000',
  green: '#00ff00',
  blue: '#0000ff',
  // This would cause an error without 'satisfies'
} satisfies Record<Colors, string>
 
// Now you get both type checking and intellisense
const redColor = palette.red // string type, not Colors

2. Template Literal Types

type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type Endpoint = '/api/${string}'
type APIRoute = `${HTTPMethod} ${Endpoint}`
 
// Usage
function makeRequest<T extends APIRoute>(route: T) {
  // Type-safe API calls
}
 
makeRequest('GET /api/users') // ✅ Valid
makeRequest('INVALID /api/users') // ❌ Type error

3. Conditional Types

type NonNullable<T> = T extends null | undefined ? never : T
 
type ApiResponse<T> = T extends string
  ? { message: T }
  : T extends object
  ? { data: T }
  : never
 
type UserResponse = ApiResponse<User> // { data: User }
type ErrorResponse = ApiResponse<string> // { message: string }

Advanced Patterns

1. Branded Types

type UserId = string & { readonly __brand: unique symbol }
type Email = string & { readonly __brand: unique symbol }
 
function createUserId(id: string): UserId {
  return id as UserId
}
 
function sendEmail(userId: UserId, email: Email) {
  // Type-safe function that prevents mixing up parameters
}

2. Discriminated Unions

type LoadingState = {
  status: 'loading'
}
 
type SuccessState = {
  status: 'success'
  data: any
}
 
type ErrorState = {
  status: 'error'
  error: string
}
 
type AsyncState = LoadingState | SuccessState | ErrorState
 
function handleState(state: AsyncState) {
  switch (state.status) {
    case 'loading':
      // TypeScript knows this is LoadingState
      return <Spinner />
    case 'success':
      // TypeScript knows state.data exists
      return <Data data={state.data} />
    case 'error':
      // TypeScript knows state.error exists
      return <Error message={state.error} />
  }
}

3. Utility Types Mastery

// Pick specific properties
type UserPreview = Pick<User, 'id' | 'name' | 'email'>
 
// Omit properties
type CreateUserRequest = Omit<User, 'id' | 'createdAt'>
 
// Make all properties optional
type PartialUser = Partial<User>
 
// Make all properties required
type RequiredUser = Required<PartialUser>
 
// Extract keys that extend a condition
type StringKeys<T> = {
  [K in keyof T]: T[K] extends string ? K : never
}[keyof T]

Configuration Best Practices

1. Strict TypeScript Config

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

2. Path Mapping

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

Testing with TypeScript

1. Type-safe Test Utilities

import { expectTypeOf } from 'vitest'
 
// Test types at compile time
test('User type should have correct shape', () => {
  expectTypeOf<User>().toMatchTypeOf<{
    id: string
    name: string
    email: string
  }>()
})

2. Mock Types

type MockedFunction<T extends (...args: any[]) => any> = jest.MockedFunction<T>
 
const mockUserService: MockedFunction<typeof userService.getUser> = 
  jest.fn()

Performance Tips

  1. Use Type Imports: import type { User } from './types'
  2. Avoid Enum Overhead: Use const assertions instead
  3. Leverage Tree Shaking: Import only what you need
  4. Use Interface over Type: For object shapes, interfaces are more performant

Common Pitfalls to Avoid

  1. Any Escape Hatch: Resist using any, use unknown instead
  2. Non-null Assertion Overuse: Avoid ! operator without proper checks
  3. Ignoring Compiler Errors: Address TypeScript errors properly
  4. Over-engineering Types: Keep types simple and readable

TypeScript in 2024 is more powerful than ever. These practices will help you write better, more maintainable code while leveraging the full power of the type system.