Capstone: build a small library end to end · Lesson 73 of 77 · practice
Choosing the shape of a session
About 90 minutes.
By the end of this lesson you can
- Choose a record shape before writing the functions that use it.
- Normalise messy input once, at the edge, so the rest of the library stays simple.
- Say in a field's name what its value means, including its unit.
- Explain why an empty string splits into a list of one, and handle it.
The shape decides how hard everything else will be
Why it matters
Every function in readlog takes a session, a list of sessions, or produces one. So the shape of a session is not a detail you can fix later — it is the thing seven functions have to agree about. Choose it badly and each of them pays: one has to trim the title, another has to guess whether pages is text or a number, a third has to cope with tags arriving sometimes as a string and sometimes as a list.
Choose it well and most of those functions become two or three boring lines. That is the actual measure of a good data shape: the functions that use it are dull. If your functions are full of 'but what if this field is…' then the shape is wrong, and no amount of clever code will rescue it.
So this lesson comes before any of the real behaviour. You are deciding what a session IS, in exactly five fields, and where the cleaning up happens.
One record answers one question
Explanation
A record is a group of values that belong to the same real thing. A reading session is one sitting with one book: which book, how many pages, how many minutes, what kind of book it was, and whether you finished it. Five fields, one sitting. Everything else — totals, averages, tallies by tag — is worked out from a list of these, never stored inside one of them.
That last rule matters more than it looks. If you put a runningTotal field inside a session, you have two copies of the truth, and the day they disagree you will not know which one to believe. A record holds facts about itself. Anything computed from many records is computed on demand, by a function.
So the canonical session is: title (text), pages (number), minutes (number), tags (a list of text), finished (true or false). That is the whole shape, and every function in the rest of the module can rely on it.
Clean it once, at the door
A mental model
Think of passport control at an airport. Everything messy — different documents, different queues, different languages — is dealt with at one gate. Past that gate, everyone inside the building has been checked in the same way, so nothing further in has to ask again.
Your library needs the same gate. The raw entry comes from a form, so the title may have stray spaces, the numbers may be text ('42' rather than 42) and the tags may be one comma-separated string. One function, toSession, turns that into the canonical shape. Every other function is written as if the world were tidy, because past that gate it is.
The alternative — each function defending itself against every possible mess — is the shape of code that never feels finished. The same trim() ends up in six places, one of them missing a case, and the bug is impossible to locate because there is no single place where the truth is decided.
There is a real cost to this choice, and it is worth naming: converting silently is not the same as validating. toSession will happily turn nonsense into a number and hand it on. Lesson 4 builds the validator that stands beside it. Cleaning and checking are two jobs, and mixing them into one function makes both harder to test.
Put the unit in the name
Explanation
A field called time holding 90 is a small landmine. Ninety what — minutes, seconds, pages, a clock time? Every reader has to go and find out, and one of them will guess wrong. A field called minutes holding 90 needs no explanation and cannot be misread.
This is the cheapest quality improvement in programming. Not durationInMinutes, which is long enough to be annoying, just minutes. Same for pages rather than count, and title rather than name (a name could be the author's).
The rule generalises: if a reader would have to ask a question to use your field, put the answer in the field's name. You are not writing for the compiler, which does not care, but for the next person to open the file.
Flat until nesting is real
Explanation
It is tempting to arrange a record into little boxes: book: { title: 'Dune' }, effort: { pages: 30, minutes: 45 }. It looks organised. It is not, at this size — every reader now writes session.book.title, every function has one more level to get wrong, and the nesting expresses no fact about reading sessions.
Nest when the nested thing is genuinely a thing on its own, with several fields and its own identity that would survive being pulled out. A book has an author, a year and a title, so a bigger version of readlog might well have a book record with sessions pointing at it. Version 1 does not need that, and building it now would be paying for a feature the brief did not ask for.
Flat records also serialise cleanly: JSON.stringify of a flat record is readable, and comparing two of them in a test is straightforward. Both of those matter in the lessons ahead.
An empty string does not split into nothing
A common mistake
Tags arrive as one string, so you split them: 'fiction, classic'.split(',') gives you the pieces, still carrying their spaces, and map with trim() cleans each one. So far so good — and then someone submits the form with the tags box empty.
''.split(',') is not an empty list. It is a list of ONE item: the empty string. MDN states the rule plainly — when the string is empty and the separator is not, split returns an array containing one empty string. So your tidy list of tags now contains a tag that is nothing at all, session.tags.length says 1, and 'how many tags does this have' answers wrongly for every untagged entry in the log.
The fix is one filter: keep only the pieces whose length is greater than zero. That same filter also cleans up 'fiction,,prize' and a trailing comma, which is exactly the kind of thing a real form produces. Add it once, in toSession, and no other function ever meets the problem.
Recap
Recap
The shape comes first, because every function has to agree about it. A record holds facts about one thing; anything computed from many records is a function, not a field. Cleaning happens once, at the door, so the rest of the library can assume a tidy world — but cleaning is not checking, and lesson 4 adds the other half. Put units in field names. Stay flat until nesting expresses a real thing. And remember that splitting an empty string gives you a list of one empty piece, so filter the blanks out.
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.
One messy entry becomes one clean record
The gate itself. Watch each field change: spaces come off the title, text becomes a number, one tag string becomes a list, and a field the form never sent gets a sensible default.
1const raw = { title: " The Hobbit ", pages: "42", minutes: 30, tags: "Fantasy, Classic" };2 3const session = {4 title: raw.title.trim(),5 pages: Number(raw.pages),6 minutes: Number(raw.minutes),7 tags: raw.tags.split(",").map((tag) => tag.trim().toLowerCase()),8 finished: false,9};10 11console.log(session.title);12console.log(typeof raw.pages, typeof session.pages);13console.log(session.tags);14console.log(session);- line 1
This is what a form gives you: spaces nobody typed on purpose, a number that arrived as text, and several tags in one string.
- line 5
Number('42') produces the number 42. Doing it here means no later function has to wonder whether pages can be added to or concatenated onto.
- line 7
split cuts the string into pieces at each comma; map runs trim and toLowerCase over every piece, so 'Fantasy' and ' fantasy' end up as the same tag.
- line 12
Two arguments, one line, joined by a space: the raw field is still text, the canonical one is a number.
What it prints
The Hobbitstring number["fantasy","classic"]{"title":"The Hobbit","pages":42,"minutes":30,"tags":["fantasy","classic"],"finished":false}
The empty-string split, seen for yourself
The trap from the common-mistake section, run rather than described. Line 1 is the surprise; line 3 is the whole fix.
1console.log("".split(","));2console.log("".split(",").length);3console.log("".split(",").filter((tag) => tag.length > 0));4console.log("fiction".split(",").filter((tag) => tag.length > 0));- line 1
A list of one empty string, not an empty list. Printed as [""] - the quotes are JSON showing you there is an item there, and it is empty.
- line 3
Keeping only the pieces with something in them. This single filter also handles 'a,,b' and a trailing comma.
What it prints
[""]1[]["fiction"]
Totals live in a function, not in the record
Pages per title, worked out from the list on demand. Nothing is stored twice, so nothing can disagree with itself later. This is the shape lesson 3's exercise builds properly.
1const sessions = [2 { title: "Dune", pages: 30 },3 { title: "Dune", pages: 12 },4 { title: "Emma", pages: 20 },5];6 7const pagesByTitle = {};8for (const session of sessions) {9 pagesByTitle[session.title] = (pagesByTitle[session.title] ?? 0) + session.pages;10}11 12console.log(pagesByTitle);13console.log(Object.keys(pagesByTitle).sort());14console.log(pagesByTitle["Dune"]);15console.log(typeof pagesByTitle["Middlemarch"]);- line 9
?? gives the left side unless it is null or undefined, so the first session for a title starts from 0 instead of needing an 'is this key here yet' branch.
- line 13
Sorting the keys makes the output stable no matter what order the sessions arrived in - which is what makes a result like this testable.
- line 15
A title nobody read is not zero, it is absent: reading a property that was never set gives undefined.
What it prints
{"Dune":42,"Emma":20}["Dune","Emma"]42undefined
Check yourself
Not scored, not stored. Getting one wrong is the useful part.
Tags arrive from a form with a stray double comma. What do these two lines print?
const tags = " Fiction ,, prize ".split(",").map((tag) => tag.trim().toLowerCase());console.log(tags);console.log(tags.filter((tag) => tag.length > 0));
Why clean the raw entry once, inside toSession, rather than in each function that uses a session?
A session records that you read for ninety minutes. Which field is better, and why?
Write it yourself
Exercise · core · 12 tests
The gate: one messy entry in, one canonical record out
Entries arrive from a form, so they are messy: the title carries stray spaces, pages and minutes may be text rather than numbers, and the tags are one comma-separated string that is sometimes blank or missing altogether. Write toSession(raw) that returns the canonical record the rest of readlog will rely on: title trimmed, pages and minutes as numbers, tags as a list of lowercase pieces with the blanks dropped, and finished as a real true or false. Assume the entry is otherwise sensible - checking whether it makes sense is lesson 4's job, and keeping the two apart is deliberate. The starter code returns the right five fields with none of the cleaning done, so it passes the tests that were already true and fails the rest.
toSession returns an object with exactly five fields: title, pages, minutes, tags, finished. title is raw.title with the outer spaces removed and the inner ones kept. pages and minutes are numbers, converted when they arrive as text. tags is an array of trimmed lowercase pieces in the order they appeared, and is empty when raw.tags is blank or missing. finished is true only when raw.finished is exactly true. Nothing is printed and raw is not changed.
Define toSession at the top level of your code — the tests call it by name.
Worth remembering
- What belongs in a record, and what does not?
Facts about one thing. Anything computed from many records - totals, averages, tallies - is a function you call, never a field you store, so two copies of the truth can never disagree.
- Where does messy input get cleaned up?
Once, in one function at the edge. Past that gate every other function can assume the canonical shape, and there is a single place to change when the rule changes.
- What is ''.split(',')?
A list of one empty string, not an empty list. Filter on length > 0 after splitting, which also removes the blanks from 'a,,b' and a trailing comma.
Check this lesson against the source
We wrote the explanation; we did not invent the facts. This is the page that backs them.
Check there: Objects are created with a literal listing field names and values, properties can be added later by assignment, and nonexistent properties have the value undefined (not null).
MDN — String.prototype.split()
Check there: When the string is empty and a non-empty separator is given, split returns an array containing one empty string.
Check there: trim removes whitespace from both ends and returns a new string; the original is unchanged.
Check there: Object.keys returns an array of the object's own enumerable property names.
Prefer to read the source in a structured breakdown? Turn this doc into a breakdown