TypeScript Best Practices for Clean Code
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 moreundefined is not an objecterrorsstrictFunctionTypes- Better function type checkingstrictPropertyInitialization- 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 userConclusion
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!