TypeScript has evolved significantly. Here are the patterns and practices I use in production projects to write better, more maintainable code.
1. Strict Mode is Non-Negotiable
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true
}
}2. Use Type Inference
Let TypeScript infer types when possible:
// ❌ Redundant
const name: string = "John";
// ✅ Better
const name = "John";
// ❌ Redundant
function add(a: number, b: number): number {
return a + b;
}
// ✅ Better (return type inferred)
function add(a: number, b: number) {
return a + b;
}3. Discriminated Unions
Perfect for state management and API responses:
type ApiResponse<T> =
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
function handleResponse<T>(response: ApiResponse<T>) {
switch (response.status) {
case 'loading':
return <Spinner />;
case 'success':
return <Data data={response.data} />;
case 'error':
return <Error message={response.error} />;
}
}4. Utility Types
Master the built-in utility types:
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
// Pick specific properties
type UserPreview = Pick<User, 'id' | 'name'>;
// Omit properties
type UserWithoutEmail = Omit<User, 'email'>;
// Make all properties optional
type PartialUser = Partial<User>;
// Make all properties required
type RequiredUser = Required<User>;
// Make all properties readonly
type ReadonlyUser = Readonly<User>;5. Generic Constraints
// Constrain to objects with id property
function findById<T extends { id: string }>(
items: T[],
id: string
): T | undefined {
return items.find(item => item.id === id);
}
// Constrain to specific keys
function getProperty<T, K extends keyof T>(
obj: T,
key: K
): T[K] {
return obj[key];
}6. Template Literal Types
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Endpoint = '/users' | '/posts' | '/comments';
// Generate all combinations
type ApiRoute = `${HttpMethod} ${Endpoint}`;
// Result: 'GET /users' | 'POST /users' | ... (12 combinations)
// CSS properties
type CSSProperty = `${'margin' | 'padding'}-${'top' | 'bottom' | 'left' | 'right'}`;
// Result: 'margin-top' | 'margin-bottom' | ...7. Type Guards
// User-defined type guard
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj &&
'name' in obj
);
}
// Usage
function processData(data: unknown) {
if (isUser(data)) {
// TypeScript knows data is User here
console.log(data.name);
}
}8. Const Assertions
// Without const assertion
const colors = ['red', 'green', 'blue'];
// Type: string[]
// With const assertion
const colors = ['red', 'green', 'blue'] as const;
// Type: readonly ['red', 'green', 'blue']
// Object const assertion
const config = {
api: 'https://api.example.com',
timeout: 5000
} as const;
// All properties become readonly9. Branded Types
Create distinct types from primitives:
type UserId = string & { __brand: 'UserId' };
type PostId = string & { __brand: 'PostId' };
function getUser(id: UserId) { /* ... */ }
function getPost(id: PostId) { /* ... */ }
const userId = '123' as UserId;
const postId = '456' as PostId;
getUser(userId); // ✅ OK
getUser(postId); // ❌ Error: Type 'PostId' is not assignable to 'UserId'10. Avoid 'any'
Use 'unknown' instead:
// ❌ Bad
function process(data: any) {
return data.value; // No type checking
}
// ✅ Good
function process(data: unknown) {
if (typeof data === 'object' && data !== null && 'value' in data) {
return (data as { value: string }).value;
}
throw new Error('Invalid data');
}Conclusion
These patterns have saved me countless hours of debugging and made my code more maintainable. TypeScript's type system is powerful—use it to your advantage!
