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.
// 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.
// 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
}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.
// 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.
// 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.
// 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) => void6. Use type for Unions, Intersections, and Computed Types
// 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.
// 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 acceptedconst 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.
// 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.
// 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.
// 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> |
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>>
// User12. 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
// 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.
// 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.unknown over any, model state with discriminated unions, prefer type guards over assertions, and always run with strict: true.