Name things for what they are
- A function is a verb:
formatPrice,loadDoors,isValid. - A boolean reads as a question:
isOpen,hasError,canEdit. - A collection is plural:
doors, notdoorList. - Short names for short lives —
i,din a one-line callback — long names for long lives. - If the name needs "And", it is two functions.
// what does it do? read the body to find out
function proc(d, f) {
return d.filter((x) => !x.a).map((x) => x.p * f);
}
// now you do not have to
function activePricesWithVat(doors, vatMultiplier) {
return doors
.filter((door) => !door.archived)
.map((door) => door.price * vatMultiplier);
}
One job each
// mixed: impossible to test without a DOM and a network
function save(form) {
const data = Object.fromEntries(new FormData(form));
if (!data.email.includes("@")) { showError("Bad email"); return; }
fetch("/api", { method: "POST", body: JSON.stringify(data) });
}
// separated: three of these are pure and testable in one line each
const read = (form) => Object.fromEntries(new FormData(form));
const validate = (data) => (data.email.includes("@") ? null : "Bad email");
const send = (data) => fetch("/api", { method: "POST", body: JSON.stringify(data) });
function save(form) {
const data = read(form);
const error = validate(data);
if (error) return showError(error);
return send(data);
}
Return early
Every new rule becomes another guard at the top instead of another level of nesting. The function never grows sideways.
function price(door, options) {
if (!door) return 0;
if (door.archived) return 0;
if (!(door.days > 0)) return 0;
const base = door.days * 100;
return options?.express ? base * 1.5 : base;
}
Make the data shape obvious
render(door, true, false); // true what? false what?
render(door, { compact: true, animate: false }); // now it says
// and a state machine beats a bag of booleans
let isLoading = false, isError = false, isEmpty = false; // 8 combinations,
// 4 of them impossible
let status = "idle"; // "idle" | "loading" | "ready" | "empty" | "error"
Fail loudly, early
A bad value that is silently accepted surfaces three functions later as NaN in the total, and you have to work backwards. Rejecting it at the boundary turns a mystery into a message.
function setDays(door, days) {
if (!Number.isFinite(days) || days <= 0) {
throw new Error(`days must be a positive number, got ${days}`);
}
return { ...door, days };
}
Comments that are worth keeping
Write the comment when you nearly wrote the code differently. A comment explaining a workaround, a constraint or a rejected alternative earns its place forever; one narrating the next line rots the first time the line changes.
// BAD: says what the code already says
// loop over the doors
for (const door of doors) { }
// GOOD: says something the code cannot
// Safari fires resize before the new height is laid out, so we read on the
// next frame. Removing this makes the panel jump on rotation.
requestAnimationFrame(measure);
// GOOD: records a decision
// Sorted by days rather than name because the catalogue is browsed by lead time.
Small, honest functions
- If you cannot see the whole function on one screen, it is doing too much.
- More than three parameters: take an options object.
- A function that takes a callback and returns a promise and mutates its argument is three functions.
- Pure where you can: the same input gives the same output, and nothing else changes. Those are the ones you can test, move and trust.
Consistency beats preference
Semicolons or not, single or double quotes, two spaces or four: none of it matters, and arguing about it costs more than any of the options. Put a formatter in the project, run it on save, and never discuss it again.
A linter is different: it catches real mistakes — an unused variable, a missing await, a ==. That one is worth configuring.
The test that matters
Come back to a file in three months. Can you change one behaviour without reading all of it, and be confident you broke nothing else?
Every rule above is a way of making the answer yes. None of them is worth following when it makes the answer no.