Functions: one job, few parameters, no hidden side effects
In short: A good function does one job at one level of abstraction, takes few parameters and no boolean flags, and does not quietly change state its name does not promise.
Problem
The function that does everything
The names from the last lesson are in place. The shop's constants:
const FREE_DELIVERY_FROM = 50;const STANDARD_DELIVERY_FEE = 4.99;const EXPRESS_SURCHARGE = 7;const SPRING_COUPON = "SPRING10";const SPRING_DISCOUNT = 0.1;
const orders = [];And this is the function every order goes through:
function processOrder(email, prices, coupon, express) { let amount = 0; for (const price of prices) { amount += price; } if (coupon === SPRING_COUPON) { amount = amount * (1 - SPRING_DISCOUNT); } let delivery = amount >= FREE_DELIVERY_FROM ? 0 : STANDARD_DELIVERY_FEE; if (express) { delivery += EXPRESS_SURCHARGE; } const total = amount + delivery; orders.push(total); console.log(`To ${email}: your order is confirmed, €${total.toFixed(2)}`); console.log(`[log] order #${orders.length} for €${total.toFixed(2)}`);}processOrder("maria@example.com", [24.5, 18], "SPRING10", true);processOrder("ivan@example.com", [64], "", false);To maria@example.com: your order is confirmed, €50.24[log] order #1 for €50.24To ivan@example.com: your order is confirmed, €64.00[log] order #2 for €64.00It works. Now marketing wants the coupon not to apply to delivery. Accounting wants the fee shown separately in the cart. The tester wants to check delivery below and above €50, and for every check has to place an order, which means saving it and sending an email. Everyone touches the same function. And what true at the end of the call means still can't be seen.
The idea
One job and one level
Try to say what processOrder does: "it adds up the prices, and applies the coupon, and calculates delivery, and saves, and sends an email, and writes to the log". Every "and" is a separate job. Each one changes for its own reason and because of a different person.
The rule is one job per function. A function with one job is small, has a name that says the whole job, and has few parameters. How you know the job is one:
- you describe it in one sentence without "and";
- you can't pull a piece out of it with a name that isn't just a retelling of the code.
The second rule is one level of abstraction. The top function reads like the table of contents of a book: calculate the total, save, confirm, log. How exactly the prices are added up (the loops, the comparisons, the format of the string) is one level below, in the small functions. When "save the order" and amount += price are in the same method, the reader jumps between the two levels on every line.
See it
The same order, split up
At the top is the table of contents:
function placeOrder(email, prices, coupon, express) { const total = orderTotal(prices, coupon, express); orders.push(total); sendConfirmation(email, total); logOrder(orders.length, total);}
function sendConfirmation(email, total) { console.log(`To ${email}: your order is confirmed, €${total.toFixed(2)}`);}
function logOrder(number, total) { console.log(`[log] order #${number} for €${total.toFixed(2)}`);}Below it is the calculation, each job in its own function:
function subtotal(prices) { let amount = 0; for (const price of prices) { amount += price; } return amount;}
function applyCoupon(amount, coupon) { if (coupon === SPRING_COUPON) { return amount * (1 - SPRING_DISCOUNT); } return amount;}
function deliveryFee(amount, express) { let fee = amount >= FREE_DELIVERY_FROM ? 0 : STANDARD_DELIVERY_FEE; if (express) { fee += EXPRESS_SURCHARGE; } return fee;}function orderTotal(prices, coupon, express) { const amount = subtotal(prices); const discounted = applyCoupon(amount, coupon); return discounted + deliveryFee(discounted, express);}placeOrder("maria@example.com", [24.5, 18], "SPRING10", true);placeOrder("ivan@example.com", [64], "", false);To maria@example.com: your order is confirmed, €50.24[log] order #1 for €50.24To ivan@example.com: your order is confirmed, €64.00[log] order #2 for €64.00The output is the same. Now the coupon is in one function of three lines, delivery in another, and each one changes without touching the email. There are more lines of code, but each piece is read, changed and checked on its own.
More precisely, if you are curious
How small? There is no number. A 30-line function that does one thing (for example a long but flat switch with similar branches) is fine. An 8-line function with three levels of nesting and two flags is not. The measure is the one-sentence description, not the number of lines. Splitting a long function in steps, so that the program works after each one, is Extract Method in the sixth lesson of this module.
Try it
Put the lines in order
Put orderTotal in order so that the coupon is applied before delivery. Why the order matters: the discount can take the order below €50, and then delivery is no longer free.
const amount = subtotal(prices);function orderTotal(prices, coupon, express) {}const discounted = applyCoupon(amount, coupon);return discounted + deliveryFee(discounted, express);
The idea
Few parameters and no flags
Every parameter is something the reader has to keep in mind, and something every call has to pass in the right order. Zero, one and two parameters read easily. Three is already a lot. Four or more usually mean that the parameters are one thing broken into pieces: the email, the prices, the coupon and the delivery together are an order. Then they become one object. This is the Introduce Parameter Object refactoring from the sixth lesson.
The worst parameter is a flag: a boolean that chooses what the function does.
function printSlip(prices, forCourier) { if (forCourier) { console.log(`Parcel: ${prices.length} items`); } else { for (const price of prices) { console.log(`€${price.toFixed(2)}`); } }}const cart = [24.5, 18];printSlip(cart, true);printSlip(cart, false);Parcel: 2 items€24.50€18.00At the call, true says nothing. And inside, the function has two separate paths, so two jobs. The fix is two functions with names:
function printCourierSlip(prices) { console.log(`Parcel: ${prices.length} items`);}
function printReceipt(prices) { for (const price of prices) { console.log(`€${price.toFixed(2)}`); }}const cart = [24.5, 18];printCourierSlip(cart);printReceipt(cart);Parcel: 2 items€24.50€18.00Sometimes the boolean really is data, not a choice of behavior: express delivery is part of the order. Then at least the call should say what it means:
JavaScript has no named arguments, so you pass an options object, and the function unpacks it into its parameters:
function deliveryFee(amount, { express = false } = {}) { let fee = amount >= FREE_DELIVERY_FROM ? 0 : STANDARD_DELIVERY_FEE; if (express) { fee += EXPRESS_SURCHARGE; } return fee;}
console.log(`€${deliveryFee(38.25, { express: true }).toFixed(2)}`);€11.99The idea
Side effect
Anything a function changes outside itself is a side effect: a field of an object, a list it was given, a global variable, a file, a database, an email, a line on the screen.
Side effects are not bad on their own. The order has to be saved somewhere, and the email has to go out. It is bad when the effect is hidden: the name says "I ask", but the function also does. Whoever calls it doesn't expect anything to change, and calls it twice without a second thought.
See it
The question that changes the answer
The warehouse says whether there are lamps. Someone decided that if there are, it's handy to reserve them right away:
class Warehouse { constructor(stock) { this.stock = stock; }
isInStock(quantity) { if (this.stock >= quantity) { this.stock -= quantity; return true; } return false; }}The cart asks so that it can show "available". Then checkout asks the same thing:
const lamps = new Warehouse(3);console.log("Cart: " + (lamps.isInStock(2) ? "available" : "out of stock"));console.log("Checkout: " + (lamps.isInStock(2) ? "available" : "out of stock"));console.log("In stock: " + lamps.stock);Cart: availableCheckout: out of stockIn stock: 1class Warehouse { constructor(stock) { this.stock = stock; }
isInStock(quantity) { if (this.stock >= quantity) { this.stock -= quantity; return true; } return false; }}
const lamps = new Warehouse(3);console.log("Cart: " + (lamps.isInStock(2) ? "available" : "out of stock"));console.log("Checkout: " + (lamps.isInStock(2) ? "available" : "out of stock"));console.log("In stock: " + lamps.stock);Step by step, in words
- The warehouse has 3 lamps on the shelf.
- The cart only wants to show whether there are 2. It asks isInStock(2): a question, judging by the name.
- 3 >= 2: there are enough.
- But the question also takes the lamps: this.stock goes from 3 to 1. Nobody bought anything, and the cart doesn't know this happened.
- The cart gets true and shows "available".
- The customer goes to checkout. Checkout asks the same thing: isInStock(2).
- 1 >= 2 is false. The same question gets a different answer, and the customer sees "out of stock".
- In stock: 1. The answer changed because the first question had a hidden side effect.
Nobody bought anything, two lamps are missing, and the customer can't pay. In a real shop, this is an order lost because of a page that asks about stock. And the bug is hard to find, because every line on its own looks fine.
More precisely, if you are curious
get stock()): lamps.stock looks like reading a field, but it is a function. The browser console and the debugger evaluate getters when you expand them, and that can change the state.Try it
Predict the output
The warehouse with the isInStock that also reserves now has 5 lamps. The same question is asked three times. What does the program print?
const lamps = new Warehouse(5);console.log(lamps.isInStock(2));console.log(lamps.isInStock(2));console.log(lamps.isInStock(2));Every "yes" takes 2 lamps: 5 becomes 3, then 1. At the third question, 1 is less than 2. If the question only asked, all three answers would be "yes".
The idea
A command or a query, not both
The rule is called command–query separation: a function either answers a question and changes nothing, or does something and doesn't pretend to be a question. The warehouse becomes:
class Warehouse { constructor(stock) { this.stock = stock; }
isInStock(quantity) { return this.stock >= quantity; }
reserve(quantity) { if (!this.isInStock(quantity)) { throw new Error("Not enough stock"); } this.stock -= quantity; }}const lamps = new Warehouse(3);console.log("Cart: " + (lamps.isInStock(2) ? "available" : "out of stock"));console.log("Checkout: " + (lamps.isInStock(2) ? "available" : "out of stock"));lamps.reserve(2);console.log("In stock: " + lamps.stock);Cart: availableCheckout: availableIn stock: 1The cart and checkout ask as much as they like. The lamps are reserved on one line that says exactly that: reserve(2).
More precisely, if you are curious
There are well-known exceptions, and the name gives them away: items.pop() removes the element and returns it, iterator.next() moves forward and returns. These are verbs, not questions, so nobody calls them "just to check". The rule is not "a command never returns anything" but "a query never changes anything".
The idea
The pure function
deliveryFee from the split order answers only from its parameters and changes nothing. A function like this is called pure: with the same parameters it always returns the same thing, no matter how many times or in what order you call it.
console.log(`€${deliveryFee(38.25, false).toFixed(2)}`);console.log(`€${deliveryFee(38.25, true).toFixed(2)}`);console.log(`€${deliveryFee(64, false).toFixed(2)}`);console.log(`€${deliveryFee(38.25, true).toFixed(2)}`);€4.99€11.99€0.00€11.99Checking delivery below and above the threshold takes four lines. There is no warehouse to fill, no email to send, no database. With processOrder, you had to place an order for the same thing. That is why the calculations are kept pure, and the side effects are gathered in a few clearly named commands at the top. How checks like these become tests that protect the code at every change is the topic of the fifth lesson of this module.
Try it
Find the bug
The "Remove last" button should remove the product the customer added last. The customer adds milk, apples and bread. Before that, the shop prints a receipt sorted alphabetically:
function printReceipt(items) { items.sort(); for (const item of items) { console.log(" " + item); }}const cart = ["milk", "apples", "bread"];printReceipt(cart);console.log("Last added: " + cart[cart.length - 1]); apples bread milkLast added: milkThe bread was added last, but the program says milk. Which line is the bug on?
Pick the line with the bug.
items.sort() sorts the very list the function got, and that list is the caller's cart. The receipt only wanted to print, but it quietly changed the order of the cart. The fix is to sort a copy and leave the cart untouched:
function printReceipt(items) { for (const item of items.toSorted()) { console.log(" " + item); }}items.toSorted() returns a new array, while items.sort() changes the array itself (and returns it, which makes the bug even harder to notice).Exercise
The order shipped notification
Solve the task in your editor.
The notification. This function builds the text, chooses the channel with a flag, prints and counts what was sent:
let sent = 0;
function notifyShipped(name, orderId, sms) { sent++; const text = `Hello, ${name}! Order ${orderId} has shipped.`; if (sms) { console.log("SMS: " + text); } else { console.log("Email: " + text); }}notifyShipped("Maria", 1042, false);notifyShipped("Ivan", 1043, true);console.log("Sent: " + sent);Email: Hello, Maria! Order 1042 has shipped.SMS: Hello, Ivan! Order 1043 has shipped.Sent: 2Split it into three functions: a pure one that only builds the text, and two commands, one that sends by email and one by SMS, each counting what it sent. The flag goes away. The output must stay the same:
Email: Hello, Maria! Order 1042 has shipped.SMS: Hello, Ivan! Order 1043 has shipped.Sent: 2More tasks
- A parameter object.
placeOrderhas four parameters. Group them in anorderobject and see what the call looks like. - The coupon doesn't apply to delivery. Marketing changes the rule. Which function do you touch, and how many others stay untouched?
- The queue. Write
peekNext()andtakeNext()for a queue of orders for the courier. Which one is a query and which one is a command?
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Exceptions · Throwing and your own exceptions · Arrays: push, splice and sort · Names in code
- Continues in: Comments that lie and comments that help · Code smells: long method, large class, duplication, primitives instead of types, long if chains · Tests as a safety net · Refactoring in small steps
- Tasks on this topic: Clean code and refactoring