bugl
bugl
HomeLearnPatternsPathsSearchPremium
HomeLearnPatternsPaths

Loading lesson path

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