TypeScript Best Practices for Better Code Quality
TypeScript has become an essential tool for modern web development. After using it extensively in various projects, I’ve compiled the best practices that have significantly improved my code quality and developer experience.
1. Strict Type Configuration
Start with strict TypeScript configuration from day one:
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true
}
}
2. Use Union Types Effectively
Union types are powerful for creating flexible yet type-safe code:
// Instead of any
type Status = 'loading' | 'success' | 'error';
interface ApiResponse<T> {
status: Status;
data?: T;
error?: string;
}
// Usage
const handleResponse = (response: ApiResponse<User>) => {
switch (response.status) {
case 'loading':
return <Spinner />;
case 'success':
return <UserCard user={response.data} />;
case 'error':
return <ErrorMessage message={response.error} />;
}
};
3. Leverage Utility Types
TypeScript’s built-in utility types can save you a lot of time:
// Pick specific properties
type UserSummary = Pick<User, 'id' | 'name' | 'email'>;
// Omit sensitive data
type PublicUser = Omit<User, 'password' | 'internalNotes'>;
// Make all properties optional
type PartialUser = Partial<User>;
// Make all properties required
type RequiredUser = Required<Partial<User>>;
// Extract specific types
type UserId = Extract<User, { id: string }>['id'];
// Exclude specific types
type NonAdminUser = Exclude<User, AdminUser>;
4. Create Reusable Generic Types
Generics make your code more flexible and reusable:
// Generic API response wrapper
interface ApiResponse<T> {
data: T;
message: string;
success: boolean;
}
// Generic pagination
interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
// Usage
const usersResponse: ApiResponse<PaginatedResponse<User>> = await fetchUsers();
5. Use Discriminated Unions
Perfect for handling different states or variants:
// Loading states
type LoadingState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string };
// Component props variants
type ButtonProps =
| { variant: 'primary'; onClick: () => void }
| { variant: 'secondary'; href: string }
| { variant: 'danger'; onConfirm: () => void };
6. Implement Type Guards
Type guards help with runtime type checking:
// Type guard functions
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj &&
'name' in obj &&
'email' in obj
);
}
// Usage
const processData = (data: unknown) => {
if (isUser(data)) {
// TypeScript knows data is User here
console.log(data.name);
}
};
7. Use Const Assertions
Const assertions help create more precise types:
// Without const assertion
const colors = ['red', 'green', 'blue']; // string[]
// With const assertion
const colors = ['red', 'green', 'blue'] as const; // readonly ['red', 'green', 'blue']
// Type from const assertion
type Color = typeof colors[number]; // 'red' | 'green' | 'blue'
// Object const assertion
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3,
} as const;
type Config = typeof config; // Readonly object with literal types
8. Create Branded Types
Branded types help prevent mixing up similar types:
// Branded types
type UserId = string & { readonly brand: unique symbol };
type ProductId = string & { readonly brand: unique symbol };
// Factory functions
const createUserId = (id: string): UserId => id as UserId;
const createProductId = (id: string): ProductId => id as ProductId;
// Usage
const userId = createUserId('123');
const productId = createProductId('456');
// This will cause a TypeScript error
const getUser = (id: ProductId) => { /* ... */ };
getUser(userId); // Error: ProductId is not assignable to UserId
9. Use Template Literal Types
Template literal types enable powerful string manipulation:
// API endpoint types
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiEndpoint = `/api/${string}`;
type ApiRoute = `${HttpMethod} ${ApiEndpoint}`;
// CSS property types
type CssProperty = `--${string}`;
type CssValue = `${number}px` | `${number}%` | `${number}rem`;
// Event handler types
type EventHandler<T extends string> = `on${Capitalize<T>}`;
type ClickHandler = EventHandler<'click'>; // 'onClick'
10. Implement Proper Error Handling
Create type-safe error handling:
// Result type for error handling
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
// Async result wrapper
async function safeAsync<T>(
promise: Promise<T>
): Promise<Result<T>> {
try {
const data = await promise;
return { success: true, data };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error(String(error))
};
}
}
// Usage
const result = await safeAsync(fetchUserData());
if (result.success) {
console.log(result.data); // TypeScript knows this is T
} else {
console.error(result.error); // TypeScript knows this is Error
}
11. Use Mapped Types
Mapped types are powerful for transforming existing types:
// Make all properties optional
type Partial<T> = {
[P in keyof T]?: T[P];
};
// Make all properties readonly
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
// Custom mapped type for API responses
type ApiFields<T> = {
[K in keyof T as `api_${string & K}`]: T[K];
};
// Usage
type UserApiFields = ApiFields<User>;
// Results in: { api_id: string; api_name: string; api_email: string; }
12. Conditional Types
Conditional types enable complex type logic:
// Conditional return type
type ApiResponse<T> = T extends string
? { message: T }
: { data: T };
// Non-nullable type
type NonNullable<T> = T extends null | undefined ? never : T;
// Extract array element type
type ArrayElement<T> = T extends (infer U)[] ? U : never;
// Usage
type StringArray = string[];
type StringElement = ArrayElement<StringArray>; // string
13. Module Augmentation
Extend existing types when needed:
// Augment global types
declare global {
interface Window {
myCustomProperty: string;
}
}
// Augment module types
declare module 'express' {
interface Request {
user?: User;
}
}
14. Use Index Signatures Carefully
Index signatures can be powerful but use them judiciously:
// Good: Specific key types
interface UserPreferences {
theme: 'light' | 'dark';
language: string;
notifications: boolean;
}
// Better: Use Record utility type
type UserPreferences = Record<'theme' | 'language' | 'notifications', string | boolean>;
// Avoid: Too broad index signatures
interface BadExample {
[key: string]: any; // Too permissive
}
15. Documentation with JSDoc
Document your types for better developer experience:
/**
* Represents a user in the system
* @interface User
*/
interface User {
/** Unique identifier for the user */
id: string;
/** User's display name */
name: string;
/** User's email address */
email: string;
/** User's role in the system */
role: 'admin' | 'user' | 'guest';
}
/**
* Creates a new user with the provided data
* @param userData - The user data to create
* @returns Promise that resolves to the created user
* @throws {ValidationError} When user data is invalid
*/
async function createUser(userData: Omit<User, 'id'>): Promise<User> {
// Implementation
}
Key Takeaways
- Start Strict: Enable strict mode from the beginning
- Use Union Types: Leverage discriminated unions for state management
- Generic Types: Create reusable generic components and functions
- Type Guards: Implement runtime type checking
- Utility Types: Use built-in utility types effectively
- Error Handling: Create type-safe error handling patterns
- Documentation: Document your types with JSDoc
- Avoid
any: Useunknownand type guards instead - Branded Types: Prevent type confusion with branded types
- Template Literals: Use template literal types for string manipulation
Conclusion
TypeScript is more than just JavaScript with types - it’s a powerful tool for creating maintainable, scalable applications. These best practices have helped me write better code and catch errors early in the development process.
The key is to start with strict configuration and gradually adopt more advanced patterns as your codebase grows. Remember, TypeScript is there to help you, not to make your life harder.
What TypeScript patterns have you found most useful in your projects? I’d love to hear about your experiences!
For more TypeScript tips and web development insights, follow me on GitHub and LinkedIn!