
TypeScript Best Practices for 2025
TypeScript continues to evolve and improve. Here are the modern patterns and best practices that will make your TypeScript code more maintainable, type-safe, and enjoyable to work with in 2025.
Always Use Strict Mode
Enable all strict type-checking options in your tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
}
}
These options catch many common errors at compile time:
// ❌ Without strict mode, this compiles but fails at runtime
function greet(name: string | null) {
console.log(name.toUpperCase()) // Runtime error if name is null
}
// ✅ With strict mode, TypeScript catches this
function greet(name: string | null) {
if (name) {
console.log(name.toUpperCase()) // Safe!
}
}
Leverage Type Inference
Let TypeScript infer types when possible - it's often smarter than manual annotations:
// ❌ Redundant type annotation
const user: { name: string; age: number } = {
name: 'John',
age: 30
}
// ✅ TypeScript infers the type correctly
const user = {
name: 'John',
age: 30
}
However, do annotate function return types for better error messages:
// ✅ Explicit return type catches errors
function getUser(id: string): User {
// TypeScript will error if you return the wrong type
return database.users.find(u => u.id === id)
}
Use Discriminated Unions for State
Model state machines and variants with discriminated unions:
type LoadingState = { status: 'loading' }
type SuccessState = { status: 'success'; data: User }
type ErrorState = { status: 'error'; error: Error }
type State = LoadingState | SuccessState | ErrorState
function render(state: State) {
switch (state.status) {
case 'loading':
return <Spinner />
case 'success':
return <UserProfile user={state.data} />
case 'error':
return <ErrorMessage error={state.error} />
}
}
TypeScript narrows the type in each branch, giving you autocomplete and type safety.
Embrace Utility Types
TypeScript provides powerful built-in utility types:
Partial and Required
interface User {
id: string
name: string
email: string
}
// All properties optional
function updateUser(id: string, updates: Partial<User>) {
// ...
}
// All properties required
function createUser(data: Required<User>) {
// ...
}
Pick and Omit
// Select specific properties
type UserPreview = Pick<User, 'id' | 'name'>
// Exclude specific properties
type UserWithoutEmail = Omit<User, 'email'>
Record
// Create an object type with specific keys
type UserRoles = Record<string, 'admin' | 'user' | 'guest'>
const roles: UserRoles = {
'user-1': 'admin',
'user-2': 'user'
}
Avoid any - Use unknown Instead
The any type disables type checking. Use unknown for truly unknown types:
// ❌ Unsafe - no type checking
function processData(data: any) {
return data.value.toUpperCase() // Might crash
}
// ✅ Safe - requires type checking
function processData(data: unknown) {
if (typeof data === 'object' && data !== null && 'value' in data) {
const value = (data as { value: unknown }).value
if (typeof value === 'string') {
return value.toUpperCase()
}
}
throw new Error('Invalid data')
}
Use Type Guards
Create custom type guards for complex type checking:
interface Cat {
type: 'cat'
meow: () => void
}
interface Dog {
type: 'dog'
bark: () => void
}
type Animal = Cat | Dog
// Type guard function
function isCat(animal: Animal): animal is Cat {
return animal.type === 'cat'
}
function handleAnimal(animal: Animal) {
if (isCat(animal)) {
animal.meow() // TypeScript knows it's a Cat
} else {
animal.bark() // TypeScript knows it's a Dog
}
}
Prefer const Assertions
Use as const for literal types:
// Without const assertion
const colors = ['red', 'blue', 'green'] // Type: string[]
// With const assertion
const colors = ['red', 'blue', 'green'] as const
// Type: readonly ["red", "blue", "green"]
// Now you can use it as a union type
type Color = typeof colors[number] // "red" | "blue" | "green"
Template Literal Types
Create precise string types:
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type Endpoint = '/users' | '/posts' | '/comments'
// Combine them
type Route = `${HTTPMethod} ${Endpoint}`
// Result: "GET /users" | "GET /posts" | "POST /users" | ...
function handleRoute(route: Route) {
// TypeScript knows exactly which strings are valid
}
Branded Types for Stronger Type Safety
Create "branded" types to prevent mixing similar types:
type UserId = string & { readonly brand: unique symbol }
type PostId = string & { readonly brand: unique symbol }
function createUserId(id: string): UserId {
return id as UserId
}
function createPostId(id: string): PostId {
return id as PostId
}
function getUser(id: UserId) { /* ... */ }
function getPost(id: PostId) { /* ... */ }
const userId = createUserId('123')
const postId = createPostId('456')
getUser(userId) // ✅ Works
getUser(postId) // ❌ Type error - can't mix IDs
Conditional Types
Create types that depend on conditions:
type IsString<T> = T extends string ? true : false
type A = IsString<'hello'> // true
type B = IsString<42> // false
// Practical example: extract promise type
type Awaited<T> = T extends Promise<infer U> ? U : T
type A = Awaited<Promise<string>> // string
type B = Awaited<number> // number
Mapped Types
Transform existing types:
interface User {
id: string
name: string
email: string
}
// Make all properties nullable
type NullableUser = {
[K in keyof User]: User[K] | null
}
// Make all properties functions that return their type
type UserGetters = {
[K in keyof User as `get${Capitalize<K>}`]: () => User[K]
}
// Result: { getId: () => string; getName: () => string; ... }
Organize Types Well
Co-locate types with their usage
// user.service.ts
export interface User {
id: string
name: string
}
export async function getUser(id: string): Promise<User> {
// ...
}
Use index files for shared types
// types/index.ts
export type { User, UserRole } from './user'
export type { Post, PostStatus } from './post'
export type { Comment } from './comment'
Generic Constraints
Make generics more specific with constraints:
// ❌ Too broad
function getValue<T>(obj: T, key: string) {
return obj[key] // Error: can't index T with string
}
// ✅ Constrained generic
function getValue<T extends object, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key] // Type-safe!
}
const user = { name: 'John', age: 30 }
const name = getValue(user, 'name') // Type: string
const age = getValue(user, 'age') // Type: number
Readonly and Immutability
Use readonly to prevent mutations:
interface Config {
readonly apiUrl: string
readonly timeout: number
}
// Can't modify after creation
const config: Config = {
apiUrl: 'https://api.example.com',
timeout: 5000
}
config.timeout = 10000 // ❌ Error: Cannot assign to 'timeout'
For deep immutability:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]
}
interface User {
name: string
address: {
street: string
city: string
}
}
type ImmutableUser = DeepReadonly<User>
Error Handling with Types
Model errors explicitly:
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E }
async function fetchUser(id: string): Promise<Result<User>> {
try {
const response = await fetch(`/api/users/${id}`)
const data = await response.json()
return { success: true, data }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error('Unknown error')
}
}
}
// Usage
const result = await fetchUser('123')
if (result.success) {
console.log(result.data.name) // Type-safe access
} else {
console.error(result.error.message) // Type-safe error handling
}
Conclusion
These TypeScript best practices will help you write more maintainable, type-safe code. The key is to leverage TypeScript's powerful type system rather than fighting against it.
Start with strict mode, use type inference wisely, avoid any, and gradually adopt more advanced patterns as you need them. TypeScript is a tool that gets better as you learn to use its features effectively. Happy typing!