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
// 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')
}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
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
// 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! 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
// 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
// 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
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
// 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 import8. Don't use loose index signatures — Do be specific
// 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
// 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
// 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
// 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
// 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
// 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 Email14. Don't ignore compiler errors with // @ts-ignore — Do fix the root cause
// 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
// 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 |