Writing code you can read later

Write code the next person — probably you — can change without fear.

30of 32
≈ 11Min read
9Parts

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, not doorList.
  • Short names for short lives — i, d in a one-line callback — long names for long lives.
  • If the name needs "And", it is two functions.
the same function, twice
// 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

separate the decision from the effect
// 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.

guards at the top, the real work unindented
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

a boolean argument tells the reader nothing
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.

a wrong value should not travel
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.

why, not what
// 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.

Practice