Writing TypeScript That Makes Product Code Easier to Maintain
Dive deep into advanced TypeScript patterns and techniques that will help you write more maintainable, type safe code in your projects.
Updated October 22, 2026

TypeScript does not replace tests or runtime validation, but it makes invalid states harder to represent and refactors much safer. These are the patterns I reach for when they make a boundary clearer, not when they only make a type cleverer.
Understanding Type Inference
TypeScript's type inference is powerful, but understanding when and how to use it effectively is crucial:
// Let TypeScript infer simple types
const name = "Dheeraj" // inferred as the literal type "Dheeraj"
let age = 25 // inferred as number
// Be explicit with complex types
interface User {
id: string
name: string
email: string
}
const user: User = {
id: "123",
name: "Dheeraj",
email: "dheeraj@example.com"
}
Generics: Writing Reusable Code
Generics allow you to write flexible, reusable functions and classes:
// Generic function
function first<T>(arr: T[]): T | undefined {
return arr[0]
}
const numbers = [1, 2, 3]
const firstNumber = first(numbers) // number | undefined
const strings = ["a", "b", "c"]
const firstString = first(strings) // string | undefined
Advanced Generic Patterns
// Conditional types with generics
type Defined<T> = T extends null | undefined ? never : T
type Result = Defined<string | null> // string
// Generic constraints
interface HasId {
id: string
}
function findById<T extends HasId>(items: T[], id: string): T | undefined {
return items.find(item => item.id === id)
}
Utility Types for Common Patterns
TypeScript provides built in utility types that solve common problems:
Partial and Required
Partial and Required transform the immediate properties of a type. They do not recursively change nested objects or validate a network payload. Use them when the resulting shape matches the operation you are modelling.
interface Todo {
title: string
description: string
completed: boolean
}
// Make all properties optional
type PartialTodo = Partial<Todo>
// Make all properties required
type RequiredTodo = Required<PartialTodo>
Pick and Omit
An update input should expose only the fields a caller can change. Pick is useful for that boundary; Omit can describe a different view of an existing type. Neither transformation removes fields from an object at runtime.
// Pick specific properties
type TodoPreview = Pick<Todo, 'title' | 'completed'>
// Omit specific properties
type TodoWithoutDescription = Omit<Todo, 'description'>
Record for Object Types
type UserRoles = 'admin' | 'user' | 'guest'
type RolePermissions = Record<UserRoles, string[]>
const permissions: RolePermissions = {
admin: ['read', 'write', 'delete'],
user: ['read', 'write'],
guest: ['read']
}
Type Guards for Runtime Safety
Type guards help you narrow types at runtime:
function isString(value: unknown): value is string {
return typeof value === 'string'
}
function processValue(value: string | number) {
if (isString(value)) {
// TypeScript knows value is string here
console.log(value.toUpperCase())
} else {
// TypeScript knows value is number here
console.log(value.toFixed(2))
}
}
Discriminated Unions
Represent mutually exclusive states with a discriminated union. A successful result has data, while an error state has an error; consumers narrow on status before reading those fields.
type LoadingState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: { id: string; title: string } }
| { status: 'error'; error: Error }
function handleState(state: LoadingState) {
switch (state.status) {
case 'idle':
return 'Not started'
case 'loading':
return 'Loading...'
case 'success':
return `Data: ${state.data.title}`
case 'error':
return `Error: ${state.error.message}`
}
}
Template Literal Types
Create powerful string manipulation types:
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type Endpoint = '/users' | '/posts' | '/comments'
type APIRoute = `${HTTPMethod} ${Endpoint}`
// Valid routes:
const route1: APIRoute = 'GET /users'
const route2: APIRoute = 'POST /posts'
This example permits every combination of the method and endpoint unions. If only certain combinations are valid, model that narrower contract explicitly. Template literal types help describe strings at compile time; the API still needs runtime validation.
Validate untrusted data before narrowing it
Use unknown at an external boundary and perform checks before treating a value as application data. An assertion such as payload as User does not check the response.
type OrderSummary = { id: string; title: string }
function isOrderSummary(value: unknown): value is OrderSummary {
return typeof value === 'object' && value !== null
&& 'id' in value && typeof value.id === 'string'
&& 'title' in value && typeof value.title === 'string'
}
For larger or nested inputs, a schema validator can keep the validation rules easier to maintain. The goal is to hand the application a value that has actually passed the checks represented by its type.
Best Practices
- Use strict mode - Enable strict type checking in tsconfig.json
- Avoid
any- Useunknownwhen you don't know the type - Leverage type inference - Don't over annotate simple types
- Create reusable types - Use interfaces and type aliases
- Document complex types - Add comments for clarity
The TypeScript handbook explains inference, unions, and assertions. Keep the types close to the business rules they express, and prefer the simplest model that makes an invalid state difficult to represent.