How big is too big
Every rule about size is somebody's guess dressed as a law. The reason behind all of them is the same: a function should do one thing, at one level of abstraction. Size is a symptom.
One level of abstraction
async function addOrder(form) {
const customer = form.customer.value.trim();
if (!customer) {
form.customer.nextElementSibling.textContent = "Please enter a name.";
return;
}
const pieces = Number(form.pieces.value);
if (!Number.isInteger(pieces) || pieces < 1) {
form.pieces.nextElementSibling.textContent = "Enter a whole number.";
return;
}
const order = { id: crypto.randomUUID(), customer, pieces, delivered: false };
orders.push(order);
localStorage.setItem("orders", JSON.stringify(orders));
const li = document.createElement("li");
li.textContent = `${customer} — ${pieces}`;
list.append(li);
}
Not long. Still hard to read, because four altitudes are stacked: reading a
form, validating a rule, persisting, and building DOM. A reader looking for the
business rule steps over createElement to find it.
async function addOrder(form) {
const result = validate(readForm(form));
if (!result.ok) return showErrors(form, result.errors);
store.add(createOrder(result.value));
}
Same work. The function now reads as a summary and each detail is one step down. The test: can you read it and know what it does without reading anything it calls?
That is the shape your capstone already uses — app.js wires, validate.js
validates, state.js stores, render.js draws. Four files, four altitudes.
What to extract, and what to leave
Extract when the block needs a comment to say what it does (the comment is the function name you have not written), when it is at a different altitude, when it is duplicated, or when you want to test it alone.
Leave it when it is three obvious lines used once, when extracting would need
four parameters to carry the context, or when the name would restate the code —
doTheLoop.
If you cannot name the extracted function better than the code it replaces, the extraction earns nothing.
Parameters
Zero is best, one is good, two is fine, three is a smell, four means something is wrong. JavaScript gives you a clean fix:
// unreadable
createOrder("Asha", "veg", 2, "412207", true, false);
// destructured options, with defaults
function createOrder({ customer, item, pieces = 1, pincode = "411001", express = false }) {
…
}
createOrder({ customer: "Asha", item: "veg", pieces: 2, pincode: "412207" });
Labelled at the call site, order-independent, and adding a field breaks nobody.
Two details worth knowing:
function createOrder({ customer } = {}) { … }
createOrder(); // works — without the `= {}` this throws
Destructuring a missing argument throws TypeError: Cannot destructure property 'customer' of 'undefined'. The = {} default makes a no-argument call work.
A boolean parameter is nearly always wrong. save(order, true) tells the
reader nothing. Either two named functions, or an options object so the call
reads save(order, { notify: true }).
Return early
// arrow-shaped
function priceFor(plan, pieces) {
if (plan) {
if (pricing[plan]) {
if (pieces > 0) {
return pricing[plan](pieces);
} else {
throw new Error("pieces must be positive");
}
} else {
throw new Error(`unknown plan: ${plan}`);
}
} else {
throw new Error("plan is required");
}
}
// flat
function priceFor(plan, pieces) {
if (!plan) throw new Error("plan is required");
if (!pricing[plan]) throw new Error(`unknown plan: ${plan}`);
if (pieces <= 0) throw new Error("pieces must be positive");
return pricing[plan](pieces);
}
Guards at the top where a reader absorbs them, the work at the bottom
unindented, and every else gone — each of which was a place to make a mistake.
Async: the shapes that go wrong
Three things that are specifically about async, and each is a real bug.
forEach does not await.
// wrong — every promise is started and none is awaited
orders.forEach(async (order) => {
await save(order);
});
console.log("all saved"); // prints first, nothing is saved yet
// sequential
for (const order of orders) {
await save(order);
}
// parallel, and awaited
await Promise.all(orders.map((order) => save(order)));
forEach ignores the returned promise. for…of awaits each in turn;
Promise.all runs them together. Choose deliberately — Promise.all against a
rate-limited API will get you blocked.
An async function always returns a promise, including when it throws:
async function load() { throw new Error("nope"); }
load(); // an unhandled rejection, not a thrown error
try { load(); } catch { } // catches nothing
try { await load(); } catch { } // catches it
await inside a loop is often the bug. Ten sequential requests at 200ms
each is two seconds; the same ten in parallel is 200ms. But ten thousand in
parallel will exhaust something.
Modules
A module is the unit, and import is your dependency graph.
Split a file when it has more than one reason to change, when you are scrolling to find things, or when the imports at the top come from unrelated worlds.
Do not split by kind. A utils.js, helpers.js and constants.js in every
project is a filing cabinet with drawers labelled "paper". Split by feature —
storage.js, validate.js, render.js — so a change to rendering touches one
file. Your capstone does this, and it is why its files are each under a hundred
lines.
Prefer named exports.
// export default function createStore() { … } // renamed freely by importers
export function createStore() { … } // one name everywhere
A default export can be imported under any name, so the same function appears as three different identifiers across a codebase and searching for it stops working. Named exports also autocomplete and catch typos at build time.
Watch for circular imports. If a.js imports b.js and b.js imports
a.js, one of them sees undefined during evaluation — and the error appears
somewhere unrelated. A cycle is nearly always a sign that a third module wants
to exist, holding what both need.
Do not gold-plate
A function wrapped in a factory behind a module with one export is not well-designed; it is three things to read before you learn what happens.
The right size for a first version is the obvious thing. The second time you touch it, the seams will be visible — and in places you would not have guessed.
Check your work
Why size rules are symptoms: one thing at one level of abstraction is the real rule.
The readability test: can you read the function without reading what it calls?
What an options object fixes: an unreadable call site, and = {} lets it be
called with no argument at all.
Why forEach with async is wrong: it ignores the returned promise.
for…of versus Promise.all: sequential versus parallel, and parallel can
overwhelm an API.
Why a thrown error in an async function is not catchable without await: it
becomes a rejected promise.
Why named exports: a default can be imported under any name, so search stops working.
How to split modules: by feature, never by kind, and never into utils.js.
What a circular import means: a third module wants to exist.
Practice
- Find the longest function in your capstone and list the altitudes in it.
- Extract one block and name it. If the name restates the code, put it back.
- Convert a function with four positional parameters to an options object.
- Call it with no arguments, then add
= {}and call it again. - Write the
forEach+asyncbug, then fix it both ways and time each. - Throw inside an async function and try to catch it without
await. - Run ten fake requests sequentially and then with
Promise.all. Time both. - Change one module to a default export and import it under two different names in two files.
- Create a circular import deliberately and read the error.
- If you have a
utils.js, list what is in it and decide where each thing belongs.
Next: comments, and the few worth writing.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship