TypeScriptBest Practices

TypeScript Best Practices

Well-typed TypeScript code is not about adding types everywhere — it is about using the type system purposefully to prevent bugs, communicate intent, and enable safe refactoring. These best practices are distilled from large production codebases.

1. Prefer Type Inference Over Explicit Annotations

TypeScript can infer most types automatically. Redundant annotations add noise without adding safety. Let the compiler do the work, and only annotate when inference falls short.

TS
// Bad — redundant annotations
const count: number = 42
const name: string = 'Alice'
const active: boolean = true
const items: string[] = ['a', 'b', 'c']

// Good — let TypeScript infer
const count = 42        // inferred: number
const name = 'Alice'    // inferred: string
const active = true     // inferred: boolean
const items = ['a', 'b', 'c'] // inferred: string[]

// Annotate where inference is unclear or misleading
function fetchUser(id: string): Promise<User> { // return type annotation helps readers
  return fetch(`/users/${id}`).then(r => r.json())
}

// Annotate function parameters — they are not inferred from context
function greet(name: string): string {
  return `Hello, ${name}`
}
2. Use unknown Over any

any disables the type checker entirely for a value. unknown forces you to narrow the type before using it, keeping you safe while still accepting arbitrary input.

TS
// Bad — any bypasses all checks
function processInput_bad(input: any) {
  console.log(input.name.toUpperCase()) // runtime error if name is undefined
}

// Good — unknown forces narrowing
function processInput(input: unknown): string {
  if (typeof input === 'string') {
    return input.toUpperCase() // TS knows it's string here
  }
  if (typeof input === 'object' && input !== null && 'name' in input) {
    const obj = input as { name: unknown }
    if (typeof obj.name === 'string') {
      return obj.name.toUpperCase()
    }
  }
  return String(input)
}

// Common pattern: parsed JSON
const raw: unknown = JSON.parse(someString)

// Must narrow before use
if (typeof raw === 'object' && raw !== null) {
  // safe to access properties after narrowing
}
Note
Use any only at system boundaries where you truly cannot know the type — for example, in a generic serializer. Document why any is intentional with a comment.
3. Never Disable Strict Mode

strict: true in tsconfig enables the full suite of type-checking options. The bugs it catches — null dereferences, implicit any, wrong this bindings — are exactly the bugs that are hardest to debug at runtime.

JSON
// Always have this in tsconfig.json
{
  "compilerOptions": {
    "strict": true
  }
}

// strict: true is shorthand for all of:
// "noImplicitAny": true
// "strictNullChecks": true
// "strictFunctionTypes": true
// "strictBindCallApply": true
// "strictPropertyInitialization": true
// "noImplicitThis": true
// "alwaysStrict": true
// "useUnknownInCatchVariables": true  (TS 4.4+)
4. Use const Assertions for Literal Types

as const freezes an object or array as a read-only literal, preventing widening to string or number and enabling powerful narrowing.

TS
// Without as const — types are widened
const direction = 'north'                  // string (widened!)
const config = { env: 'production' }       // { env: string }

// With as const — literal types are preserved
const direction2 = 'north' as const        // 'north'
const config2 = { env: 'production' } as const // { readonly env: 'production' }

// Great for union types from arrays
const ROLES = ['admin', 'editor', 'viewer'] as const
type Role = typeof ROLES[number]
// 'admin' | 'editor' | 'viewer'

function setRole(role: Role) {
  console.log(role)
}

setRole('admin')   // OK
// setRole('guest') // Error: 'guest' is not assignable to Role

// Route config — all values stay as string literals
const ROUTES = {
  home:  '/',
  about: '/about',
  blog:  '/blog',
} as const

type AppRoute = typeof ROUTES[keyof typeof ROUTES]
// '/' | '/about' | '/blog'
5. Prefer interface for Object Shapes

Use interface for describing the shape of objects and classes. Interfaces support declaration merging (useful for extending library types) and are more readable in error messages.

TS
// Prefer interface for objects
interface User {
  id:    string
  name:  string
  email: string
}

interface AdminUser extends User {
  permissions: string[]
}

// Declaration merging — augment a library's interface
interface Window {
  analytics: { track(event: string): void }
}

// Now window.analytics is typed everywhere

// Use type for everything else (unions, intersections, primitives)
type ID = string | number
type UserOrAdmin = User | AdminUser
type StringMap  = Record<string, string>
type Callback   = (error: Error | null, result: unknown) => void
6. Use type for Unions, Intersections, and Computed Types

TS
// Unions
type Status = 'idle' | 'loading' | 'success' | 'error'
type StringOrNumber = string | number

// Intersections
type WithTimestamps = { createdAt: Date; updatedAt: Date }
type UserRecord = User & WithTimestamps

// Mapped types
type Optional<T> = { [K in keyof T]?: T[K] }
type Readonly_<T> = { readonly [K in keyof T]: T[K] }

// Conditional types
type IsString<T> = T extends string ? true : false

// Template literal types
type EventName = `on${Capitalize<string>}`

// Function signatures
type Handler = (event: MouseEvent) => void
type AsyncHandler = (event: MouseEvent) => Promise<void>
7. Avoid Enums — Use const Objects or Union Types

TypeScript enums have surprising behavior: numeric enums allow any number, string enums are nominal and cannot be compared to plain strings, and both generate runtime code (unlike most TypeScript). Use const objects or string union types instead.

TS
// Avoid — numeric enum allows any number to be assigned
enum Direction_bad {
  Up,
  Down,
  Left,
  Right,
}
const d: Direction_bad = 999 // No error! Numeric enums are unsafe.

// Avoid — string enum forces use of the enum type, not string literals
enum Status_bad {
  Active   = 'active',
  Inactive = 'inactive',
}
// const s: Status_bad = 'active' // Error: must use Status_bad.Active

// Good — string union type
type Direction = 'up' | 'down' | 'left' | 'right'
const move = (dir: Direction) => console.log(dir)
move('up')    // OK
move('left')  // OK
// move('sideways') // Error

// Good — const object (when you need the enum-like object at runtime)
const Status = {
  Active:   'active',
  Inactive: 'inactive',
  Pending:  'pending',
} as const

type Status = typeof Status[keyof typeof Status]
// 'active' | 'inactive' | 'pending'

function setStatus(status: Status) {
  console.log(status)
}

setStatus(Status.Active)  // OK
setStatus('active')       // Also OK — string literals are accepted
Warning
If you use const enum, be careful: it only works when compiled by TypeScript. Babel and esbuild do not support it, which can cause runtime errors in many modern build pipelines.
8. Use Discriminated Unions

Discriminated unions are the primary tool for modeling state machines, API responses, and any value that can be one of several shapes. They work beautifully with TypeScript's control flow analysis.

TS
// Bad — optional fields, requires null checks everywhere
interface LoadingState {
  isLoading?: boolean
  data?:      User
  error?:     string
}

// Good — discriminated union, each state is self-contained
type LoadingState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error';   error: string }

function render(state: LoadingState) {
  switch (state.status) {
    case 'idle':
      return 'Start fetching'
    case 'loading':
      return 'Loading...'
    case 'success':
      // state.data is available here — no optional chaining needed
      return `Hello, ${state.data.name}`
    case 'error':
      // state.error is available here
      return `Error: ${state.error}`
  }
}
9. Avoid Type Casting — Use Type Guards

Type assertions (as SomeType) bypass the type checker. They are sometimes necessary at system boundaries, but a type guard that actually checks the value at runtime is almost always safer.

TS
// Bad — assertion with no runtime check
function processApiResponse(data: unknown) {
  const user = data as User // hope it's actually a User!
  console.log(user.name)    // runtime error if it isn't
}

// Good — type guard with runtime check
function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    typeof (value as any).id    === 'string' &&
    typeof (value as any).name  === 'string' &&
    typeof (value as any).email === 'string'
  )
}

function processApiResponse2(data: unknown) {
  if (isUser(data)) {
    // data is User — checked at runtime AND compile time
    console.log(data.name)
  } else {
    throw new Error('Invalid user shape')
  }
}

// Use a validation library for production code
import { z } from 'zod'
const UserSchema = z.object({
  id:    z.string(),
  name:  z.string(),
  email: z.string().email(),
})

function processApiResponse3(data: unknown) {
  const user = UserSchema.parse(data) // throws with details if invalid
  console.log(user.name) // User — fully typed
}
10. Keep Types Close to Data

Define types in the same file as the data they describe. This makes it easier to find the type, keep it in sync with the implementation, and understand the code in context.

TS
// user.ts — type and implementation together
export interface User {
  id:    string
  name:  string
  email: string
}

export function createUser(name: string, email: string): User {
  return { id: crypto.randomUUID(), name, email }
}

export function isAdmin(user: User): boolean {
  return user.email.endsWith('@company.com')
}

// Avoid a separate types/user.ts file unless the type is genuinely
// shared across many modules — premature abstraction adds indirection.
11. Use Built-In Utility Types

TypeScript ships with a comprehensive library of utility types. Use them instead of rewriting common type transformations.

Utility

Purpose

Example

Partial<T>

All properties optional

Partial<User> for update payloads

Required<T>

All properties required

Required<Config> to enforce full config

Readonly<T>

All properties readonly

Readonly<State> for immutable state

Pick<T, K>

Keep only named keys

Pick<User, "id" | "name"> for a DTO

Omit<T, K>

Remove named keys

Omit<User, "password"> for public responses

Record<K, V>

Typed map/dictionary

Record<string, number> for a score map

ReturnType<F>

Return type of a function

ReturnType<typeof fetchUser>

Parameters<F>

Parameters tuple of a function

Parameters<typeof fetch>

Awaited<T>

Resolved type of a Promise

Awaited<ReturnType<typeof fetchUser>>

NonNullable<T>

Remove null and undefined

NonNullable<string | null>

TS
interface User {
  id:       string
  name:     string
  email:    string
  password: string
  role:     'admin' | 'user'
}

// Create update payload — all fields optional
type UpdateUserInput = Partial<Omit<User, 'id'>>

// Public user DTO — no password
type PublicUser = Omit<User, 'password'>

// Read-only state
type UserState = Readonly<User>

// Function type extraction
async function fetchUser(id: string): Promise<User> {
  return fetch(`/users/${id}`).then(r => r.json())
}

type FetchedUser = Awaited<ReturnType<typeof fetchUser>>
// User
12. Organize Types Consistently
  • Co-locate types with the code that uses them

  • Export types from the same barrel index as their implementation

  • Use a shared types/ directory only for genuinely cross-cutting types

  • Name interfaces after the thing they describe, not after their usage (User not IUser or UserInterface)

  • Use PascalCase for types and interfaces, camelCase for variables and functions

  • Suffix error types with Error: NetworkError, ValidationError

  • Suffix event payload types with Event or Payload: LoginEvent, UserCreatedPayload

13. Naming Conventions

Thing

Convention

Example

Interfaces

PascalCase

User, HttpConfig, EventEmitter

Type aliases

PascalCase

UserId, Status, EventHandler

Generic parameters

Single uppercase letter or descriptive PascalCase

T, K, V, TValue, TKey

Enum-like const objects

ALL_CAPS keys, PascalCase object name

const Status = { ACTIVE: "active" }

Type predicates

isX, hasX, canX

isUser, hasPermission, canEdit

Utility / helper types

Descriptive PascalCase

DeepPartial, Nullable, Brand

14. Avoid Redundant Type Annotations on Class Members

TS
// Bad — redundant annotations
class UserService {
  private users: User[] = []

  // Return type is obvious from implementation
  add(user: User): void {
    this.users.push(user)
  }

  // TypeScript infers User | undefined
  find(id: string): User | undefined {
    return this.users.find(u => u.id === id)
  }
}

// Good — annotate only when inference is unclear
class UserService2 {
  private users: User[] = [] // annotation needed — empty array

  add(user: User) { // return void is inferred
    this.users.push(user)
  }

  find(id: string) { // return User | undefined is inferred
    return this.users.find(u => u.id === id)
  }

  // Explicit return type adds value here — makes the contract clear
  getAll(): readonly User[] {
    return this.users
  }
}
15. Use Type-Only Imports

Use import type for imports that are only needed at the type level. This guarantees the import is erased at compile time and has zero runtime cost.

TS
// Bad — may include a runtime import even if only used as a type
import { User } from './user'

// Good — erased completely at compile time
import type { User } from './user'

// Mixed — import values and types from the same module
import { createUser, type User, type CreateUserInput } from './user'

// tsconfig option to enforce this automatically:
// "verbatimModuleSyntax": true
// This requires all type-only imports to use import type.
Success
Following these best practices keeps your TypeScript code safe, readable, and maintainable: infer where you can, annotate where you must, use unknown over any, model state with discriminated unions, prefer type guards over assertions, and always run with strict: true.