Skip to main content

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

❌ Bad: Multiple processes mixed into one function
// ❌ 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

✅ Good: Split following the single responsibility principle
// ✅ 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

❌ Bad: You cannot tell what the function does
// ❌ You cannot tell what the function does
function user(id: number): User | null { /* ... */ }
function data(): string[] { /* ... */ }
function process(items: Item[]): void { /* ... */ }

Good

✅ Good: Make it clear with verb + noun
// ✅ 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

✅ Good: Naming patterns for functions that return a boolean
// ✅ 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

❌ Bad: Too many arguments
// ❌ 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

✅ Good: Group them into an object
// ✅ 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.

✅ Good: Using default arguments
// ✅ 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

❌ Bad: The same process is duplicated
// ❌ 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

✅ Good: Share the constant
// ✅ 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

❌ Bad: Validation logic is scattered
// ❌ 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

✅ Good: Share the validation
// ✅ 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

❌ Bad: Has a side effect (changes external state)
// ❌ 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

✅ Good: A pure function (no external dependency, no side effects)
// ✅ 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

❌ Bad: Computation and side effects are mixed
// ❌ 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

✅ Good: Pure computation
// ✅ 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

❌ Bad: No referential transparency (the result changes on each call)
// ❌ No referential transparency (the result changes on each call)
function getCurrentTime(): string {
return new Date().toISOString();
}

function getRandomId(): number {
return Math.random();
}

Good

✅ Good: Referentially transparent (inject dependencies via arguments)
// ✅ 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

❌ Bad: Deep nesting
// ❌ 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

✅ Good: Flat with early returns
// ✅ 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:

  1. Restrict operations with a type (literal types)
  2. Separate each calculation into its own pure function
  3. Ensure flexibility with rest parameters
  4. Dispatch with an object (eliminate if/else)
  5. Prevent invalid operations with the type system

Summary of defining functions

Best practices for 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

  • Conditionals — how to keep nesting shallow with early returns
  • Loops — express loops inside functions with higher-order functions