bugl
bugl
HomeLearnPatternsPathsSearchPremium
HomeLearnPatternsPaths
Learn/TypeScript/TypeScript Core
TypeScript•TypeScript Core

TypeScript Best Practices

This guide covers essential TypeScript best practices to help you write clean, maintainable, and type-safe code. Following these practices will improve code quality and developer experience.

Enable Strict Mode

Always enable strict mode in your tsconfig.json for maximum type safety:

// tsconfig.json
{
 "compilerOptions": {
 /* Enable all strict type-checking options */
 "strict": true,
 /* Additional recommended settings */
 "target": "ES2020",
 "module": "commonjs",
 "moduleResolution": "node",
 "esModuleInterop": true,
 "skipLibCheck": true,
 "forceConsistentCasingInFileNames": true
 }
}

Consider enabling these additional strict checks for better code quality:

{
 "compilerOptions": {
 /* Additional strict checks */
 "noImplicitAny": true,
 "strictNullChecks": true,
 "strictFunctionTypes": true,
 "strictBindCallApply": true,
 "strictPropertyInitialization": true,
 "noImplicitThis": true,
 "alwaysStrict": true
 }
}

Type System Best Practices

Use Type Inference Where Possible

// Bad: Redundant type annotation
const name: string = 'John';
// Good: Let TypeScript infer the type
const name = 'John';
// Bad: Redundant return type function add(a: number, b: number): number {
return a + b;
}
// Good: Let TypeScript infer return type function add(a: number, b: number) {
return a + b;
}

Precise Type Annotations

Be explicit with types for public APIs and function parameters:

// Bad: No type information
function processUser(user) {
 return user.name.toUpperCase();
}
// Good: Explicit parameter and return types
interface User {
 id: number;
 name: string;
 email?: string; // Optional property
}
function processUser(user: User): string {
 return user.name.toUpperCase();
}
interface

Prefer more specific types over any

// Bad: Loses type safety
function logValue(value: any) {
 console.log(value.toUpperCase()); // No error until runtime
}
// Better: Use generic type parameter
function logValue<T>(value: T) {
 console.log(String(value)); // Safer, but still not ideal
}
// Best: Be specific about expected types
function logString(value: string) {
 console.log(value.toUpperCase()); // Type-safe
}
// When you need to accept any value but still be type-safe
function logUnknown(value: unknown) {
 if (typeof value === 'string') {
 console.log(value.toUpperCase());
 } else {
 console.log(String(value));
}
}

Module Organization

Organize code into logical modules with clear responsibilities:

// user/user.model.ts
export interface User {
 id: string;
 name: string;
 email: string;
}
// user/user.service.ts
import { User } from './user.model';
export class UserService {
 private users: User[] = [];
 addUser(user: User) {
 this.users.push(user);
 }
 getUser(id: string): User | undefined {
 return this.users.find(user => user.id === id);
 }
}
// user/index.ts (barrel file)
export * from './user.model';
export * from './user.service';

File Naming Conventions

Follow consistent file naming patterns

// Good
user.service.ts // Service classes
user.model.ts // Type definitions
user.controller.ts // Controllers
user.component.ts // Components
user.utils.ts // Utility functions
user.test.ts // Test files
// Bad
UserService.ts // Avoid PascalCase for file names
user_service.ts // Avoid snake_case
userService.ts // Avoid camelCase for file names

Best Practices

  • Document your types and interfaces.
  • Prefer composition over inheritance for types.
  • Keep tsconfig.json strict and up-to-date.
  • Refactor code to use more specific types as the codebase evolves.

Functions and Methods

Function Parameters and Return Types

// Bad: No type information function process(user, notify) { notify(user.name);
}
// Good: Explicit parameter and return types function processUser( user: User, notify: (message: string) => void ): void { notify(`Processing user: ${user.name}`);
}
// Use default parameters instead of conditionals function createUser( name: string, role: UserRole = 'viewer', isActive: boolean = true ): User {
return { name, role, isActive };
}
// Use rest parameters for variable arguments function sum(...numbers: number[]): number {
return numbers.reduce((total, num) => total + num, 0);
}

Be mindful of function complexity and responsibilities:

// Bad: Too many responsibilities
function processUserData(userData: any) {
 // Validation
 if (!userData || !userData.name) throw new Error('Invalid user data');
 // Data transformation
 const processedData = {
 ...userData,
 name: userData.name.trim(),
 createdAt: new Date()
 };
 // Side effect
 saveToDatabase(processedData);
 // Notification
 sendNotification(processedData.email, 'Profile updated');
 return processedData;
}
// Better: Split into smaller, focused functions
function validateUserData(data: unknown): UserData {
 if (!data || typeof data !== 'object') {
 throw new Error('Invalid user data');
 }
 return data as UserData;
}
function processUserData(userData: UserData): ProcessedUserData {
 return {
 ...userData,
 name: userData.name.trim(),
 createdAt: new Date()
 };
}

Proper Async/Await Usage

Handle asynchronous operations effectively with proper error handling:

// Bad: Not handling errors
async function fetchData() {
 const response = await fetch('/api/data');
 return response.json();
}
// Good: Proper error handling
async function fetchData<T>(url: string): Promise<T> {
 try {
 const response = await fetch(url);
 if (!response.ok) {
 throw new Error(`HTTP error! status: ${response.status}`);
 }
 return await response.json() as T;
 } catch (error) {
 console.error('Failed to fetch data:', error);
 throw error; // Re-throw to allow caller to handle
}
}
// Better: Use Promise.all for parallel operations
async function fetchMultipleData<T>(urls: string[]): Promise<T[]> {
 try {
 const promises = urls.map(url => fetchData<T>(url));
 return await Promise.all(promises);
 } catch (error) {
 console.error('One or more requests failed:', error);
 throw error;
}
}
// Example usage
interface User {
 id: string;
 name: string;
 email: string;
}
// Fetch user data with proper typing
async function getUserData(userId: string): Promise<User> {
 return fetchData<User>(`/api/users/${userId}`);
}

Flatten your async/await code to avoid callback hell:

// Bad: Nested async/await (callback hell)
async function processUser(userId: string) {
 const user = await getUser(userId);
 if (user) {
 const orders = await getOrders(user.id);
 if (orders.length > 0) {
 const latestOrder = orders[0];
 const items = await getOrderItems(latestOrder.id);
 return { user, latestOrder, items };
 }
 }
 return null;
}
// Better: Flatten the async/await chain
async function processUser(userId: string) {
 const user = await getUser(userId);
 if (!user) return null;
 const orders = await getOrders(user.id);
 if (orders.length === 0) return { user, latestOrder: null, items: [] };
 const latestOrder = orders[0];
 const items = await getOrderItems(latestOrder.id);
 return { user, latestOrder, items };
}
// Best: Use Promise.all for independent async operations
async function processUser(userId: string) {
 const [user, orders] = await Promise.all([
 getUser(userId),
 getOrders(userId)
 ]);
 if (!user) return null;
 if (orders.length === 0) return { user, latestOrder: null, items: [] };
 const latestOrder = orders[0];
 const items = await getOrderItems(latestOrder.id);
 return { user, latestOrder, items };
}

Writing Testable Code

Design your code with testability in mind by using dependency injection and pure functions:

// Bad: Hard to test due to direct dependencies
class PaymentProcessor {
 async processPayment(amount: number) {
 const paymentGateway = new PaymentGateway();
 return paymentGateway.charge(amount);
 }
}
// Better: Use dependency injection
interface PaymentGateway {
 charge(amount: number): Promise<boolean>;
}
class PaymentProcessor {
 constructor(private paymentGateway: PaymentGateway) {}
 async processPayment(amount: number): Promise<boolean> {
 if (amount <= 0) {
 throw new Error('Amount must be greater than zero');
 }
 return this.paymentGateway.charge(amount);
 }
}
// Test example with Jest
describe('PaymentProcessor', () => {
 let processor: PaymentProcessor;
 let mockGateway: jest.Mocked<PaymentGateway>;
 beforeEach(() => {
 mockGateway = {
 charge: jest.fn()
 };
 processor = new PaymentProcessor(mockGateway);
 });
 it('should process a valid payment', async () => {
 mockGateway.charge.mockResolvedValue(true);
 const result = await processor.processPayment(100);
 expect(result).toBe(true);
 expect(mockGateway.charge).toHaveBeenCalledWith(100);
 });
 it('should throw for invalid amount', async () => {
 await expect(processor.processPayment(-50))
 .rejects
 toThrow('Amount must be greater than zero');
 });
});

Test your types to ensure they work as expected using type assertions and utilities:

// Using @ts-expect-error to test for type errors
// @ts-expect-error - Should not allow negative values
const invalidUser: User = { id: -1, name: 'Test' };
// Using type assertions in tests
function assertIsString(value: unknown): asserts value is string {
 if (typeof value !== 'string') {
 throw new Error('Not a string');
 }
}
// Using utility types for testing
type IsString<T> = T extends string ? true : false;
type Test1 = IsString<string>; // true
type Test2 = IsString<number>; // false
// Using tsd for type testing (install with: npm install --save-dev tsd)
/*
import { expectType } from 'tsd';
const user = { id: 1, name: 'John' };
expectType<{ id: number; name: string }>(user);
expectType<string>(user.name);
*/

Type-Only Imports and Exports

Use type-only imports and exports to reduce bundle size and improve tree-shaking:

// Bad: Imports both type and value
import { User, fetchUser } from './api';
// Good: Separate type and value imports
import type { User } from './api';
import { fetchUser } from './api';
// Even better: Use type-only imports when possible
import type { User, UserSettings } from './types';
// Type-only export
export type { User };
// Runtime export
export { fetchUser };
// In tsconfig.json, enable "isolatedModules": true
// to ensure type-only imports are properly handled

Be mindful of complex types that can impact compilation time:

// Bad: Deeply nested mapped types can be slow
type DeepPartial<T> = {
 [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
// Better: Use built-in utility types when possible
type User = {
 id: string;
 profile: {
 name: string;
 email: string;
 };
 preferences?: {
 notifications: boolean;
 };
};
// Instead of DeepPartial<User>, use Partial with type assertions
const updateUser = (updates: Partial<User>) => {
 // Implementation
};
// For complex types, consider using interfaces
interface UserProfile {
 name: string;
 email: string;
}
interface UserPreferences {
 notifications: boolean;
}
interface User {
 id: string;
 profile: UserProfile;
 preferences?: UserPreferences;
}

Use const Assertions for Literal Types

// Without const assertion (wider type)
const colors = ['red', 'green', 'blue'];
// Type: string[] // With const assertion (narrower, more precise type)
const colors = ['red', 'green', 'blue'] as const;
// Type: readonly ["red", "green", "blue"] // Extract union type from const array type Color = typeof colors[number];  // "red" | "green" | "blue" // Objects with const assertions
const config = {
  apiUrl: 'https://api.example.com', timeout: 5000, features: ['auth', 'notifications'], } as const;
// Type is: // { //   readonly apiUrl: "https://api.example.com"; //   readonly timeout: 5000; //   readonly features: readonly ["auth", "notifications"]; // }

Overusing the any Type

Avoid using any as it defeats TypeScript's type checking:

// Bad: Loses all type safety
function process(data: any) {
 return data.map(item => item.name);
}
// Better: Use generics for type safety
function process<T extends { name: string }>(items: T[]) {
 return items.map(item => item.name);
}
// Best: Use specific types when possible
interface User {
 name: string;
 age: number;
}
function processUsers(users: User[]) {
 return users.map(user => user.name);
}

Not Using Strict Mode

Always enable strict mode in your tsconfig.json:

// tsconfig.json
{
 "compilerOptions": {
 "strict": true,
 /* Additional strictness flags */
 "noImplicitAny": true,
 "strictNullChecks": true,
 "strictFunctionTypes": true,
 "strictBindCallApply": true,
 "strictPropertyInitialization": true,
 "noImplicitThis": true,
 "alwaysStrict": true
 }
}

Ignoring Type Inference

Let TypeScript infer types when possible

// Redundant type annotation
const name: string = 'John';
// Let TypeScript infer the type
const name = 'John'; // TypeScript knows it's a string
// Redundant return type
function add(a: number, b: number): number {
 return a + b;
}
// Let TypeScript infer the return type
function add(a: number, b: number) {
 return a + b; // TypeScript infers number
}

Not Using Type Guards

// Without type guard function process(input: string | number) {
return input.toUpperCase();  // Error: toUpperCase doesn't exist on number } // With type guard function isString(value: unknown): value is string { return typeof value === 'string'; } function process(input: string | number) { if (isString(input)) { return input.toUpperCase();  // TypeScript knows input is string here } else { return input.toFixed(2);  // TypeScript knows input is number here } } // Built-in type guards if (typeof value === 'string') { /* value is string */ } if (value instanceof Date) { /* value is Date */ } if ('id' in user) { /* user has id property */ }

Not Handling null and undefined

Always handle potential null or undefined values:

// Bad: Potential runtime error
function getLength(str: string | null) {
 return str.length; // Error: Object is possibly 'null'
}
// Good: Null check
function getLength(str: string | null) {
 if (str === null) return 0;
 return str.length;
}
// Better: Use optional chaining and nullish coalescing
function getLength(str: string | null) {
 return str?.length ?? 0;
}
// For arrays
const names: string[] | undefined = [];
const count = names?.length ?? 0; // Safely handle undefined
// For object properties
interface User {
 profile?: {
 name?: string;
 };
}
const user: User = {};
const name = user.profile?.name ?? 'Anonymous';

Previous

TypeScript Error Handling

Next chapter

Practice

Start with TypeScript Practice: Trace State

This chapter

Overview
40

Lessons

241m

Read time

1. TypeScript Tutorial2. TypeScript Introduction3. TypeScript Getting Started4. TypeScript Simple Types5. TypeScript Explicit Types and Inference6. TypeScript Special Types7. TypeScript Arrays8. TypeScript Tuples9. TypeScript Object Types10. TypeScript Enums11. TypeScript Type Aliases and Interfaces12. TypeScript Union Types13. TypeScript Functions14. TypeScript Casting15. TypeScript Classes16. TypeScript Basic Generics17. TypeScript Utility Types18. TypeScript Keyof19. TypeScript Null & Undefined20. TypeScript Definitely Typed21. TypeScript 5.x Updates22. TypeScript Configuration23. TypeScript with Node.js24. TypeScript with React25. TypeScript Tooling26. TypeScript Advanced Types27. TypeScript Type Guards28. TypeScript Conditional Types29. TypeScript Mapped Types30. TypeScript Type Inference31. TypeScript Literal Types32. TypeScript Namespaces33. TypeScript Index Signatures34. TypeScript Declaration Merging35. TypeScript Async Programming36. TypeScript Decorators37. TypeScript in JavaScript Projects (JSDoc)38. TypeScript Migration Guide39. TypeScript Error Handling40. TypeScript Best Practices

On this page

Enable Strict ModeType System Best PracticesPrecise Type AnnotationsModule OrganizationFile Naming ConventionsBest PracticesFunctions and MethodsProper Async/Await UsageWriting Testable CodeType-Only Imports and ExportsOverusing the any TypeNot Using Strict ModeIgnoring Type InferenceNot Handling null and undefined