Capstone: build a small library end to end · Lesson 75 of 77 · practice
Error paths: deciding what happens when the entry is wrong
About 85 minutes.
By the end of this lesson you can
- Check a caller's input at the boundary and say exactly what was wrong with it.
- Collect every problem in one pass instead of stopping at the first.
- Choose deliberately between returning a reported failure and throwing.
- Explain why typeof is not enough to prove a value is a usable number.
Two different kinds of wrong
Explanation
When a function gets input it cannot work with, the first question is who made the mistake. A person typing into a form who left the title blank has done something entirely expected - forms get filled in badly all day long, and your library's job is to say what is missing so it can be fixed. A programmer who calls createSession with the string 'thirty' where a number belongs has written a bug; no amount of politeness at runtime will fix their code.
Those two deserve different treatments, and mixing them up is what makes error handling feel like guesswork. Expected failure - bad data from outside - is a result: you hand back a report saying what is wrong, and the caller shows it to the person. A bug in the calling code is not a result: you throw, loudly, so it cannot be ignored and the stack points at the line that did it.
readlog needs both, and they are two separate functions on purpose. validateSession reports. createSession enforces. Keeping them apart means the reporting can be tested without catching anything, and the enforcing is three lines.
Check once, at the boundary
A mental model
This is the same gate as lesson 2, doing its other job. The boundary is wherever data crosses from a place you do not control into your library. Everything outside is suspect; everything inside has been checked. Where exactly you draw that line is a design choice, but there must BE a line, and it must be somewhere you can point at.
The failure mode without one is defensive code everywhere: every function testing its arguments, and every one of them slightly differently. It doubles the size of the code, it hides the actual behaviour among the checks, and it still misses cases, because there is no single place responsible for saying what a valid session is.
With a boundary, the middle of the library is written as if the data were always fine - because past the gate it is. That is not optimism, it is where the checking happens being written down.
Report everything wrong, not just the first thing
Explanation
The lazy validator returns as soon as it finds a problem. The person fixes it, resubmits, and is told about the second problem. Fix, resubmit, third problem. Three rounds where one would have done, and each round feels like the software is toying with them.
So collect. Start an empty array of messages, test each rule, push a message for every rule that fails, and at the end report whether the array is empty. The whole validator becomes a flat list of if statements with no early returns, which also makes it trivial to read: one rule per if, in the order the fields appear.
There is one legitimate early return, and it is the shape check. If the thing handed in is not an object at all, every field test after it would throw rather than report - so that check comes first and, when it fails, it is the only message there is. Checking for null explicitly matters here, because typeof null is famously 'object'.
A message names the field, the rule and the value
Explanation
'Invalid input' is useless. It tells the reader that something is wrong and leaves them to find out what, which is exactly the work they needed help with. A good message names three things: which field, what the rule is, and - when it is safe to show - what arrived instead.
'pages must be a number of 0 or more' names the field and the rule. Adding the value, as the second worked example does, turns it from a rule you have to interpret into an observation you can act on: got '30' with the quotes right there tells you the form sent text where a number belongs.
Keep the wording of a message stable, because things depend on it - your own tests, and sometimes a caller matching on it. Changing 'title must be a non-empty string' to 'the title is required' is not a cosmetic edit; it is a change to your library's behaviour, and it belongs in the same category as changing a return value.
Return a report, or throw: choosing on purpose
Why it matters
Module 7 gave you both tools. The choice between them comes down to one question: can the caller reasonably do something about this? If yes - bad form data, a file with a rubbish line in it - return a value describing the failure, because handling it is normal work, not an emergency. { ok: false, errors: [...] } is a result like any other, and the caller can show it, log it, or skip that row.
If no - the call itself is wrong, the arguments make no sense, continuing would produce a silently wrong answer - throw. Throwing is the loud option, and its loudness is the feature: nobody can ignore it by forgetting to check a field, which is exactly how a returned failure gets missed.
Notice that the same badness can deserve both treatments in different functions, and readlog does exactly that. validateSession reports on a blank title, because reporting is its whole job. createSession throws on the same blank title, because building a record from an entry you were told is invalid would put nonsense into the middle of the library. One rule, two responses, chosen by what each function is for.
typeof will tell you that NaN is a number
A common mistake
The obvious check for a numeric field is typeof value === 'number', and it has a hole in it. When a conversion fails - Number('twelve'), for instance - the result is NaN, which stands for not-a-number and whose typeof is, unhelpfully, 'number'. So the check passes, the value flows into your totals, and every sum that touches it becomes NaN as well. The log reports nothing at all and nothing in the code looks broken.
Number.isFinite is the check that closes the hole. MDN is explicit that it does not convert its argument first: only values that are already numbers, and finite, come back true. NaN is false. Infinity is false. The string '42' is false - which is the right answer for a validator, because text is not a number no matter how numeric it looks.
Watch out for the other conversion trap while you are here: Number('') is 0, not NaN. An empty box in a form therefore becomes a perfectly valid zero if you convert before you check. Convert where you have decided text is acceptable, check where you have decided it is not, and never let the two happen in the same breath.
Recap
Recap
Bad data from outside is expected and gets reported; a wrong call from other code is a bug and gets thrown. Check at one boundary so the middle of the library can stop defending itself. Collect every broken rule in one pass, with a single early return for 'this is not an object at all' - remembering that typeof null is 'object'. Name the field, the rule and, where it helps, the value. And use Number.isFinite rather than typeof, because typeof NaN is 'number' and NaN spreads through every total it touches.
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.
A validator that reports everything at once
Two rules, no early returns, one report. The second call breaks both rules and gets told about both - one round trip instead of two.
1function validate(raw) {2 const errors = [];3 if (typeof raw.title !== "string" || raw.title.trim() === "") {4 errors.push("title must be a non-empty string");5 }6 if (!Number.isFinite(raw.pages) || raw.pages < 0) {7 errors.push("pages must be a number of 0 or more");8 }9 return { ok: errors.length === 0, errors: errors };10}11 12console.log(validate({ title: "Dune", pages: 30 }));13console.log(validate({ title: " ", pages: -3 }));- line 3
Two conditions for one field: it must be text, and it must not be blank once trimmed. A title of spaces is not a title.
- line 6
Number.isFinite first, then the range. Ordering them the other way round would compare NaN with 0, which is false, and let it through.
- line 9
ok is derived from the errors rather than tracked separately, so the two can never disagree.
- line 13
Both problems reported together. Stopping at the first would make the person fix, resubmit, and be told about the next one.
What it prints
{"ok":true,"errors":[]}{"ok":false,"errors":["title must be a non-empty string","pages must be a number of 0 or more"]}
Throwing, with the offending value in the message
The other response to the same kind of badness. Look closely at the printed message: the quotes around 30 are what tell you the form sent text where a number belongs.
1function createSession(raw) {2 if (!Number.isFinite(raw.pages)) {3 throw new TypeError("createSession: pages must be a number, got " + JSON.stringify(raw.pages));4 }5 return { title: raw.title.trim(), pages: raw.pages };6}7 8try {9 createSession({ title: "Dune", pages: "30" });10} catch (error) {11 console.log(error.name);12 console.log(error.message);13}14 15console.log(createSession({ title: " Dune ", pages: 30 }));- line 3
TypeError rather than Error says which kind of mistake it was: the value handed in was of the wrong type. JSON.stringify puts quotes round text, so '30' and 30 look different in the message.
- line 5
Everything below the guard is written as if the data were fine, because by this line it is. That is what a guard buys.
- line 11
A thrown error carries both a name and a message, and both are readable in the catch.
What it prints
TypeErrorcreateSession: pages must be a number, got "30"{"title":"Dune","pages":30}
The hole in typeof, shown four ways
Line 1 is the problem and line 2 is the fix. Lines 3 and 4 are the two conversions that catch people out: empty text becomes zero, and padded text converts perfectly happily.
1console.log(typeof Number("twelve"));2console.log(Number.isFinite(Number("twelve")));3console.log(Number("") === 0);4console.log(Number.isFinite(Number(" 42 ")));- line 1
The conversion failed and produced NaN - whose typeof is 'number'. Any check based on typeof alone has just been fooled.
- line 3
An empty form box converts to 0, not to NaN. Convert first and an empty field becomes a valid zero without anyone deciding that it should.
What it prints
numberfalsetruetrue
The catch that does nothing at all
A try/catch wrapped round code that does not throw. It looks careful and protects nothing: the bad value walks out of the front door as a return value.
1function pagesOf(raw) {2 try {3 return Number(raw.pages);4 } catch (error) {5 return 0;6 }7}8 9console.log(pagesOf({ pages: "12" }));10console.log(pagesOf({ pages: "twelve" }) === 0);11console.log(Number.isFinite(pagesOf({ pages: "twelve" })));- line 4
This catch never runs. Number does not throw on text it cannot convert - it returns NaN - so the safety net is under the wrong trapeze.
- line 10
Not 0, as the author clearly expected. The function returned NaN, and NaN is not equal to anything, including itself.
- line 11
The actual state of affairs: what came back is not a usable number. A check would have caught this; a catch could not.
What it prints
12falsefalse
Check yourself
Not scored, not stored. Getting one wrong is the useful part.
The form sent pages as text. What single line does this print?
function pagesOf(raw) { if (!Number.isFinite(raw.pages)) { throw new TypeError("pages must be a number"); } return raw.pages;}try { pagesOf({ pages: "30" });} catch (error) { console.log(error.name + ": " + error.message);}
This function reports failure instead of throwing it. What do the two calls print?
function parsePages(text) { const value = Number(text); if (!Number.isFinite(value)) { return { ok: false, reason: "not a number: " + text }; } return { ok: true, value: value };}console.log(parsePages("42"));console.log(parsePages("forty two"));
Which check actually proves that pages is a number you can add up?
When should a function return a reported failure rather than throw?
Write it yourself
Exercise · core · 12 tests
validateSession reports, createSession refuses
Nothing in readlog should have to wonder whether its input is sane. Write validateSession(raw), which reports what is wrong, and createSession(raw), which refuses to build a record from a bad entry. A valid entry is an object with a title that is a non-empty string once trimmed, and pages and minutes that are finite numbers of 0 or more. Use these exact messages, in this order: 'title must be a non-empty string', then 'pages must be a number of 0 or more', then 'minutes must be a number of 0 or more'. If raw is not an object at all, that is the only message and it is 'a session must be an object'. createSession returns { title, pages, minutes } with the title trimmed, or throws a TypeError whose message is 'invalid session: ' followed by every message joined with '; '. The starter checks the title only, and its createSession lets everything through.
validateSession returns { ok, errors }: errors is empty when the entry is good, otherwise it holds one message per broken rule in field order - title, then pages, then minutes - using the exact wording in the prompt. Anything that is not an object, including null, gives the single message 'a session must be an object'. Zero is a valid number of pages. createSession returns a record with the title trimmed, or throws a TypeError listing every message. Neither function prints anything.
Define validateSession and createSession at the top level of your code — the tests call them by name.
Exercise · stretch · 10 tests
loadSessions: one bad row must not sink the file
A whole log arrives at once and some of the entries are rubbish. One bad row must not stop the good ones loading, and it must not vanish silently either - somebody has to be told which row was dropped and why. Write loadSessions(rawList) that returns { sessions, rejected }: sessions holds a clean record for every valid entry, in the order they appeared, with the title trimmed; rejected holds one { index, reason } for each entry that failed, where index is its position in the input and reason is the first message from validating it. loadSessions must never throw, whatever is in the list - including null and values that are not objects. Your validateSession from the last exercise is already in the starter; the loadSessions beside it currently lets everything through and reports nothing.
loadSessions returns { sessions, rejected }, both always arrays. sessions holds { title, pages, minutes } for each valid entry, in input order, with the title trimmed. rejected holds { index, reason } for each invalid entry, where index is its position in rawList and reason is the first validation message. An empty list gives two empty arrays. Nothing is printed, nothing is thrown, and the list that was passed in is not changed.
Define loadSessions at the top level of your code — the tests call it by name.
Worth remembering
- How do you decide between reporting a failure and throwing?
Ask whether the caller can reasonably act on it. Expected bad data they can show or skip is a returned result; a call that makes no sense is a bug in their code, so throw and make it impossible to ignore.
- Why collect validation messages instead of returning at the first problem?
So one submission reports every fault at once. Fix-resubmit-repeat is three rounds where one would do. The single exception is the 'not an object' guard, which must return early or the field checks throw.
- Why is typeof value === 'number' not enough at a boundary?
typeof NaN is 'number', so a failed conversion sails through and poisons every total it touches. Number.isFinite does not convert and is false for NaN, Infinity and text.
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: TypeError represents an error thrown when a value is not of the expected type; instances carry name 'TypeError' and the message passed to the constructor.
Check there: Number.isFinite does not convert its argument: only values that are already numbers and are neither Infinity, -Infinity nor NaN return true.
Check there: The catch block runs only when a statement inside the try block throws.
Prefer to read the source in a structured breakdown? Turn this doc into a breakdown