Flash cards
Review the key moves
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.
Which statement best captures the main point of this lesson?
Complete the missing token from the example code.
// ___.jsonPut the learning moves in the order that makes the concept easiest to apply.
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();
}interfacePrefer 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 namesBest 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 handledBe 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';