bugl
bugl
HomeLearnPatternsPathsSearch
HomeLearnPatternsPathsSearch

Loading lesson path

Learn/TypeScript/TypeScript Core
TypeScript•TypeScript Core

TypeScript Best Practices

Flash cards

Review the key moves

1/4
Core idea

What is the main idea behind TypeScript Best Practices?

Lesson checks

Practice each idea before moving on

Short Mimo-style checks built from this lesson's code, terms, and sequence.

1Quick choice

Which statement best captures the main point of this lesson?

2Fill blank

Complete the missing token from the example code.

// ___.json
3Order

Put the learning moves in the order that makes the concept easiest to apply.

Type System Best Practices
Enable Strict Mode
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