TypeScript Best Practices: Writing Maintainable and Type-Safe Code
A comprehensive guide to TypeScript best practices, covering patterns, techniques, and strategies for writing maintainable, type-safe, and scalable TypeScript code in production applications.
TypeScript Best Practices: Writing Maintainable and Type-Safe Code
TypeScript has become the de facto standard for building large-scale JavaScript applications. However, writing good TypeScript code requires more than just adding type annotations. In this article, I'll share best practices and patterns that will help you write maintainable, type-safe, and scalable TypeScript code.
Type Safety Fundamentals
1. Avoid any Type
The any type defeats the purpose of TypeScript. Use it sparingly, if at all.
// ❌ Bad: Using any
function processData(data: any) {
return data.value * 2
}
// ✅ Good: Proper typing
function processData(data: { value: number }) {
return data.value * 2
}
// ✅ Better: Generic type
function processData<T extends { value: number }>(data: T): number {
return data.value * 2
}
2. Use Type Inference
Let TypeScript infer types when possible.
// ❌ Bad: Unnecessary type annotation
const count: number = 0
const name: string = "John"
// ✅ Good: Type inference
const count = 0
const name = "John"
// ✅ Good: Explicit when needed for clarity
const count: number = calculateCount()
3. Prefer Interfaces for Object Shapes
Use interfaces for object shapes, types for unions and intersections.
// ✅ Good: Interface for object shape
interface User {
id: string
name: string
email: string
}
// ✅ Good: Type for union
type Status = 'pending' | 'approved' | 'rejected'
// ✅ Good: Type for intersection
type AdminUser = User & { role: 'admin' }
Advanced Type Patterns
1. Discriminated Unions
Use discriminated unions for type-safe state management.
// ✅ Good: Discriminated union
type LoadingState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string }
function handleState(state: LoadingState) {
switch (state.status) {
case 'idle':
return 'Ready to load'
case 'loading':
return 'Loading...'
case 'success':
return `Loaded ${state.data.length} users` // TypeScript knows data exists
case 'error':
return `Error: ${state.error}` // TypeScript knows error exists
}
}
2. Utility Types
Leverage TypeScript's utility types.
// Partial: Make all properties optional
type PartialUser = Partial<User>
// Pick: Select specific properties
type UserPreview = Pick<User, 'id' | 'name'>
// Omit: Exclude specific properties
type UserWithoutEmail = Omit<User, 'email'>
// Record: Create object type
type UserRoles = Record<string, 'admin' | 'user' | 'guest'>
// Readonly: Make properties readonly
type ImmutableUser = Readonly<User>
3. Generic Constraints
Use generic constraints to create flexible, type-safe functions.
// ✅ Good: Generic with constraint
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
const user = { id: '1', name: 'John', age: 30 }
const name = getProperty(user, 'name') // Type: string
const id = getProperty(user, 'id') // Type: string
// const invalid = getProperty(user, 'invalid') // ❌ Type error
Function Patterns
1. Function Overloads
Use function overloads for better type inference.
// ✅ Good: Function overloads
function format(value: string): string
function format(value: number): string
function format(value: boolean): string
function format(value: string | number | boolean): string {
return String(value)
}
const str = format('hello') // Type: string
const num = format(42) // Type: string
2. Async/Await Types
Properly type async functions and promises.
// ✅ Good: Typed async function
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`)
return response.json()
}
// ✅ Good: Typed promise
function createUser(data: CreateUserInput): Promise<User> {
return api.post('/users', data)
}
3. Error Handling
Type errors properly for better error handling.
// ✅ Good: Custom error types
class ValidationError extends Error {
constructor(
public field: string,
public message: string
) {
super(message)
this.name = 'ValidationError'
}
}
class NetworkError extends Error {
constructor(public statusCode: number, message: string) {
super(message)
this.name = 'NetworkError'
}
}
type AppError = ValidationError | NetworkError
function handleError(error: AppError) {
if (error instanceof ValidationError) {
console.error(`Validation error in ${error.field}: ${error.message}`)
} else if (error instanceof NetworkError) {
console.error(`Network error ${error.statusCode}: ${error.message}`)
}
}
React-Specific Patterns
1. Component Props
Type React component props properly.
// ✅ Good: Typed component props
interface ButtonProps {
label: string
onClick: () => void
variant?: 'primary' | 'secondary'
disabled?: boolean
}
function Button({ label, onClick, variant = 'primary', disabled = false }: ButtonProps) {
return (
<button
onClick={onClick}
className={`btn btn-${variant}`}
disabled={disabled}
>
{label}
</button>
)
}
2. Hooks
Type custom hooks properly.
// ✅ Good: Typed custom hook
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
fetch(url)
.then(res => res.json())
.then((data: T) => {
setData(data)
setLoading(false)
})
.catch((err: Error) => {
setError(err)
setLoading(false)
})
}, [url])
return { data, loading, error }
}
// Usage
const { data, loading, error } = useFetch<User[]>('/api/users')
3. Event Handlers
Type event handlers correctly.
// ✅ Good: Typed event handlers
function Form() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
// Handle form submission
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
// Handle input change
}
return (
<form onSubmit={handleSubmit}>
<input onChange={handleChange} />
</form>
)
}
Configuration and Tooling
1. Strict Type Checking
Enable strict mode in tsconfig.json.
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true
}
}
2. Path Aliases
Use path aliases for cleaner imports.
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/utils/*": ["./src/utils/*"]
}
}
}
// Usage
import { Button } from '@/components/Button'
import { formatDate } from '@/utils/date'
3. ESLint Rules
Configure ESLint for TypeScript.
// .eslintrc.json
{
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking"
],
"rules": {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/explicit-function-return-type": "warn",
"@typescript-eslint/no-unused-vars": "error"
}
}
Common Pitfalls and Solutions
1. Type Assertions
Avoid type assertions when possible. Use type guards instead.
// ❌ Bad: Type assertion
const user = data as User
// ✅ Good: Type guard
function isUser(data: unknown): data is User {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'name' in data
)
}
if (isUser(data)) {
// TypeScript knows data is User
console.log(data.name)
}
2. Optional Chaining and Nullish Coalescing
Use optional chaining and nullish coalescing for safer code.
// ❌ Bad: Manual null checks
const name = user && user.profile && user.profile.name
// ✅ Good: Optional chaining
const name = user?.profile?.name
// ✅ Good: Nullish coalescing
const displayName = user?.name ?? 'Anonymous'
3. Array Methods
Type array methods properly.
// ✅ Good: Typed array methods
const numbers: number[] = [1, 2, 3, 4, 5]
const doubled = numbers.map((n: number) => n * 2) // Type: number[]
const evens = numbers.filter((n: number): n is number => n % 2 === 0) // Type: number[]
// ✅ Good: Type predicate for filtering
function isString(value: unknown): value is string {
return typeof value === 'string'
}
const values: (string | number)[] = ['hello', 42, 'world', 100]
const strings = values.filter(isString) // Type: string[]
Best Practices Summary
- Avoid
any: Use proper types orunknownwhen necessary - Use type inference: Let TypeScript infer types when possible
- Leverage utility types: Use built-in utility types for common patterns
- Enable strict mode: Catch more errors at compile time
- Type everything: Functions, components, hooks, events
- Use type guards: Prefer type guards over type assertions
- Document complex types: Add JSDoc comments for complex types
- Keep types close: Define types near where they're used
- Use generics: Create reusable, type-safe functions
- Test types: Use tools like
tsdto test types
Conclusion
Writing good TypeScript code requires understanding type safety fundamentals, leveraging advanced patterns, and following best practices. By applying these techniques, you'll write more maintainable, type-safe, and scalable code.
Remember: TypeScript is a tool to help you write better code. Use it to catch errors early, improve code documentation, and enable better IDE support.
Resources: