Defining functions
Let's learn the key points for defining functions. In TypeScript, type annotations make a function's input and output even clearer.
Key points
- Name functions with "verb + noun"
- Follow the single responsibility principle
- Keep the number of arguments small
- Follow the DRY (Don't Repeat Yourself) principle
- Aim for pure functions
- Avoid side effects
Keep functions small
A function should have only one feature. Splitting functions that have multiple features improves readability and testability.
Bad
// ❌ Multiple processes mixed into one function
function processUserData(user: User): void {
// Validate the user
if (!user.name || !user.email) {
throw new Error('Invalid user data');
}
// Normalize the email address
const normalizedEmail = user.email.toLowerCase().trim();
// Save to the database
database.save({ ...user, email: normalizedEmail });
// Send a confirmation email
emailService.send(normalizedEmail, 'Welcome!');
// Record a log
logger.info(`User ${user.name} created`);
}
Good
// ✅ Split following the single responsibility principle
function validateUser(user: User): void {
if (!user.name || !user.email) {
throw new Error('Invalid user data');
}
}
function normalizeEmail(email: string): string {
return email.toLowerCase().trim();
}
function saveUser(user: User): void {
database.save(user);
}
function sendWelcomeEmail(email: string): void {
emailService.send(email, 'Welcome!');
}
function logUserCreation(userName: string): void {
logger.info(`User ${userName} created`);
}
// Main process
function createUser(user: User): void {
validateUser(user);
const normalizedUser = { ...user, email: normalizeEmail(user.email) };
saveUser(normalizedUser);
sendWelcomeEmail(normalizedUser.email);
logUserCreation(user.name);
}
Name functions with verb + noun
Give functions a name that clearly conveys "what it does."
Bad
// ❌ You cannot tell what the function does
function user(id: number): User | null { /* ... */ }
function data(): string[] { /* ... */ }
function process(items: Item[]): void { /* ... */ }
Good
// ✅ Make it clear with verb + noun
function getUserById(id: number): User | null { /* ... */ }
function fetchDataFromApi(): string[] { /* ... */ }
function filterActiveItems(items: Item[]): Item[] { /* ... */ }
// Common verb patterns
function createUser(data: UserData): User { /* ... */ } // Create
function updateUser(user: User): void { /* ... */ } // Update
function deleteUser(id: number): void { /* ... */ } // Delete
function findUserByEmail(email: string): User | null { /* ... */ } // Search
function validateEmail(email: string): boolean { /* ... */ } // Validate
function calculateTotal(items: Item[]): number { /* ... */ } // Calculate
function formatDate(date: Date): string { /* ... */ } // Format
function parseJson(json: string): object { /* ... */ } // Parse
Start functions that return a boolean with is/has/can
// ✅ Naming patterns for functions that return a boolean
function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
function hasPermission(user: User, action: string): boolean {
return user.permissions.includes(action);
}
function canAccessResource(user: User, resourceId: string): boolean {
return user.accessibleResources.includes(resourceId);
}
function shouldRetry(error: Error, attempts: number): boolean {
return error.name === 'NetworkError' && attempts < 3;
}
Keep the number of arguments small
Functions with many arguments are hard to understand and prone to mistakes when called. Group three or more arguments into an object.
Bad
// ❌ Too many arguments
function createUser(
name: string,
email: string,
age: number,
address: string,
phone: string,
isAdmin: boolean
): User {
return { name, email, age, address, phone, isAdmin };
}
// Caller: easy to get the order wrong
const user = createUser('Taro', 'taro@example.com', 25, 'Tokyo', '090-1234-5678', false);
Good
// ✅ Group them into an object
interface CreateUserParams {
name: string;
email: string;
age: number;
address: string;
phone: string;
isAdmin?: boolean; // Optional parameter
}
function createUser(params: CreateUserParams): User {
return {
...params,
isAdmin: params.isAdmin ?? false, // Default value
};
}
// Caller: clear with property names
const user = createUser({
name: 'Taro',
email: 'taro@example.com',
age: 25,
address: 'Tokyo',
phone: '090-1234-5678',
});
Use default arguments
In TypeScript, default arguments let you handle optional parameters concisely.
// ✅ Using default arguments
function greet(name: string, greeting: string = 'Hello'): string {
return `${greeting}, ${name}!`;
}
greet('Taro'); // 'Hello, Taro!'
greet('Taro', 'Hi'); // 'Hi, Taro!'
// Default values with an object argument
interface PaginationOptions {
page?: number;
limit?: number;
sortBy?: string;
order?: 'asc' | 'desc';
}
function fetchItems(options: PaginationOptions = {}): Promise<Item[]> {
const {
page = 1,
limit = 10,
sortBy = 'createdAt',
order = 'desc',
} = options;
return api.get('/items', { page, limit, sortBy, order });
}
The DRY (Don't Repeat Yourself) principle
Avoid repeating the same code. Extract duplicated code into a function.
Bad
// ❌ The same process is duplicated
function calculateCircleArea(radius: number): number {
return 3.14159 * radius * radius;
}
function calculateCircleCircumference(radius: number): number {
return 2 * 3.14159 * radius;
}
function calculateSphereVolume(radius: number): number {
return (4 / 3) * 3.14159 * radius * radius * radius;
}
Good
// ✅ Share the constant
const PI = Math.PI;
function calculateCircleArea(radius: number): number {
return PI * radius ** 2;
}
function calculateCircleCircumference(radius: number): number {
return 2 * PI * radius;
}
function calculateSphereVolume(radius: number): number {
return (4 / 3) * PI * radius ** 3;
}
Bad
// ❌ Validation logic is scattered
function createUser(userData: UserData): User {
if (!userData.email.includes('@')) {
throw new Error('Invalid email');
}
// ...
}
function updateUser(userData: UserData): User {
if (!userData.email.includes('@')) {
throw new Error('Invalid email');
}
// ...
}
Good
// ✅ Share the validation
function validateEmail(email: string): void {
if (!email.includes('@')) {
throw new Error('Invalid email');
}
}
function createUser(userData: UserData): User {
validateEmail(userData.email);
// ...
}
function updateUser(userData: UserData): User {
validateEmail(userData.email);
// ...
}
Aim for pure functions
A pure function always returns the same output for the same input and has no side effects.
Bad
// ❌ Has a side effect (changes external state)
let total = 0;
function addToTotal(value: number): number {
total += value; // Changes an external variable
return total;
}
// ❌ Depends on external state (the result changes even for the same input)
let discount = 0.1;
function calculatePrice(price: number): number {
return price * (1 - discount); // Depends on an external variable
}
Good
// ✅ A pure function (no external dependency, no side effects)
function add(a: number, b: number): number {
return a + b;
}
function calculatePrice(price: number, discountRate: number): number {
return price * (1 - discountRate);
}
// ✅ Array operations also return a new array
function addItem<T>(items: T[], newItem: T): T[] {
return [...items, newItem]; // Does not change the original array
}
function removeItem<T>(items: T[], index: number): T[] {
return items.filter((_, i) => i !== index);
}
Separate side effects
Side effects (I/O operations, state changes, and so on) are sometimes unavoidable, but separate them from pure computation.
Bad
// ❌ Computation and side effects are mixed
function processOrder(order: Order): void {
// Computation
const subtotal = order.items.reduce((sum, item) => sum + item.price, 0);
const tax = subtotal * 0.1;
const total = subtotal + tax;
// Side effects
database.save({ ...order, total });
emailService.sendReceipt(order.email, total);
logger.info(`Order processed: ${total}`);
}
Good
// ✅ Pure computation
function calculateOrderTotal(items: OrderItem[]): {
subtotal: number;
tax: number;
total: number;
} {
const subtotal = items.reduce((sum, item) => sum + item.price, 0);
const tax = subtotal * 0.1;
const total = subtotal + tax;
return { subtotal, tax, total };
}
// ✅ Separate side effects explicitly
async function saveOrder(order: Order, total: number): Promise<void> {
await database.save({ ...order, total });
}
async function notifyOrderComplete(email: string, total: number): Promise<void> {
await emailService.sendReceipt(email, total);
}
// ✅ Orchestration
async function processOrder(order: Order): Promise<void> {
const { total } = calculateOrderTotal(order.items);
await saveOrder(order, total);
await notifyOrderComplete(order.email, total);
logger.info(`Order processed: ${total}`);
}
Maintain referential transparency
Referential transparency is the property that replacing an expression with its value does not change the program's behavior.
Bad
// ❌ No referential transparency (the result changes on each call)
function getCurrentTime(): string {
return new Date().toISOString();
}
function getRandomId(): number {
return Math.random();
}
Good
// ✅ Referentially transparent (inject dependencies via arguments)
function formatTime(date: Date): string {
return date.toISOString();
}
function generateId(seed: number): number {
// Deterministic ID generation
return seed * 2654435761 % 2 ** 32;
}
// Becomes testable
const testDate = new Date('2024-01-01');
console.log(formatTime(testDate)); // Always the same result
Use early returns
Returning early when a condition is not met reduces nesting and improves readability.
Bad
// ❌ Deep nesting
function processUser(user: User | null): string {
if (user !== null) {
if (user.isActive) {
if (user.hasPermission) {
return `Welcome, ${user.name}!`;
} else {
return 'Permission denied';
}
} else {
return 'Account is inactive';
}
} else {
return 'User not found';
}
}
Good
// ✅ Flat with early returns
function processUser(user: User | null): string {
if (user === null) {
return 'User not found';
}
if (!user.isActive) {
return 'Account is inactive';
}
if (!user.hasPermission) {
return 'Permission denied';
}
return `Welcome, ${user.name}!`;
}
Use type guards
Use TypeScript type guards to narrow types.
// Custom type guard
interface Dog {
type: 'dog';
bark(): void;
}
interface Cat {
type: 'cat';
meow(): void;
}
type Pet = Dog | Cat;
function isDog(pet: Pet): pet is Dog {
return pet.type === 'dog';
}
function playWithPet(pet: Pet): void {
if (isDog(pet)) {
pet.bark(); // TypeScript recognizes the Dog type here
} else {
pet.meow(); // TypeScript recognizes the Cat type here
}
}
// A null-check type guard
function assertExists<T>(value: T | null | undefined, message?: string): asserts value is T {
if (value === null || value === undefined) {
throw new Error(message ?? 'Value does not exist');
}
}
function processUser(user: User | null): void {
assertExists(user, 'User not found');
// From here on, user can be treated as the User type
console.log(user.name);
}
Use overloads
TypeScript function overloads let you handle different argument patterns.
// Function overloads
function createElement(tag: 'div'): HTMLDivElement;
function createElement(tag: 'span'): HTMLSpanElement;
function createElement(tag: 'input'): HTMLInputElement;
function createElement(tag: string): HTMLElement {
return document.createElement(tag);
}
const div = createElement('div'); // HTMLDivElement
const span = createElement('span'); // HTMLSpanElement
const input = createElement('input'); // HTMLInputElement
// Combining with generics
function parseValue(value: string, type: 'number'): number;
function parseValue(value: string, type: 'boolean'): boolean;
function parseValue(value: string, type: 'string'): string;
function parseValue(value: string, type: string): number | boolean | string {
switch (type) {
case 'number':
return Number(value);
case 'boolean':
return value === 'true';
default:
return value;
}
}
Exercises
Refactor the following code following the principles of clean code
// Problem: improve the following code
function calc(a: number, b: number, c: number, d: string): number | string {
if (d === 'add') {
let result = 0;
result = a + b + c;
return result;
} else if (d === 'multiply') {
let result = 1;
result = a * b * c;
return result;
} else if (d === 'average') {
let result = 0;
result = (a + b + c) / 3;
return result;
} else {
return 'Unknown operation';
}
}
Example answer:
type MathOperation = 'add' | 'multiply' | 'average';
function add(...numbers: number[]): number {
return numbers.reduce((sum, num) => sum + num, 0);
}
function multiply(...numbers: number[]): number {
return numbers.reduce((product, num) => product * num, 1);
}
function average(...numbers: number[]): number {
if (numbers.length === 0) return 0;
return add(...numbers) / numbers.length;
}
function calculate(numbers: number[], operation: MathOperation): number {
const operations: Record<MathOperation, (...nums: number[]) => number> = {
add,
multiply,
average,
};
return operations[operation](...numbers);
}
// Usage example
const result = calculate([1, 2, 3], 'add'); // 6
Improvements:
- Restrict operations with a type (literal types)
- Separate each calculation into its own pure function
- Ensure flexibility with rest parameters
- Dispatch with an object (eliminate if/else)
- Prevent invalid operations with the type system
Summary of defining functions
- Keep functions small with a single responsibility
- Name functions with verb + noun
- Two or fewer arguments (group three or more into an object)
- Use default arguments
- Eliminate duplication with the DRY principle
- Aim for pure functions
- Separate side effects
- Reduce nesting with early returns
- Narrow types with type guards
What to read next
- Conditionals — how to keep nesting shallow with early returns
- Loops — express loops inside functions with higher-order functions