TypeScript best practices
TypeScript Best Practices for Modern Development Teams
TypeScript has become an essential tool for JavaScript development, offering type safety, better tooling, and improved maintainability for large-scale applications. As teams adopt TypeScript, following best practices becomes crucial to maximize its benefits while avoiding common pitfalls. This guide covers key TypeScript best practices that will help your team write more robust, maintainable code.
1. Leverage TypeScript's Type System Effectively
Use Strict Mode
Always enable strict
mode in your tsconfig.json
. This activates several important type-checking features:
{
"compilerOptions": {
"strict": true,
// Other options...
}
}
Strict mode includes:
noImplicitAny
: Prevents theany
type from being inferredstrictNullChecks
: Forces explicit null/undefined handlingstrictFunctionTypes
: More accurate function type checking- And several other important checks
Avoid any
and Prefer Specific Types
While any
might seem convenient, it defeats TypeScript's purpose. Instead:
- Use
unknown
for values of uncertain type that need type checking - Use type guards to narrow types
- Create precise interfaces and types
// Bad function processData(data: any) { // ... } // Good function processData(data: unknown) { if (typeof data === 'string') { // Now safely use as string } }
Use Utility Types
TypeScript provides powerful utility types that can reduce boilerplate:
interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}
// Create a type with only the fields needed for display
type UserDisplay = Pick<User, 'id' | 'name'>;
// Create a type with all fields optional
type PartialUser = Partial<User>;
// Create a type that omits certain fields
type UserWithoutEmail = Omit<User, 'email'>;
2. Organize and Structure Your Codebase
Use Type Aliases vs. Interfaces
Understand when to use type
vs interface
:
- Use
interface
for object shapes that may need extension - Use
type
for unions, tuples, or complex type compositions
// Interface is better for extendable object shapes
interface Animal {
name: string;
}
interface Dog extends Animal {
breed: string;
}
// Type is better for unions
type Status = 'active' | 'inactive' | 'pending';
Modularize Type Definitions
Keep type definitions close to where they're used:
- For component props, define types in the same file
- For shared types, create a dedicated
types
directory - Avoid global type pollution by using modules
Use Declaration Merging Judiciously
While declaration merging can be powerful, overuse can make code harder to understand. Use it primarily for:
- Extending third-party type definitions
- Library authoring scenarios
- Well-documented augmentation points
3. Advanced Patterns for Better Type Safety
Use Discriminated Unions
This pattern helps TypeScript understand which variant of an object you're working with:
type NetworkState = | { state: 'loading' } | { state: 'success'; response: string } | { state: 'error'; error: Error }; function handleState(state: NetworkState) { switch (state.state) { case 'loading': // TypeScript knows state has no other properties here return 'Loading...'; case 'success': return state.response; case 'error': return state.error.message; } }
Implement Proper Error Handling
TypeScript's type system can help ensure proper error handling:
type Result<T, E = Error> = | { success: true; value: T } | { success: false; error: E }; function safeParse(json: string): Result<unknown, SyntaxError> { try { return { success: true, value: JSON.parse(json) }; } catch (e) { return { success: false, error: e as SyntaxError }; } }
Use Const Assertions for Literal Types
When you need to preserve literal types:
// 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"]
4. Tooling and Project Configuration
Configure Path Aliases
Use path aliases to avoid relative path hell:
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@components/*": ["src/components/*"],
"@utils/*": ["src/utils/*"]
}
}
}
Now you can import like:
import { Button } from '@components/ui/Button';
Use Project References for Monorepos
For larger projects, use project references to improve build times:
// tsconfig.json
{
"references": [
{ "path": "./packages/core" },
{ "path": "./packages/ui" }
]
}
Integrate with ESLint
Use @typescript-eslint
to catch TypeScript-specific issues:
module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking'
],
// ...
};
Conclusion
Adopting these TypeScript best practices will significantly improve your code quality, maintainability, and developer experience. Remember that TypeScript is most powerful when you fully embrace its type system rather than fighting against it. Start with strict mode, use precise types, leverage advanced patterns like discriminated unions, and configure your tooling properly. As your team becomes more proficient with TypeScript, you'll find it becomes an invaluable tool for catching errors early, improving documentation through types, and enabling safer refactoring.
The key is to view TypeScript not just as JavaScript with types, but as a way to model your application's behavior and constraints at the type level. This mindset shift, combined with these practical techniques, will help you get the most value from TypeScript in your projects.