Capstone: build a small library end to end · Lesson 76 of 77 · practice

Refactoring: changing the shape without changing the answer

About 80 minutes.

By the end of this lesson you can

  • Change the shape of working code without changing what it does.
  • Extract a helper whose name says what it computes.
  • Recognise an extraction that makes the code harder to read, and stop.
  • Use your checks as the safety net that makes a refactor honest.

Refactoring means the behaviour does not change. At all.

Explanation

Refactoring is changing the shape of code that already works, while its behaviour stays exactly the same. Same inputs, same outputs, same errors, same everything an outsider could observe. Only the arrangement changes: where things live, what they are called, which parts have been given names.

That definition is strict on purpose, because the word gets used loosely for 'rewriting while also adding things', and those two activities have opposite risk profiles. When behaviour is fixed, your checks are a safety net: they were green before, they must be green after, and any red one is a mistake you just made. Change behaviour at the same time and the net is gone - a failing check might be the new behaviour or might be a bug, and you cannot tell which.

So the rule is: refactor, run the checks, commit the improvement. Then, separately, change behaviour. Two small safe steps rather than one large unverifiable one.

Without checks it is not refactoring, it is hoping

Why it matters

The reason lesson 3 came before this one is that a refactor without checks is a bet. You believe the extracted version does the same thing. Belief is not evidence, and the bugs introduced this way are the quiet kind - an off-by-one in a boundary you did not think about, a case that used to be handled by an accident of the old shape.

With checks, the loop is mechanical and almost boring: make one small change, run them, all green, keep going. If something goes red you undo the last change rather than debugging it, because you know exactly which change caused it - it was the one you just made.

This is also the moment the work of lesson 3 pays off. The checks you wrote to design the functions become the thing that lets you improve them for years afterwards. That is why test-first is not a chore imposed by someone else: it is the reason you can still change your own code six months later.

What to look for before you change anything

Explanation

A comment explaining what a block does is usually a name asking to be born. If you find yourself writing '// work out the pages per hour', that block wants to be a function called pagesPerHour, and the comment can go, because the name now says it.

A variable called out, data, temp or result2 tells you the author had not decided what it was. Every one of those is a small tax on the next reader, who has to hold 'out means the totals so far' in their head while reading everything else.

A loop doing three unrelated jobs at once is harder to read than three obvious things, even though it walks the list only once. And a function you have to scroll to see is usually two or three functions that were never separated. None of these is a rule violation - they are hints. The judgement stays yours.

Extracting: the actual mechanics

Explanation

Pick the lines that do one identifiable thing. Ask what they need from around them - those become the parameters. Ask what they produce - that becomes the return value. Move them into a new function, name it after WHAT it produces rather than how, and call it from where they used to be. Run the checks. That is the whole move.

The most common hesitation is where to put the new function, and JavaScript settles it: function declarations are hoisted. MDN's page on the function statement says so and shows a function being called on the line above its own declaration. So you can put helpers below the function that uses them and read the file top-down - the important thing first, the details underneath.

Names deserve more thought than the mechanics. pagesPerHour says what comes back. calculateRate makes you ask 'rate of what, in what units'. doStuff is a confession. The best name is the shortest one that would let a reader skip the body entirely - because most readers, most of the time, should not have to open it.

The extraction that makes things worse

A common mistake

Once extracting feels good it is easy to overdo, and the result is code where nothing happens anywhere. A function called addOne(value) that returns value + 1 has replaced something everybody understands with something they now have to go and look up, on the promise of a name that says no more than the code did.

The test to apply: does the name tell the reader more than the line it replaced? pagesPerHour(pages, minutes) does - it names a domain idea, and the arithmetic inside it is fiddly enough to be worth hiding. isPositive(value) barely does. addOne(total) does not, and it costs a jump to nowhere.

The other end of the same mistake is a helper with six parameters, which usually means you cut the code in a place where it did not want to be cut. If a helper needs half the surrounding function's variables to do its job, the seam is in the wrong place - put it back and look for a better one.

A name is the shortest true sentence about a value

A mental model

Useful way to hold it: a name is a promise about what is inside. best is a promise you cannot check - best what, by what measure? topTitle promises a title, chosen by being top of something. rate promises nothing at all; pagesPerHour promises a number in units you can say out loud.

Names are also where a domain lives. readlog talks about sessions, pages, minutes, tags and titles - not items, values, counts and strings. When the code uses the same words as the brief, the distance between what someone asked for and what you built is small enough to see across.

And when you genuinely cannot name something, that is information: it usually means the thing does not have one job. Splitting it until each piece can be named is not a naming exercise, it is a design one.

Recap

Recap

Refactoring changes shape and never behaviour, which is what lets your checks tell you whether you got it right - so refactor and change behaviour in separate steps. Look for comments that want to be names, vague variable names, and loops doing several jobs. Extract by asking what the lines need (parameters) and what they produce (a return value), and put the helpers underneath, because function declarations are hoisted. Stop when the name would say no more than the code it replaced.

Worked examples

Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.

Before: one function doing four jobs

This works, and every test of it passes. That is exactly the situation refactoring is for - the problem is not correctness, it is that you have to read all twenty lines to answer any question about it.

1function report(sessions) {2  let pages = 0;3  let minutes = 0;4  const totals = {};5  for (const session of sessions) {6    pages += session.pages;7    minutes += session.minutes;8    totals[session.title] = (totals[session.title] ?? 0) + session.pages;9  }10  let best = null;11  for (const title of Object.keys(totals).sort()) {12    if (best === null || totals[title] > totals[best]) {13      best = title;14    }15  }16  let rate = 0;17  if (minutes > 0) {18    rate = Math.round((pages / (minutes / 60)) * 10) / 10;19  }20  return sessions.length + " sessions, " + pages + " pages, " + rate + " pages/hour";21}22 23console.log(report([{ title: "Dune", pages: 60, minutes: 30 }]));24console.log(report([{ title: "Dune", pages: 50, minutes: 90 }, { title: "Emma", pages: 42, minutes: 90 }]));
  • line 5

    One loop, three unrelated jobs: two totals and a tally. Walking the list once is not a good enough reason to make a reader untangle three ideas.

  • line 11

    Sorting the keys before comparing is what makes a tie deterministic - the alphabetically first title wins. Buried in the middle of a long function, that decision is invisible.

  • line 18

    Multiply by 10, round, divide by 10: one decimal place. Correct, and it means nothing until you have read it twice.

What it prints

1 sessions, 60 pages, 120 pages/hour2 sessions, 92 pages, 30.7 pages/hour

After: the same two lines of output, four named ideas

Identical behaviour - compare the expected output with the previous example, character for character. What changed is that each idea now has a name, and report reads like a sentence.

1function totalPages(sessions) {2  return sessions.reduce((total, session) => total + session.pages, 0);3}4 5function totalMinutes(sessions) {6  return sessions.reduce((total, session) => total + session.minutes, 0);7}8 9function pagesPerHour(pages, minutes) {10  if (minutes <= 0) {11    return 0;12  }13  return Math.round((pages / (minutes / 60)) * 10) / 10;14}15 16function report(sessions) {17  const pages = totalPages(sessions);18  const rate = pagesPerHour(pages, totalMinutes(sessions));19  return sessions.length + " sessions, " + pages + " pages, " + rate + " pages/hour";20}21 22console.log(report([{ title: "Dune", pages: 60, minutes: 30 }]));23console.log(report([{ title: "Dune", pages: 50, minutes: 90 }, { title: "Emma", pages: 42, minutes: 90 }]));
  • line 10

    The guard that used to be an if wrapped round the arithmetic. Now it lives with the thing it protects, and the answer for 'no minutes' is stated in one place.

  • line 13

    The same fiddly rounding, now behind a name that says what comes out. Nobody has to read this line to use it.

  • line 17

    report is now three lines you can read aloud. The list is walked twice rather than once - a real cost, and at this size an irrelevant one.

What it prints

1 sessions, 60 pages, 120 pages/hour2 sessions, 92 pages, 30.7 pages/hour

Why helpers can live below the function that uses them

The rule that makes top-down files possible: a function declaration is available before the line it is written on, so the important function can come first.

1console.log(describe(3));2 3function describe(count) {4  return count + " sessions";5}
  • line 1

    Called before it is declared, and it works. MDN's function page states this directly and shows the same example - declarations are hoisted to the top of their scope.

What it prints

3 sessions

Extraction taken one step too far

Two helpers that add nothing. Read countPositive and notice how far you have to travel to learn that it counts the values above zero - a journey the original three lines did not require.

1function isPositive(value) {2  return value > 0;3}4 5function addOne(value) {6  return value + 1;7}8 9function countPositive(values) {10  let total = 0;11  for (const value of values) {12    if (isPositive(value)) {13      total = addOne(total);14    }15  }16  return total;17}18 19console.log(countPositive([3, -1, 0, 8]));
  • line 5

    The name says exactly what the code said, one indirection further away. total += 1 needed no explanation.

  • line 13

    The test to apply: does the name tell the reader more than the line it replaced? Here, no - so put it back.

What it prints

2

Check yourself

Not scored, not stored. Getting one wrong is the useful part.

The helper is declared after the line that calls it. What happens?

console.log(rate(60, 30));function rate(pages, minutes) {  return pages / (minutes / 60);}

Which of these is a refactor?

When should you leave code inline instead of extracting a helper?

Write it yourself

Exercise · core · 10 tests

Take report apart without changing a character of its output

The report function in the starter works: every string it returns is correct, and two hidden tests check exactly that. It is also twenty lines doing four jobs. Refactor it so that two ideas have names of their own, and so that report calls them rather than repeating the work. pagesPerHour(pages, minutes) returns the pages per hour rounded to one decimal place, and 0 when minutes is 0 or less. topTitle(sessions) returns the title with the most pages in total, breaking a tie by taking the alphabetically first, and null for an empty list. report must return exactly the same strings it does now. Declare both helpers with the function keyword: one hidden test swaps pagesPerHour for a stand-in - the test-double trick from module 11 - to confirm that report really calls it instead of keeping its own copy of the arithmetic.

What your code must do

report returns exactly the strings it returned before, in the form '2 sessions, 92 pages, 30.7 pages/hour, top title: Dune', and 'top title: none' for an empty log. pagesPerHour(pages, minutes) gives the pages per hour rounded to one decimal place, and 0 when minutes is 0 or less. topTitle(sessions) gives the title with the most pages in total, choosing the alphabetically first when two tie, and null for an empty list. Both helpers are function declarations, and report calls them.

Define report, pagesPerHour and topTitle at the top level of your code — the tests call them by name.

Runs on your device in a sandbox with no network and no access to this page.

Worth remembering

  • What is the one thing a refactor must not do?

    Change behaviour. Same inputs, same outputs, same errors - which is what lets the existing checks judge the change: green before, green after, any red one is a mistake you just made.

  • Why can a helper be written below the function that calls it?

    Function declarations are hoisted to the top of their scope, so they exist before the line they are written on. A function stored in a const is not usable before its own line.

  • What is the test for whether an extraction is worth doing?

    Does the name tell the reader more than the line it replaced? pagesPerHour does; addOne(total) does not, and costs a jump to nowhere.

Check this lesson against the source

We wrote the explanation; we did not invent the facts. This is the page that backs them.

MDN — function declaration

Check there: Function declarations are hoisted to the top of the enclosing function or global scope, so a declared function can be called before the line it appears on; function expressions are not.

MDN — Functions guide

Check there: A function takes parameters, returns a value with return, and can be called from any code in the scope where it is defined.

MDN — Math.round()

Check there: Math.round returns the value rounded to the nearest integer.

Prefer to read the source in a structured breakdown? Turn this doc into a breakdown

0 of 1 exercise in this lesson solved on this device

Consent version 2026-07-31.1

Cookie preferences