TypeScriptDo's & Don'ts

TypeScript Do's and Don'ts

This page presents side-by-side comparisons of common TypeScript mistakes and their correct alternatives. Each example shows a concrete "before" and "after" with an explanation of why the change matters.

1. Don't use any — Do use unknown

TS
// DON'T — any turns off the type checker entirely
function parseData(input: any) {
  return input.data.items[0].name // runtime error if shape is wrong
}

// DO — unknown forces you to validate before use
function parseData(input: unknown): string {
  if (
    typeof input === 'object' &&
    input !== null &&
    'data' in input
  ) {
    // narrow further as needed
    const data = (input as any).data
    if (Array.isArray(data?.items) && data.items.length > 0) {
      return String(data.items[0].name)
    }
  }
  throw new Error('Unexpected shape')
}
Note
Every any you write is a hole in your type safety. Use unknown for values whose type you genuinely do not know, and narrow the type before using the value.
2. Don't assert types carelessly — Do use type guards

TS
interface User { id: string; name: string; email: string }

// DON'T — assertion has no runtime check, can silently break
function processResponse(data: unknown) {
  const user = data as User // blind trust
  console.log(user.name.toUpperCase()) // runtime error if data is wrong
}

// DO — type guard validates the shape at runtime
function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    typeof (value as Record<string, unknown>).id    === 'string' &&
    typeof (value as Record<string, unknown>).name  === 'string' &&
    typeof (value as Record<string, unknown>).email === 'string'
  )
}

function processResponse(data: unknown) {
  if (!isUser(data)) throw new Error('Invalid user data')
  // data is User here — both at compile time and runtime
  console.log(data.name.toUpperCase()) // safe
}
3. Don't use the non-null assertion carelessly — Do check for null

TS
// DON'T — ! assertion crashes if the value is actually null
function getFirstItem(arr: string[]): string {
  return arr[0]! // runtime error if arr is empty
}

const el = document.getElementById('app')!
el.addEventListener('click', () => {}) // crashes if element doesn't exist

// DO — check explicitly
function getFirstItem(arr: string[]): string | undefined {
  return arr[0]
}

// Handle the undefined case at the call site:
const first = getFirstItem(items)
if (first !== undefined) {
  console.log(first.toUpperCase())
}

// For DOM elements:
const el = document.getElementById('app')
if (!el) throw new Error('Element #app not found')
el.addEventListener('click', () => {}) // safe
Tip
The non-null assertion ! is acceptable when you have out-of-band knowledge that a value cannot be null — for example, inside a condition you just checked. Document why with a comment.
4. Don't use the Function type — Do use specific signatures

TS
// DON'T — Function accepts any callable, no argument/return type info
function execute(fn: Function) {
  fn(1, 2, 3) // TypeScript has no idea if these args are valid
}

function applyToAll(items: unknown[], fn: Function) {
  return items.map(fn) // return type is any[]
}

// DO — use precise signatures
function execute(fn: (a: number, b: number) => number) {
  return fn(1, 2) // args and return type are checked
}

function applyToAll<T, U>(items: T[], fn: (item: T) => U): U[] {
  return items.map(fn) // return type is U[]
}

// For callbacks with variable signatures:
type Callback<T = void> = (error: Error | null, result: T) => void
type EventHandler = (event: Event) => void
type AsyncFn<T> = (...args: unknown[]) => Promise<T>
5. Don't use the object type — Do use specific interfaces

TS
// DON'T — object accepts any non-primitive but tells you nothing about shape
function printUser(user: object) {
  console.log(user.name) // Error: name does not exist on type object
}

// DON'T — {} accepts any non-nullish value (too broad)
function process(config: {}) {
  // almost anything is assignable to {} — string, number, Function...
}

// DO — define the shape explicitly
interface User {
  name:  string
  email: string
}

function printUser(user: User) {
  console.log(user.name) // fine — name is on User
}

// DO — use Record for typed dictionaries
function process(config: Record<string, string>) {
  // accepts an object with string keys and string values
}

// DO — use a generic constraint for "any object"
function keys<T extends object>(obj: T): (keyof T)[] {
  return Object.keys(obj) as (keyof T)[]
}
6. Don't repeat type definitions — Do use utility types

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

// DON'T — manually duplicate and modify types
interface CreateUserInput {
  name:     string
  email:    string
  password: string
  role?:    'admin' | 'user'
}

interface PublicUser {
  id:   string
  name: string
  email: string
}

interface UpdateUserInput {
  name?:     string
  email?:    string
  password?: string
}

// DO — derive types from the source of truth
type CreateUserInput = Omit<User, 'id'> & { role?: User['role'] }
type PublicUser      = Omit<User, 'password'>
type UpdateUserInput = Partial<Omit<User, 'id' | 'role'>>

// When User changes, all derived types update automatically.
7. Don't use enums — Do use const objects or union types

TS
// DON'T — numeric enums are unsafe (any number is assignable)
enum Direction {
  North, South, East, West
}
const d: Direction = 999 // No error — this is a bug

// DON'T — string enums force consumers to import the enum type
enum Status {
  Active   = 'active',
  Inactive = 'inactive',
}
// Cannot pass 'active' directly — must use Status.Active

// DO — union type (simplest)
type Direction = 'north' | 'south' | 'east' | 'west'

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

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

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

setStatus(Status.Active) // OK
setStatus('active')      // Also OK — no forced import
8. Don't use loose index signatures — Do be specific

TS
// DON'T — index signature makes ALL properties any
interface Config {
  [key: string]: any
  host: string  // This is effectively ignored
  port: number  // This is effectively ignored
}

const c: Config = { host: 'localhost', port: 3000 }
c.anything = 'sure why not' // TypeScript allows this

// DO — define exact properties
interface Config {
  host:    string
  port:    number
  ssl?:    boolean
  timeout?: number
}

// If you genuinely need dynamic keys alongside known ones:
interface Config {
  host: string
  port: number
  // Extra fields must be string values
  [extra: string]: string | number | boolean | undefined
}

// Or use a union of the known and an extension
type FlexConfig = Config & Record<string, unknown>
9. Don't widen with type assertions — Do use proper types

TS
// DON'T — double assertion to bypass type safety
function getUser(): User {
  return {} as unknown as User // completely bypasses the checker
}

// DON'T — asserting to a broader type to silence errors
const input = document.getElementById('name') as any
input.setValue('hello') // no error, but not a real method

// DO — handle the actual types
function getUser(): User {
  // Build it properly
  return { id: '1', name: 'Alice', email: 'alice@example.com' }
}

// DO — use the correct DOM type
const input = document.getElementById('name') as HTMLInputElement | null
if (input) {
  input.value = 'hello' // HTMLInputElement.value is the real property
}

// DO — narrow properly
const el = document.querySelector<HTMLInputElement>('#name')
if (el) {
  el.value = 'hello' // typed as HTMLInputElement
}
10. Don't use optional chaining to silence all null errors — Do handle nulls explicitly

TS
// DON'T — optional chaining everywhere hides logic errors
function displayUser(user?: User) {
  // If user is unexpectedly undefined, this silently shows nothing
  console.log(user?.name?.toUpperCase())
  document.title = user?.email ?? ''
  renderAvatar(user?.id)
}

// DO — decide what to do when the value is absent
function displayUser(user: User | null) {
  if (!user) {
    console.log('No user to display')
    return
  }
  // user is User from here — no optional chaining needed
  console.log(user.name.toUpperCase())
  document.title = user.email
  renderAvatar(user.id)
}

// Optional chaining IS appropriate for truly optional nested access
const city = response?.data?.address?.city ?? 'Unknown'
11. Don't annotate obvious return types — Do annotate public API boundaries

TS
// DON'T — redundant return type annotations
function add(a: number, b: number): number {
  return a + b // TypeScript already infers number
}

const getItems = (): string[] => ['a', 'b', 'c'] // redundant

// DO — annotate public API functions that return complex types
// (adds documentation and catches mistakes in the implementation)
export async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/users/${id}`)
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  return res.json() // If you return the wrong shape, TS will catch it
}

// DO — annotate when inference would be too wide
function createStatus(): 'active' | 'inactive' {
  return 'active' // Without annotation, inferred as string
}

// DO — annotate class methods in public classes
class UserService {
  getAll(): readonly User[] {  // Makes the contract explicit
    return this.users
  }
}
12. Don't use boolean flags — Do use discriminated unions

TS
// DON'T — boolean flags lead to invalid states
interface FetchState {
  isLoading:  boolean
  isError:    boolean
  data?:      User
  error?:     string
}
// What does { isLoading: true, isError: true } mean? Invalid!

// DO — discriminated union makes impossible states unrepresentable
type FetchState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User    }
  | { status: 'error';   error: string }

function render(state: FetchState) {
  switch (state.status) {
    case 'idle':    return 'Start'
    case 'loading': return 'Loading...'
    case 'success': return state.data.name  // data is always present here
    case 'error':   return state.error       // error is always present here
  }
}
13. Don't use string for everything — Do use specific types

TS
// DON'T — all strings look the same, easy to pass wrong value
function sendEmail(to: string, from: string, subject: string) {
  // Easy to swap to/from by accident
}

sendEmail(fromEmail, toEmail, subject) // swapped args — no error!

// DO — use branded types or named interfaces for clarity
declare const __brand: unique symbol
type Brand<T, B> = T & { readonly [__brand]: B }

type Email   = Brand<string, 'Email'>
type Subject = Brand<string, 'Subject'>

function sendEmail(to: Email, from: Email, subject: Subject) {
  // Now to and from cannot be swapped — they are different types
}

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

const to      = parseEmail('alice@example.com')
const from    = parseEmail('noreply@example.com')
const subject = 'Hello' as Subject

sendEmail(to, from, subject)  // OK
// sendEmail(from, to, subject) // Still OK... brands are same type
// sendEmail(subject, from, to) // Error: Subject not assignable to Email
14. Don't ignore compiler errors with // @ts-ignore — Do fix the root cause

TS
// DON'T — ts-ignore hides real problems
// @ts-ignore
const result = someFunction(wrongArgType)

// DON'T — ts-ignore stays even after the bug is fixed (silent dead weight)

// DO — fix the actual type error
const result = someFunction(correctArgType)

// DO — if the error is expected in a test, use @ts-expect-error
// It errors if the suppressed error disappears (keeping your code clean)
// @ts-expect-error — testing that wrong arg type is rejected
someFunction(wrongArgType)

// DO — document why a ts-ignore is truly necessary
// @ts-ignore — third-party library has wrong types, filed in issue #123
const result2 = library.undocumentedMethod()
15. Don't make all props optional — Do model optional state correctly

TS
// DON'T — everything optional means everything could be undefined
interface UserProfile {
  id?:     string
  name?:   string
  email?:  string
  avatar?: string
}

// Now you need to check every field everywhere:
function greet(profile: UserProfile) {
  if (profile.name) { // if it might not be there, why is it in the type?
    return `Hello, ${profile.name}`
  }
  return 'Hello'
}

// DO — make required fields required, optional fields optional
interface UserProfile {
  id:      string   // always present
  name:    string   // always present
  email:   string   // always present
  avatar?: string   // genuinely optional
}

// For update operations, derive a separate type:
type UpdateUserProfile = Partial<Omit<UserProfile, 'id'>>

function greet(profile: UserProfile) {
  return `Hello, ${profile.name}` // no check needed
}
Summary Table

Don't

Do Instead

Use any

Use unknown and narrow

Assert types with as

Use type guards (value is T)

Use non-null assertion !

Check for null explicitly

Use Function type

Use specific function signatures

Use object type

Use specific interfaces

Duplicate type definitions

Use utility types (Partial, Omit, Pick)

Use enums

Use const objects or string union types

Use loose index signatures

Define exact property shapes

Double-assert (as unknown as T)

Fix the actual type mismatch

Use optional chaining everywhere

Handle nulls explicitly at boundaries

Annotate obvious return types

Annotate public API boundaries

Use boolean flags for state

Use discriminated unions

Use string for everything

Use branded types for critical values

Use @ts-ignore

Fix the error or use @ts-expect-error

Make all props optional

Required = required, optional = optional

Success
These 15 patterns represent the most impactful TypeScript habits. They are not about being strict for strictness's sake — each one prevents a class of real runtime bugs and makes your code easier to refactor and reason about.