#Tech#Web Development#Programming#TypeScript

TypeScript Best Practices

A comprehensive guide to TypeScript best practices, covering types, patterns, and advanced features for building robust applications.

TypeScript Best Practices: The Complete Guide

TypeScript has become the de facto standard for building robust, maintainable JavaScript applications. But simply adding type annotations isn't enough—you need to follow best practices to unlock TypeScript's full potential.

This comprehensive guide covers essential TypeScript best practices, from fundamental typing rules to advanced patterns, helping you write cleaner, safer code.

Fundamental Type Practices

1. Enable Strict Mode

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

Why:

  • Catches more errors at compile time
  • Forces you to think about null/undefined
  • Makes types more precise
  • Prevents entire classes of bugs

2. Use unknown Instead of any

// ❌ Bad: Using any defeats purpose of TypeScript
function parseJSON(json: string): any {
  return JSON.parse(json)
}

const data = parseJSON('{"name":"John"}')
// No type safety, no autocomplete

// ✅ Good: Use unknown for truly unknown types
function parseJSON(json: string): unknown {
  return JSON.parse(json)
}

const data = parseJSON('{"name":"John"}')

// Must narrow before using
if (isPerson(data)) {
  console.log(data.name) // Type-safe
}

function isPerson(value: unknown): value is Person {
  return (
    typeof value === 'object' &&
    value !== null &&
    'name' in value
  )
}

interface Person {
  name: string
}

3. Avoid Type Assertions

// ❌ Bad: Type assertions bypass compiler checks
const user = {} as User
const id = user.id // No compile error, but will fail at runtime

// ✅ Good: Type guards and type narrowing
function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    'name' in value
  )
}

const value = parseUser(input)
if (isUser(value)) {
  console.log(value.id) // Type-safe
}

4. Use Readonly Types

// ❌ Bad: Mutable arrays can cause bugs
function updateUser(users: User[], userId: number, updates: Partial<User>) {
  const user = users.find(u => u.id === userId)
  if (user) {
    Object.assign(user, updates)
    // Modifies array in place
  }
}

// ✅ Good: Use readonly for immutable data
function updateUser(users: readonly User[], userId: number, updates: Partial<User>): User[] {
  const user = users.find(u => u.id === userId)
  if (!user) return users

  const updatedUser = { ...user, ...updates }
  return users.map(u => u.id === userId ? updatedUser : u)
}

// Use as const for literal types
const config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
} as const

// config is readonly, types are narrowed
// config.apiUrl is "https://api.example.com", not string

Interface vs Type

When to Use Interfaces

// ✅ Use interfaces for:
// 1. Object shapes
interface User {
  id: number
  name: string
  email: string
}

// 2. Extending other interfaces
interface AdminUser extends User {
  permissions: string[]
}

// 3. Implementation contracts
interface Repository<T> {
  findById(id: number): Promise<T | null>
  findAll(): Promise<T[]>
  save(entity: T): Promise<T>
}

// 4. Type that might need extending later
interface Event {
  type: string
  timestamp: number
  payload?: unknown
}

When to Use Types

// ✅ Use types for:
// 1. Unions and intersections
type Status = 'pending' | 'approved' | 'rejected'
type User = AdminUser | RegularUser

type AdminUser = {
  type: 'admin'
  permissions: string[]
}

type RegularUser = {
  type: 'regular'
  email: string
}

// 2. Tuple types
type Coordinates = [number, number]
type Color = [number, number, number]

// 3. Function types
type EventHandler<T> = (event: T) => void
type AsyncFunction<T, R> = (arg: T) => Promise<R>

// 4. Mapped types
type Partial<T> = {
  [K in keyof T]?: T[K]
}

type Readonly<T> = {
  readonly [K in keyof T]: T[K]
}

// 5. Conditional types
type NonNullable<T> = T extends null | undefined ? never : T
type ExtractType<T, U> = T extends U ? T : never

Advanced Type Patterns

Utility Types

// Partial - Make all properties optional
interface User {
  id: number
  name: string
  email: string
}

type UserUpdate = Partial<User>
// { id?: number, name?: string, email?: string }

// Required - Make all properties required
type CreateUser = Required<Partial<User>>
// { id: number, name: string, email: string }

// Readonly - Make all properties readonly
type ReadonlyUser = Readonly<User>
// { readonly id: number, readonly name: string, readonly email: string }

// Pick - Select specific properties
type UserSummary = Pick<User, 'id' | 'name'>
// { id: number, name: string }

// Omit - Exclude specific properties
type UserWithoutId = Omit<User, 'id'>
// { name: string, email: string }

// Record - Create object type with specific keys
type UserMap = Record<number, User>
// { [key: number]: User }

// Extract - Extract types from union
type StringOrNumber = string | number
type StringType = Extract<StringOrNumber, string>
// string

// Exclude - Exclude types from union
type NonString = Exclude<StringOrNumber, string>
// number

Generics with Constraints

// ✅ Good: Constrained generics
function first<T extends { length: number }>(array: T[]): T {
  return array[0]
}

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]
}

interface Config {
  apiUrl: string
  timeout: number
}

const config: Config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
}

const url = getProperty(config, 'apiUrl')
// Type is string

Conditional Types

// Type-based conditional logic
type NonNullable<T> = T extends null | undefined ? never : T

type Maybe<T> = T | null | undefined

function unwrap<T>(value: Maybe<T>): NonNullable<T> {
  if (value === null || value === undefined) {
    throw new Error('Value cannot be null or undefined')
  }
  return value as NonNullable<T>
}

// Type inference with conditionals
type ElementType<T> = T extends (infer U)[] ? U : T

type Numbers = number[]
type NumberElement = ElementType<Numbers>
// number

Mapped Types

// Create transformed types
type ReadonlyDeep<T> = {
  readonly [K in keyof T]: T[K] extends object ? ReadonlyDeep<T[K]> : T[K]
}

type NullableFields<T> = {
  [K in keyof T]: T[K] | null
}

interface User {
  id: number
  name: string
  email: string
}

type NullableUser = NullableFields<User>
// { id: number | null, name: string | null, email: string | null }

// Key remapping
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}

interface Data {
  name: string
  age: number
}

type DataGetters = Getters<Data>
// { getName: () => string, getAge: () => number }

Type Guards and Narrowing

Type Guards

// typeof type guard
function isString(value: unknown): value is string {
  return typeof value === 'string'
}

// instanceof type guard
function isDate(value: unknown): value is Date {
  return value instanceof Date
}

// Custom type guard
interface Circle {
  kind: 'circle'
  radius: number
}

interface Square {
  kind: 'square'
  side: number
}

type Shape = Circle | Square

function isCircle(shape: Shape): shape is Circle {
  return shape.kind === 'circle'
}

// Using type guard
function getArea(shape: Shape): number {
  if (isCircle(shape)) {
    return Math.PI * shape.radius * shape.radius
  }
  return shape.side * shape.side
}

Discriminated Unions

// ✅ Good: Use discriminated unions for complex types
interface LoadingState {
  status: 'loading'
}

interface SuccessState<T> {
  status: 'success'
  data: T
}

interface ErrorState {
  status: 'error'
  error: Error
}

type AsyncState<T> = LoadingState | SuccessState<T> | ErrorState

function renderState<T>(state: AsyncState<T>): string {
  switch (state.status) {
    case 'loading':
      return 'Loading...'
    case 'success':
      return JSON.stringify(state.data)
    case 'error':
      return `Error: ${state.error.message}`
    default:
      const exhaustiveCheck: never = state
      return exhaustiveCheck
  }
}

// TypeScript ensures all cases are handled

Type Narrowing

function processValue(value: unknown): string {
  // Narrow type step by step
  if (value === null || value === undefined) {
    return 'null or undefined'
  }

  if (typeof value === 'string') {
    return value.toUpperCase()
  }

  if (typeof value === 'number') {
    return value.toFixed(2)
  }

  if (Array.isArray(value)) {
    return `Array of length ${value.length}`
  }

  if (typeof value === 'object') {
    return 'Object'
  }

  return 'Unknown type'
}

Best Practices for Specific Patterns

Error Handling

// Define error types
interface ApiError {
  code: string
  message: string
  details?: unknown
}

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

// Type-safe error handling
async function fetchUser(id: number): Promise<User> {
  try {
    const response = await fetch(`/api/users/${id}`)

    if (!response.ok) {
      const body: ApiError = await response.json()
      throw new HttpError(response.status, body)
    }

    return await response.json()
  } catch (error) {
    if (error instanceof HttpError) {
      // Type-safe access to error properties
      console.error(`HTTP ${error.statusCode}: ${error.body.message}`)
      throw error
    }

    // Unknown error
    throw new Error('Failed to fetch user')
  }
}

// Discriminated union for result
type Result<T, E = Error> =
  | { success: true; data: T }
  | { success: false; error: E }

async function safeFetchUser(id: number): Promise<Result<User>> {
  try {
    const user = await fetchUser(id)
    return { success: true, data: user }
  } catch (error) {
    return {
      success: false,
      error: error instanceof Error ? error : new Error(String(error))
    }
  }
}

// Using Result type
const result = await safeFetchUser(1)

if (result.success) {
  console.log(result.data.name)
} else {
  console.error(result.error.message)
}

API Response Typing

// Define response types
interface PaginationMeta {
  page: number
  limit: number
  total: number
  totalPages: number
}

interface PaginatedResponse<T> {
  data: T[]
  meta: PaginationMeta
}

interface User {
  id: number
  name: string
  email: string
}

// Type-safe API client
class ApiClient {
  async get(url: string): Promise<any> {
    const response = await fetch(url)
    return response.json()
  }

  async getUsers(page: number = 1): Promise<PaginatedResponse<User>> {
    const response = await this.get(`/api/users?page=${page}`)
    return response
  }

  async getUser(id: number): Promise<User> {
    const response = await this.get(`/api/users/${id}`)
    return response
  }

  async createUser(data: Omit<User, 'id'>): Promise<User> {
    const response = await this.post('/api/users', data)
    return response
  }

  async updateUser(id: number, data: Partial<User>): Promise<User> {
    const response = await this.patch(`/api/users/${id}`, data)
    return response
  }

  async deleteUser(id: number): Promise<void> {
    await this.delete(`/api/users/${id}`)
  }
}

React Integration

// Type-safe props
interface ButtonProps {
  children: React.ReactNode
  onClick?: () => void
  variant?: 'primary' | 'secondary' | 'danger'
  disabled?: boolean
}

function Button({ children, onClick, variant = 'primary', disabled = false }: ButtonProps) {
  const baseStyles = 'px-4 py-2 rounded-md font-medium'
  const variants = {
    primary: 'bg-blue-600 text-white hover:bg-blue-700',
    secondary: 'bg-gray-600 text-white hover:bg-gray-700',
    danger: 'bg-red-600 text-white hover:bg-red-700',
  }

  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`${baseStyles} ${variants[variant]} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
    >
      {children}
    </button>
  )
}

// Type-safe hooks
interface UseStateReturn<T> {
  value: T
  setValue: (value: T | ((prev: T) => T)) => void
}

function useLocalStorage<T>(key: string, initialValue: T): UseStateReturn<T> {
  const [value, setValue] = useState<T>(() => {
    const saved = localStorage.getItem(key)
    return saved ? JSON.parse(saved) : initialValue
  })

  const setStoredValue = (value: T | ((prev: T) => T)) => {
    setValue((prev) => {
      const newValue = typeof value === 'function' ? (value as (prev: T) => T)(prev) : value
      localStorage.setItem(key, JSON.stringify(newValue))
      return newValue
    })
  }

  return { value, setValue: setStoredValue }
}

// Type-safe context
interface ThemeContextType {
  theme: 'light' | 'dark'
  toggleTheme: () => void
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined)

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light')

  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light')
  }

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

function useTheme(): ThemeContextType {
  const context = useContext(ThemeContext)
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider')
  }
  return context
}

Performance Tips

Avoid Unnecessary Type Instantiation

// ❌ Bad: Recreating types
function processUsers(users: User[]): User[] {
  const result: User[] = []
  // ...
  return result
}

// ✅ Good: Reuse types
function processUsers(users: User[]): User[] {
  const result: User[] = []
  // ...
  return result
}

// Better: Use type inference
function processUsers(users: User[]) {
  return users.map(/* ... */)
}

Use Type Inference Where Possible

// ❌ Bad: Explicit types everywhere
const data: Record<string, number> = {
  a: 1,
  b: 2,
}

// ✅ Good: Let TypeScript infer
const data = {
  a: 1,
  b: 2,
} as const // Add as const for literal types

// Better: Define type separately
type Data = Record<string, number>
const data: Data = {
  a: 1,
  b: 2,
}

Optimize Complex Types

// ❌ Bad: Expensive type computation
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}

// ✅ Good: Use utility types
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? Partial<T[K]> : T[K]
}

// Or use a library like ts-essentials
import { DeepPartial } from 'ts-essentials'

Configuration Best Practices

tsconfig.json Setup

{
  "compilerOptions": {
    /* Language and Environment */
    "target": "ES2020",
    "lib": ["ES2020", "DOM"],
    "jsx": "react-jsx",

    /* Modules */
    "module": "NodeNext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "esModuleInterop": true,

    /* Emit */
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,

    /* Interop Constraints */
    "isolatedModules": true,
    "allowSyntheticDefaultImports": true,

    /* Type Checking */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,

    /* Completeness */
    "skipLibCheck": true,

    /* Advanced */
    "incremental": true,
    "tsBuildInfoFile": "./dist/.tsbuildinfo"
  },

  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}

Testing with TypeScript

Type-Safe Tests

// Test utilities
interface TestContext {
  api: ApiClient
  user: User
}

function createTestContext(): TestContext {
  return {
    api: new ApiClient('https://test-api.example.com'),
    user: {
      id: 1,
      name: 'Test User',
      email: 'test@example.com',
    },
  }
}

// Test assertions
function assertEqual<T>(actual: T, expected: T, message?: string): asserts actual is T {
  if (actual !== expected) {
    throw new Error(`${message}: Expected ${expected}, got ${actual}`)
  }
}

// Test example
test('should fetch user', async () => {
  const context = createTestContext()
  const user = await context.api.getUser(context.user.id)

  assertEqual(user.id, context.user.id, 'User ID should match')
  assertEqual(user.name, context.user.name, 'User name should match')
})

Mock Types

// Type-safe mocks
interface Mock<T> extends jest.Mock<{}, T> {
  mockReturnValue(value: T): Mock<T>
  mockResolvedValue(value: T): Mock<T>
}

function createMock<T>(implementation?: (...args: any[]) => T): Mock<T> {
  const mock = jest.fn(implementation) as Mock<T>

  mock.mockReturnValue = function (value: T) {
    return this.mockImplementation(() => value) as Mock<T>
  }

  mock.mockResolvedValue = function (value: T) {
    return this.mockImplementation(() => Promise.resolve(value)) as Mock<T>
  }

  return mock
}

// Usage
const mockApi = createMock<ApiClient>()
mockApi.getUser.mockResolvedValue({ id: 1, name: 'Test' })

Common Pitfalls and Solutions

Pitfall 1: Using any Instead of Proper Types

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

// ✅ Good
interface DataItem {
  value: number
}

function processData(data: DataItem[]) {
  return data.map(item => item.value)
}

Pitfall 2: Ignoring TypeScript Errors with @ts-ignore

// ❌ Bad
// @ts-ignore
const user = {} as User

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

Pitfall 3: Not Handling Null/Undefined

// ❌ Bad
function getUserName(user?: User): string {
  return user.name // Error: Object is possibly 'undefined'
}

// ✅ Good
function getUserName(user?: User): string {
  if (!user) {
    throw new Error('User is required')
  }
  return user.name
}

// Or use nullish coalescing
function getUserName(user?: User): string {
  return user?.name ?? 'Unknown'
}

Pitfall 4: Overusing Type Assertions

// ❌ Bad
const element = document.getElementById('app') as HTMLDivElement

// ✅ Good
const element = document.getElementById('app')
if (element && element instanceof HTMLDivElement) {
  // Type-safe access
}

Conclusion

TypeScript best practices aren't about following rules blindly—they're about writing code that's safer, more maintainable, and easier to understand. By embracing strict mode, using advanced type patterns, and avoiding common pitfalls, you can build robust applications with confidence.

Key Takeaways

  1. Enable strict mode - Catch errors early
  2. Avoid any - Use unknown and type guards
  3. Prefer readonly types - Embrace immutability
  4. Choose interfaces vs types wisely - Use the right tool
  5. Master utility types - Avoid repetitive code
  6. Use type guards - Narrow types safely
  7. Configure properly - tsconfig.json matters
  8. Test thoroughly - Type-safe tests

Next Steps

  1. Audit your TypeScript configuration
  2. Enable strict mode and fix resulting errors
  3. Replace any with proper types
  4. Implement type guards and narrowing
  5. Learn advanced type patterns
  6. Set up type-safe API clients
  7. Practice with real-world examples

TypeScript is a powerful tool. Use it wisely, and it will transform how you write JavaScript applications.


Ready to improve your TypeScript? Start by enabling strict mode and fixing the errors. The investment will pay dividends in bug prevention and code quality.