Back to blog

TypeScript Best Practices for Clean Code

TypeScriptJavaScriptBest Practices

TypeScript has revolutionized how we write JavaScript applications. But just using TypeScript isn't enough—you need to use it effectively. Here are some best practices I've learned over the years.

Use Strict Mode

Always enable strict mode in your tsconfig.json:

{
  "compilerOptions": {
    "strict": true
  }
}

This enables several important checks:

  • strictNullChecks - No more undefined is not an object errors
  • strictFunctionTypes - Better function type checking
  • strictPropertyInitialization - Ensures class properties are initialized

Prefer Interfaces for Object Types

When defining object shapes, prefer interfaces over type aliases:

// Prefer this
interface User {
  id: string
  name: string
  email: string
}
 
// Over this
type User = {
  id: string
  name: string
  email: string
}

Interfaces can be extended and merged, making them more flexible for library authors and large codebases.

Use Discriminated Unions

Discriminated unions are perfect for representing states:

type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error }
 
function handleState<T>(state: RequestState<T>) {
  switch (state.status) {
    case 'idle':
      return 'Ready to fetch'
    case 'loading':
      return 'Loading...'
    case 'success':
      return `Data: ${state.data}` // TypeScript knows data exists here
    case 'error':
      return `Error: ${state.error.message}` // And error exists here
  }
}

Avoid any, Use unknown

When you don't know the type, use unknown instead of any:

// Bad - any disables type checking
function processData(data: any) {
  return data.foo.bar // No error, but might crash at runtime
}
 
// Good - unknown requires type checking
function processData(data: unknown) {
  if (typeof data === 'object' && data !== null && 'foo' in data) {
    // Now we can safely access properties
  }
}

Use Utility Types

TypeScript provides powerful utility types:

interface User {
  id: string
  name: string
  email: string
  password: string
}
 
// Make all properties optional
type PartialUser = Partial<User>
 
// Make all properties required
type RequiredUser = Required<User>
 
// Pick specific properties
type UserCredentials = Pick<User, 'email' | 'password'>
 
// Omit specific properties
type PublicUser = Omit<User, 'password'>
 
// Make properties readonly
type ReadonlyUser = Readonly<User>

Generic Constraints

Use constraints to make generics more useful:

// Without constraint - too permissive
function getProperty<T>(obj: T, key: string) {
  return obj[key] // Error: can't index T with string
}
 
// With constraint - type-safe
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key] // Works!
}
 
const user = { name: 'John', age: 30 }
getProperty(user, 'name') // Returns string
getProperty(user, 'age')  // Returns number
getProperty(user, 'foo')  // Error: 'foo' is not a key of user

Conclusion

These practices will help you write safer, more maintainable TypeScript code. The key is to leverage the type system rather than fighting against it. Let TypeScript catch bugs at compile time so you can focus on building features.

What TypeScript patterns do you find most useful? Let me know!