Back to Blog
TypeScriptBest PracticesType SafetyCode QualityDevelopment

TypeScript Best Practices: Writing Maintainable and Type-Safe Code

7 min read
By Arman Hazrati

A comprehensive guide to TypeScript best practices, covering patterns, techniques, and strategies for writing maintainable, type-safe, and scalable TypeScript code in production applications.

TypeScript Best Practices: Writing Maintainable and Type-Safe Code

TypeScript has become the de facto standard for building large-scale JavaScript applications. However, writing good TypeScript code requires more than just adding type annotations. In this article, I'll share best practices and patterns that will help you write maintainable, type-safe, and scalable TypeScript code.

Type Safety Fundamentals

1. Avoid any Type

The any type defeats the purpose of TypeScript. Use it sparingly, if at all.

// ❌ Bad: Using any
function processData(data: any) {
  return data.value * 2
}

// ✅ Good: Proper typing
function processData(data: { value: number }) {
  return data.value * 2
}

// ✅ Better: Generic type
function processData<T extends { value: number }>(data: T): number {
  return data.value * 2
}

2. Use Type Inference

Let TypeScript infer types when possible.

// ❌ Bad: Unnecessary type annotation
const count: number = 0
const name: string = "John"

// ✅ Good: Type inference
const count = 0
const name = "John"

// ✅ Good: Explicit when needed for clarity
const count: number = calculateCount()

3. Prefer Interfaces for Object Shapes

Use interfaces for object shapes, types for unions and intersections.

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

// ✅ Good: Type for union
type Status = 'pending' | 'approved' | 'rejected'

// ✅ Good: Type for intersection
type AdminUser = User & { role: 'admin' }

Advanced Type Patterns

1. Discriminated Unions

Use discriminated unions for type-safe state management.

// ✅ Good: Discriminated union
type LoadingState = 
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User[] }
  | { status: 'error'; error: string }

function handleState(state: LoadingState) {
  switch (state.status) {
    case 'idle':
      return 'Ready to load'
    case 'loading':
      return 'Loading...'
    case 'success':
      return `Loaded ${state.data.length} users` // TypeScript knows data exists
    case 'error':
      return `Error: ${state.error}` // TypeScript knows error exists
  }
}

2. Utility Types

Leverage TypeScript's utility types.

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

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

// Omit: Exclude specific properties
type UserWithoutEmail = Omit<User, 'email'>

// Record: Create object type
type UserRoles = Record<string, 'admin' | 'user' | 'guest'>

// Readonly: Make properties readonly
type ImmutableUser = Readonly<User>

3. Generic Constraints

Use generic constraints to create flexible, type-safe functions.

// ✅ Good: Generic with constraint
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]
}

const user = { id: '1', name: 'John', age: 30 }
const name = getProperty(user, 'name') // Type: string
const id = getProperty(user, 'id') // Type: string
// const invalid = getProperty(user, 'invalid') // ❌ Type error

Function Patterns

1. Function Overloads

Use function overloads for better type inference.

// ✅ Good: Function overloads
function format(value: string): string
function format(value: number): string
function format(value: boolean): string
function format(value: string | number | boolean): string {
  return String(value)
}

const str = format('hello') // Type: string
const num = format(42) // Type: string

2. Async/Await Types

Properly type async functions and promises.

// ✅ Good: Typed async function
async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`)
  return response.json()
}

// ✅ Good: Typed promise
function createUser(data: CreateUserInput): Promise<User> {
  return api.post('/users', data)
}

3. Error Handling

Type errors properly for better error handling.

// ✅ Good: Custom error types
class ValidationError extends Error {
  constructor(
    public field: string,
    public message: string
  ) {
    super(message)
    this.name = 'ValidationError'
  }
}

class NetworkError extends Error {
  constructor(public statusCode: number, message: string) {
    super(message)
    this.name = 'NetworkError'
  }
}

type AppError = ValidationError | NetworkError

function handleError(error: AppError) {
  if (error instanceof ValidationError) {
    console.error(`Validation error in ${error.field}: ${error.message}`)
  } else if (error instanceof NetworkError) {
    console.error(`Network error ${error.statusCode}: ${error.message}`)
  }
}

React-Specific Patterns

1. Component Props

Type React component props properly.

// ✅ Good: Typed component props
interface ButtonProps {
  label: string
  onClick: () => void
  variant?: 'primary' | 'secondary'
  disabled?: boolean
}

function Button({ label, onClick, variant = 'primary', disabled = false }: ButtonProps) {
  return (
    <button
      onClick={onClick}
      className={`btn btn-${variant}`}
      disabled={disabled}
    >
      {label}
    </button>
  )
}

2. Hooks

Type custom hooks properly.

// ✅ Good: Typed custom hook
function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then((data: T) => {
        setData(data)
        setLoading(false)
      })
      .catch((err: Error) => {
        setError(err)
        setLoading(false)
      })
  }, [url])

  return { data, loading, error }
}

// Usage
const { data, loading, error } = useFetch<User[]>('/api/users')

3. Event Handlers

Type event handlers correctly.

// ✅ Good: Typed event handlers
function Form() {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    // Handle form submission
  }

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value
    // Handle input change
  }

  return (
    <form onSubmit={handleSubmit}>
      <input onChange={handleChange} />
    </form>
  )
}

Configuration and Tooling

1. Strict Type Checking

Enable strict mode in tsconfig.json.

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

2. Path Aliases

Use path aliases for cleaner imports.

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"],
      "@/components/*": ["./src/components/*"],
      "@/utils/*": ["./src/utils/*"]
    }
  }
}
// Usage
import { Button } from '@/components/Button'
import { formatDate } from '@/utils/date'

3. ESLint Rules

Configure ESLint for TypeScript.

// .eslintrc.json
{
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:@typescript-eslint/recommended-requiring-type-checking"
  ],
  "rules": {
    "@typescript-eslint/no-explicit-any": "error",
    "@typescript-eslint/explicit-function-return-type": "warn",
    "@typescript-eslint/no-unused-vars": "error"
  }
}

Common Pitfalls and Solutions

1. Type Assertions

Avoid type assertions when possible. Use type guards instead.

// ❌ Bad: Type assertion
const user = data as User

// ✅ Good: Type guard
function isUser(data: unknown): data is User {
  return (
    typeof data === 'object' &&
    data !== null &&
    'id' in data &&
    'name' in data
  )
}

if (isUser(data)) {
  // TypeScript knows data is User
  console.log(data.name)
}

2. Optional Chaining and Nullish Coalescing

Use optional chaining and nullish coalescing for safer code.

// ❌ Bad: Manual null checks
const name = user && user.profile && user.profile.name

// ✅ Good: Optional chaining
const name = user?.profile?.name

// ✅ Good: Nullish coalescing
const displayName = user?.name ?? 'Anonymous'

3. Array Methods

Type array methods properly.

// ✅ Good: Typed array methods
const numbers: number[] = [1, 2, 3, 4, 5]

const doubled = numbers.map((n: number) => n * 2) // Type: number[]
const evens = numbers.filter((n: number): n is number => n % 2 === 0) // Type: number[]

// ✅ Good: Type predicate for filtering
function isString(value: unknown): value is string {
  return typeof value === 'string'
}

const values: (string | number)[] = ['hello', 42, 'world', 100]
const strings = values.filter(isString) // Type: string[]

Best Practices Summary

  1. Avoid any: Use proper types or unknown when necessary
  2. Use type inference: Let TypeScript infer types when possible
  3. Leverage utility types: Use built-in utility types for common patterns
  4. Enable strict mode: Catch more errors at compile time
  5. Type everything: Functions, components, hooks, events
  6. Use type guards: Prefer type guards over type assertions
  7. Document complex types: Add JSDoc comments for complex types
  8. Keep types close: Define types near where they're used
  9. Use generics: Create reusable, type-safe functions
  10. Test types: Use tools like tsd to test types

Conclusion

Writing good TypeScript code requires understanding type safety fundamentals, leveraging advanced patterns, and following best practices. By applying these techniques, you'll write more maintainable, type-safe, and scalable code.

Remember: TypeScript is a tool to help you write better code. Use it to catch errors early, improve code documentation, and enable better IDE support.


Resources: