TypeScript Best Practices for 2025
In the decade since I started using TypeScript, it has evolved from a niche Microsoft project to the industry standard for building robust JavaScript applications. Working on enterprise codebases like Catalyst PSA (320K+ lines) and WellOS, I've learned that TypeScript's value goes far beyond catching typos—it's about encoding business logic and domain knowledge directly into your type system.
Here are the patterns and practices that have proven most valuable across years of production TypeScript development.
Enable Strict Mode (No Exceptions)
This isn't optional. Strict mode is the foundation of TypeScript's type safety guarantees.
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true
}
}These settings enable:
- strict: All strict type-checking options
- noUncheckedIndexedAccess: Array/object index access returns
T | undefined - noImplicitOverride: Requires explicit
overridekeyword - exactOptionalPropertyTypes: Prevents assigning
undefinedto optional properties
When I inherited codebases without strict mode, the first step was always enabling it. Yes, it creates hundreds of errors initially, but fixing them reveals real bugs.
Avoid any Like the Plague
Every any is a hole in your type safety. Here's what to use instead:
Use unknown for Truly Unknown Data
// Bad: any defeats type checking
function parseJSON(json: string): any {
return JSON.parse(json)
}
const data = parseJSON('{"name": "Jason"}')
console.log(data.name.toUpperCase()) // No type checking - runtime error if structure is wrong
// Good: unknown requires type checking
function parseJSON(json: string): unknown {
return JSON.parse(json)
}
const data = parseJSON('{"name": "Jason"}')
// TypeScript forces you to validate
if (isUserData(data)) {
console.log(data.name.toUpperCase()) // Safe!
}
// Type guard
function isUserData(data: unknown): data is { name: string } {
return (
typeof data === 'object' &&
data !== null &&
'name' in data &&
typeof data.name === 'string'
)
}Use Generics for Reusable Code
// Generic API response handler
interface APIResponse<T> {
data: T
status: number
message: string
}
async function fetchAPI<T>(url: string): Promise<APIResponse<T>> {
const response = await fetch(url)
return response.json()
}
// Type-safe usage
interface User {
id: string
name: string
email: string
}
const userResponse = await fetchAPI<User>('/api/user/123')
// userResponse.data is typed as User
console.log(userResponse.data.name)Advanced Type Patterns
Discriminated Unions for State Management
This pattern has saved me countless hours debugging state-related bugs:
// Bad: Optional properties lead to invalid states
interface LoadingState {
status: 'loading' | 'success' | 'error'
data?: User
error?: Error
}
// This is a valid state, but makes no sense!
const badState: LoadingState = {
status: 'loading',
data: user,
error: new Error()
}
// Good: Discriminated union makes invalid states unrepresentable
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; error: Error }
function handleState(state: RequestState) {
switch (state.status) {
case 'idle':
return <div>Click to load</div>
case 'loading':
return <Spinner />
case 'success':
// TypeScript knows state.data exists here
return <UserProfile user={state.data} />
case 'error':
// TypeScript knows state.error exists here
return <ErrorMessage error={state.error} />
}
}In Catalyst PSA, we use this pattern extensively for async operations, form states, and API calls. It eliminates entire classes of bugs.
Conditional Types for Advanced Patterns
// Extract the return type of a promise
type Awaited<T> = T extends Promise<infer U> ? U : T
type UserPromise = Promise<User>
type UserType = Awaited<UserPromise> // User
// Make specific properties required
type RequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>
interface PartialUser {
id?: string
name?: string
email?: string
}
// Require id and email
type ValidUser = RequireFields<PartialUser, 'id' | 'email'>
// Result: { id: string; email: string; name?: string }
// Extract function parameters
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never
function createUser(name: string, email: string, age: number) {
// ...
}
type CreateUserParams = Parameters<typeof createUser>
// [string, string, number]Mapped Types for Transformations
// Make all properties readonly
type Readonly<T> = {
readonly [P in keyof T]: T[P]
}
// Make all properties optional
type Partial<T> = {
[P in keyof T]?: T[P]
}
// Create a type with only specific properties
type Pick<T, K extends keyof T> = {
[P in K]: T[P]
}
// Custom: Create update types (all fields optional except id)
type UpdateType<T extends { id: string }> = Partial<T> & { id: string }
interface Project {
id: string
title: string
description: string
startDate: Date
}
type ProjectUpdate = UpdateType<Project>
// Result: { id: string; title?: string; description?: string; startDate?: Date }
// Real usage from Catalyst PSA
async function updateProject(update: ProjectUpdate) {
// id is required, everything else is optional
const { id, ...changes } = update
return await db.projects.update(id, changes)
}Domain Modeling with Types
TypeScript shines when you model your business domain:
// Instead of primitive obsession
type UserId = string
type ProjectId = string
type Timestamp = number
// Create branded types for type safety
type Brand<K, T> = K & { __brand: T }
type UserId = Brand<string, 'UserId'>
type ProjectId = Brand<string, 'ProjectId'>
function createUserId(id: string): UserId {
return id as UserId
}
function createProjectId(id: string): ProjectId {
return id as ProjectId
}
// Now these can't be mixed up
function assignUserToProject(userId: UserId, projectId: ProjectId) {
// ...
}
const userId = createUserId('user-123')
const projectId = createProjectId('proj-456')
assignUserToProject(userId, projectId) // OK
assignUserToProject(projectId, userId) // Type error!
// Model business rules with types
type EmailAddress = Brand<string, 'EmailAddress'>
function createEmail(email: string): EmailAddress | null {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email) ? (email as EmailAddress) : null
}
// Email is guaranteed to be valid
function sendEmail(to: EmailAddress, subject: string, body: string) {
// ...
}Type-Safe API Calls
One of the most valuable patterns from enterprise work:
// Define API schema
interface APISchema {
'/users': {
GET: {
response: User[]
}
POST: {
request: { name: string; email: string }
response: User
}
}
'/users/:id': {
GET: {
response: User
}
PUT: {
request: Partial<User>
response: User
}
DELETE: {
response: { success: boolean }
}
}
'/projects': {
GET: {
params: { status?: 'active' | 'completed' }
response: Project[]
}
}
}
// Type-safe API client
class APIClient {
async get<
Path extends keyof APISchema,
Method extends keyof APISchema[Path] = 'GET'
>(
path: Path,
...args: 'params' extends keyof APISchema[Path][Method]
? [params: APISchema[Path][Method]['params']]
: []
): Promise<APISchema[Path][Method]['response']> {
const [params] = args
const url = new URL(path as string, this.baseURL)
if (params) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, String(value))
})
}
const response = await fetch(url.toString())
return response.json()
}
async post<
Path extends keyof APISchema,
Method extends 'POST' = 'POST'
>(
path: Path,
data: APISchema[Path][Method]['request']
): Promise<APISchema[Path][Method]['response']> {
const response = await fetch(path as string, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
return response.json()
}
}
// Usage is completely type-safe
const api = new APIClient()
// TypeScript knows the return type
const users = await api.get('/users') // User[]
// TypeScript enforces correct request body
const newUser = await api.post('/users', {
name: 'Jason',
email: 'jason@example.com'
}) // User
// TypeScript requires params when needed
const activeProjects = await api.get('/projects', {
status: 'active'
}) // Project[]
// TypeScript catches errors
await api.get('/users', { invalid: true }) // Type error!TypeScript in React: Common Patterns and Pitfalls
Props with Generics
// Generic list component
interface ListProps<T> {
items: T[]
renderItem: (item: T) => React.ReactNode
keyExtractor: (item: T) => string
emptyMessage?: string
}
function List<T>({ items, renderItem, keyExtractor, emptyMessage }: ListProps<T>) {
if (items.length === 0) {
return <div>{emptyMessage ?? 'No items'}</div>
}
return (
<div>
{items.map(item => (
<div key={keyExtractor(item)}>
{renderItem(item)}
</div>
))}
</div>
)
}
// Usage
<List
items={projects}
renderItem={project => <ProjectCard project={project} />}
keyExtractor={project => project.id}
/>Event Handlers
// Don't use any for event types
function handleChange(e: any) { // Bad
console.log(e.target.value)
}
// Use specific event types
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
console.log(e.target.value)
}
function handleFormSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
// ...
}
function handleButtonClick(e: React.MouseEvent<HTMLButtonElement>) {
// ...
}
// For ref callbacks
function handleRef(node: HTMLDivElement | null) {
if (node) {
// node is HTMLDivElement
}
}Children Types
// Use React.ReactNode for children
interface CardProps {
children: React.ReactNode // Accepts anything renderable
title: string
}
// Use React.ReactElement when you need a specific component
interface TabsProps {
children: React.ReactElement<TabProps> | React.ReactElement<TabProps>[]
}
// Use function type for render props
interface RenderPropProps<T> {
data: T
children: (data: T) => React.ReactNode
}Utility Types in Practice
TypeScript's built-in utility types are incredibly powerful:
interface User {
id: string
name: string
email: string
age: number
role: 'admin' | 'user'
}
// Pick - Select specific properties
type UserPreview = Pick<User, 'id' | 'name'>
// { id: string; name: string }
// Omit - Exclude specific properties
type UserWithoutEmail = Omit<User, 'email'>
// { id: string; name: string; age: number; role: 'admin' | 'user' }
// Partial - Make all properties optional
type PartialUser = Partial<User>
// { id?: string; name?: string; email?: string; age?: number; role?: 'admin' | 'user' }
// Required - Make all properties required
type RequiredUser = Required<PartialUser>
// Back to original User type
// Record - Create object type with specific keys
type UserRoles = Record<'admin' | 'user' | 'guest', string[]>
// { admin: string[]; user: string[]; guest: string[] }
// ReturnType - Extract return type
function getUser() {
return { id: '1', name: 'Jason', email: 'jason@example.com' }
}
type UserType = ReturnType<typeof getUser>
// { id: string; name: string; email: string }
// Awaited - Unwrap Promise type
type AsyncUser = Awaited<Promise<User>>
// UserType Inference Strategies
Let TypeScript do the work when possible:
// Let TypeScript infer simple types
const name = 'Jason' // string
const age = 38 // number
const active = true // boolean
// Let TypeScript infer array types
const numbers = [1, 2, 3] // number[]
const mixed = [1, 'two', true] // (string | number | boolean)[]
// Let TypeScript infer return types
function getFullName(first: string, last: string) {
return `${first} ${last}` // Returns string (inferred)
}
// Use const assertions for literal types
const config = {
api: 'https://api.example.com',
timeout: 5000
} as const
// config.api is 'https://api.example.com', not string
// config is readonly
// Infer generic types from usage
function identity<T>(value: T): T {
return value
}
const num = identity(42) // T inferred as number
const str = identity('hello') // T inferred as string
// Type parameter inference with constraints
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
const user = { id: '1', name: 'Jason' }
const name = getProperty(user, 'name') // string (inferred)
const id = getProperty(user, 'id') // string (inferred)Common TypeScript Pitfalls and Solutions
Pitfall 1: Index Signature Gotchas
// Problem: Object.keys returns string[], not (keyof T)[]
function printValues<T extends object>(obj: T) {
Object.keys(obj).forEach(key => {
// Type error: key is string, not keyof T
console.log(obj[key])
})
}
// Solution: Type assertion with runtime safety
function printValues<T extends object>(obj: T) {
(Object.keys(obj) as Array<keyof T>).forEach(key => {
console.log(obj[key]) // OK
})
}
// Better: Use a helper
function typedKeys<T extends object>(obj: T): Array<keyof T> {
return Object.keys(obj) as Array<keyof T>
}Pitfall 2: Async Function Return Types
// Problem: Forgetting async functions always return Promise
async function getUser(id: string): User { // Type error!
return await fetchUser(id)
}
// Solution: Return Promise<T>
async function getUser(id: string): Promise<User> {
return await fetchUser(id)
}
// Or let TypeScript infer it
async function getUser(id: string) {
return await fetchUser(id) // Promise<User> inferred
}Pitfall 3: Type Guards with typeof
// Problem: typeof doesn't work for null
function processValue(value: string | null) {
if (typeof value === 'string') {
// value is string, but also could be null!
}
}
// Solution: Check for null explicitly
function processValue(value: string | null) {
if (value !== null && typeof value === 'string') {
// Now value is definitely string
}
}
// Or use truthiness for simpler cases
function processValue(value: string | null) {
if (value) {
// value is string (null is falsy)
}
}Real-World Example: Type-Safe Form Builder
Here's a pattern I use extensively in enterprise applications:
// Define form schema with types
interface FormSchema {
username: string
email: string
age: number
newsletter: boolean
}
// Form field configuration
type FieldConfig<T> = {
[K in keyof T]: {
label: string
type: 'text' | 'email' | 'number' | 'checkbox'
required: boolean
validate?: (value: T[K]) => string | undefined
}
}
// Type-safe form builder
class FormBuilder<T extends Record<string, any>> {
constructor(private config: FieldConfig<T>) {}
validate(values: T): Partial<Record<keyof T, string>> {
const errors: Partial<Record<keyof T, string>> = {}
for (const key in this.config) {
const field = this.config[key]
const value = values[key]
if (field.required && !value) {
errors[key] = `${field.label} is required`
}
if (field.validate) {
const error = field.validate(value)
if (error) errors[key] = error
}
}
return errors
}
getInitialValues(): T {
const values = {} as T
for (const key in this.config) {
const field = this.config[key]
values[key] = field.type === 'checkbox' ? false : '' as any
}
return values
}
}
// Usage
const userForm = new FormBuilder<FormSchema>({
username: {
label: 'Username',
type: 'text',
required: true,
validate: (value) => value.length < 3 ? 'Too short' : undefined
},
email: {
label: 'Email',
type: 'email',
required: true,
validate: (value) => !value.includes('@') ? 'Invalid email' : undefined
},
age: {
label: 'Age',
type: 'number',
required: false,
validate: (value) => value < 18 ? 'Must be 18+' : undefined
},
newsletter: {
label: 'Subscribe to newsletter',
type: 'checkbox',
required: false,
}
})
const errors = userForm.validate({
username: 'jc',
email: 'invalid',
age: 16,
newsletter: false
})
// errors is type-safe: Partial<Record<keyof FormSchema, string>>Conclusion
TypeScript's true power emerges when you move beyond basic type annotations and start encoding your domain knowledge, business rules, and invariants directly into the type system. After years of building enterprise applications, I've found that investing time in proper type design pays dividends in reduced bugs, better developer experience, and more maintainable code.
Key takeaways:
- Enable strict mode always - It's the foundation of type safety
- Avoid
any- Useunknown, generics, or proper types instead - Use discriminated unions - Make invalid states unrepresentable
- Model your domain - Branded types and type constraints encode business rules
- Leverage type inference - Let TypeScript do the work when possible
- Use utility types - They're built-in for a reason
- Type your APIs - End-to-end type safety prevents entire classes of bugs
After 27 years of development and a decade with TypeScript, I can confidently say it's one of the most impactful tools for building maintainable JavaScript applications. These patterns have proven themselves across hundreds of thousands of lines of production code—use them wisely, and your codebase will thank you.

Jason Cochran
Sofware Engineer | Cloud Consultant | Founder at Strataga
27 years of experience building enterprise software for oil & gas operators and startups. Specializing in SCADA systems, field data solutions, and AI-powered rapid development. Based in Midland, TX serving the Permian Basin.