Tech Tutorials

TypeScript Best Practices for 2024

Learn the most effective TypeScript patterns and practices for building robust applications.

D
Developer Blog
January 5, 20246 min read

TypeScript Best Practices for 2024

TypeScript has become the standard for building large-scale JavaScript applications. In this guide, we'll explore the best practices that will help you write more maintainable, type-safe, and efficient TypeScript code.

1. Use Strict Mode

Always enable strict mode in your tsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "alwaysStrict": true
  }
}

2. Prefer Interfaces Over Types

Use interfaces for object shapes and types for unions, primitives, and complex types:

// Good - Interface for object shape
interface User {
  id: string
  name: string
  email: string
}

// Good - Type for union
type Status = 'loading' | 'success' | 'error'

// Good - Type for complex transformations
type UserWithoutId = Omit<User, 'id'>

3. Use Generic Constraints

When using generics, always add constraints to make them more specific:

// Bad
function getProperty<T>(obj: T, key: string) {
  return obj[key] // Error: Element implicitly has an 'any' type
}

// Good
function getProperty<T, K extends keyof T>(obj: T, key: K) {
  return obj[key] // Type-safe!
}

// Usage
const user = { name: 'John', age: 30 }
const name = getProperty(user, 'name') // Type: string

4. Leverage Utility Types

TypeScript provides powerful utility types that can save you time:

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

// Create a type for API responses (without password)
type UserResponse = Omit<User, 'password'>

// Create a type for creating new users (without id and timestamps)
type CreateUserRequest = Pick<User, 'name' | 'email' | 'password'>

// Make all properties optional
type PartialUser = Partial<User>

// Make all properties required
type RequiredUser = Required<User>

// Extract specific properties
type UserCredentials = Pick<User, 'email' | 'password'>

5. Use Discriminated Unions

For better type safety with related types:

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

// Usage
function handleRequest<T>(state: RequestState<T>) {
  switch (state.status) {
    case 'idle':
      return 'Ready to make request'
    case 'loading':
      return 'Loading...'
    case 'success':
      return `Data: ${state.data}` // Type-safe access to data
    case 'error':
      return `Error: ${state.error}` // Type-safe access to error
  }
}

6. Prefer const Assertions

Use const assertions for immutable data:

// Bad
const colors = ['red', 'green', 'blue'] // Type: string[]

// Good
const colors = ['red', 'green', 'blue'] as const // Type: readonly ["red", "green", "blue"]

// Usage
type Color = typeof colors[number] // Type: "red" | "green" | "blue"

7. Use Function Overloads Sparingly

Function overloads can be useful but often make code harder to understand:

// Prefer union types over overloads
function formatValue(value: string | number): string {
  if (typeof value === 'string') {
    return value.toUpperCase()
  }
  return value.toString()
}

// Only use overloads when necessary for complex APIs
interface ApiClient {
  get<T>(url: string): Promise<T>
  post<T>(url: string, data: any): Promise<T>
}

8. Use Branded Types for Type Safety

Create branded types for better type safety:

// Branded type for user IDs
type UserId = string & { readonly brand: unique symbol }

// Branded type for email addresses
type Email = string & { readonly brand: unique symbol }

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

function createEmail(email: string): Email {
  if (!email.includes('@')) {
    throw new Error('Invalid email')
  }
  return email as Email
}

// Usage
function getUser(id: UserId) {
  // Only accepts UserId, not any string
}

const userId = createUserId('123')
getUser(userId) // OK
getUser('123') // Error: Argument of type 'string' is not assignable to parameter of type 'UserId'

9. Use Template Literal Types

Leverage template literal types for string manipulation:

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type ApiEndpoint = `/api/${string}`

type ApiUrl = `${HttpMethod} ${ApiEndpoint}`

// Usage
function makeRequest(url: ApiUrl) {
  // Type-safe API URLs
}

makeRequest('GET /api/users') // OK
makeRequest('POST /api/users') // OK
makeRequest('GET /users') // Error: Type '"GET /users"' is not assignable to type 'ApiUrl'

10. Use Conditional Types

Conditional types can make your code more flexible:

type NonNullable<T> = T extends null | undefined ? never : T

type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}

type AsyncReturnType<T> = T extends (...args: any[]) => Promise<infer R> ? R : never

// Usage
type User = {
  id: string
  profile: {
    name: string
    avatar: string
  }
}

type PartialUser = DeepPartial<User>
// Result:
// {
//   id?: string
//   profile?: {
//     name?: string
//     avatar?: string
//   }
// }

11. Use satisfies Operator

The satisfies operator ensures type safety while preserving literal types:

// Without satisfies
const config = {
  api: {
    baseUrl: 'https://api.example.com',
    timeout: 5000
  }
} // Type is inferred as { api: { baseUrl: string; timeout: number } }

// With satisfies
const config = {
  api: {
    baseUrl: 'https://api.example.com',
    timeout: 5000
  }
} satisfies Config // Type is preserved as literal types

// Usage
config.api.baseUrl // Type: "https://api.example.com" (literal type)
config.api.timeout // Type: 5000 (literal type)

12. Use never Type Appropriately

Use never for exhaustive checks and impossible states:

function exhaustiveCheck(value: never): never {
  throw new Error(`Unhandled value: ${value}`)
}

function handleStatus(status: 'loading' | 'success' | 'error') {
  switch (status) {
    case 'loading':
      return 'Loading...'
    case 'success':
      return 'Success!'
    case 'error':
      return 'Error!'
    default:
      return exhaustiveCheck(status) // Ensures all cases are handled
  }
}

Conclusion

These TypeScript best practices will help you write more maintainable, type-safe, and efficient code. Remember to:

  • Always use strict mode
  • Prefer interfaces for object shapes
  • Use utility types to avoid repetition
  • Leverage discriminated unions for better type safety
  • Use branded types for domain-specific types
  • Keep your types simple and readable

TypeScript is a powerful tool, and using it effectively can significantly improve your development experience and code quality.

Resources

Happy coding! 🚀

Project Links

Related Posts