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
- Enable strict mode - Catch errors early
- Avoid
any- Useunknownand type guards - Prefer readonly types - Embrace immutability
- Choose interfaces vs types wisely - Use the right tool
- Master utility types - Avoid repetitive code
- Use type guards - Narrow types safely
- Configure properly - tsconfig.json matters
- Test thoroughly - Type-safe tests
Next Steps
- Audit your TypeScript configuration
- Enable strict mode and fix resulting errors
- Replace
anywith proper types - Implement type guards and narrowing
- Learn advanced type patterns
- Set up type-safe API clients
- 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.