Errors, validation, and defensive code · Lesson 45 of 77 · practice
Validating input at the boundary
About 55 minutes.
By the end of this lesson you can
- Name the boundary of a program and say why input is checked there and not everywhere.
- Write guard clauses that reject bad input before any real work starts.
- Check the three things typeof cannot tell apart: null, arrays and NaN.
- Collect every problem with an input instead of reporting only the first.
Where the outside meets the inside
A mental model
Draw a line around your program. Outside it is everything you do not control: what a person typed, what a file contained, what another system sent. Inside it is code you wrote, calling code you wrote.
Every value crossing that line inwards is unknown until you check it. Every value already inside has been checked, once, at the crossing. That is the boundary, and putting the checks there is what lets the rest of the code be short.
The alternative — checking in every function, just in case — sounds safer and is not. Every function ends up half-trusting its caller, none of them state who is responsible, the checks disagree with each other as the rules change, and there are still gaps because nobody can remember which functions are entry points. One check in a known place beats twenty checks in unknown ones.
Guard clauses come first
Explanation
A guard clause is a check at the very top of a function that rejects an input before any work is done. It either throws or returns immediately; it never falls through into the body.
The shape matters more than it looks. With guards, the failures are a flat list at the top and the real work is the last thing in the function, unindented, easy to read. Without them, the real work sits at the bottom of a staircase of nested ifs, and adding a rule means re-indenting everything.
Guards also fix the ordering problem. Each one is allowed to assume every guard above it has passed, so by the time you reach the body you can write straightforward code without asking what if this is null on every line.
typeof cannot see three things
A common mistake
typeof null is "object". This is a bug from 1995 that was kept deliberately, because fixing it would break the web. It means a null slipping through a typeof value === "object" check is not an edge case, it is the normal outcome.
typeof [] is "object" as well. Arrays are objects, so no amount of typeof will distinguish a settings object from a list. Array.isArray is the answer and there is no other reliable one.
typeof NaN is "number", which is the cruellest of the three. NaN passes every number check written with typeof, and then every comparison involving it is false — NaN < 13 is false and NaN > 120 is also false, so a range check silently reports that everything is fine.
The three reliable tests are value === null, Array.isArray(value), and Number.isFinite(value) or Number.isInteger(value). None of them convert their argument, which is the property you want: Number.isFinite("3") is false, whereas the older global isFinite("3") is true.
Normalise, then validate — and know the difference
Explanation
Normalising means putting a value into a standard form before checking it: trimming the spaces off the ends of typed text, lowercasing an email address before comparing it. Validating means deciding whether the result is acceptable. Normalise first, or you will reject perfectly good input for having a trailing space.
There is a line here that matters. Trimming whitespace cannot change what a person meant, so doing it silently is safe. Converting the text "36" into the number 36 can change what they meant, and it comes with a set of surprises: Number("") is 0, Number(" ") is 0, and Number(true) is 1. A blank box would quietly become a valid age of zero.
So the rule is: normalise what cannot change the meaning, and validate everything else rather than repairing it. When you do choose to convert, say so in the function's name or its documentation, so that the next reader knows the value they get back is not the value that was typed.
Report every problem, not just the first
Why it matters
A form that reports one problem at a time is a form people abandon. Fix the name, submit, discover the age is wrong, fix it, submit, discover the email is wrong. Three round trips for information the program had all along after the first one.
The technique is small: instead of returning at the first failure, push a message onto an array and carry on, then return the array. Empty means valid. The caller decides how to show them.
This is why validation and guard clauses are different jobs even though they look alike. A guard clause protects the function from a caller's bug and should stop immediately, because nothing after it is meaningful. Validation examines data a person legitimately got wrong and should keep going, because everything after it is still worth checking.
The shape to remember
Recap
One: guard the arguments. Wrong kind of thing means a bug in the caller, so throw.
Two: normalise what is safe to normalise.
Three: check each field, pushing a message per problem rather than stopping.
Four: return the list of problems, and let the caller decide what to do with it. Inside the boundary, everything that follows can assume the data is good.
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.
The same rules, nested and guarded
Two functions with identical behaviour. Compare their shapes, and imagine adding a fourth rule to each.
1function nested(order) {2 if (order !== null && typeof order === "object") {3 if (typeof order.sku === "string") {4 if (Number.isInteger(order.quantity)) {5 return order.sku + " x" + order.quantity;6 }7 }8 }9 return "invalid order";10}11 12function guarded(order) {13 if (order === null || typeof order !== "object") return "invalid order";14 if (typeof order.sku !== "string") return "invalid order";15 if (!Number.isInteger(order.quantity)) return "invalid order";16 return order.sku + " x" + order.quantity;17}18 19console.log(nested({ sku: "TEA", quantity: 2 }));20console.log(guarded({ sku: "TEA", quantity: 2 }));21console.log(guarded({ sku: "TEA", quantity: 2.5 }));- line 5
The real work of the nested version, three levels deep. A fourth rule pushes it deeper still and re-indents everything below it.
- line 13
Each guard is one line, states one rule, and leaves. A reader can check the rules against a specification without tracking any state.
- line 16
The real work of the guarded version: last line, no indentation, and it can assume all three rules passed.
- line 21
2.5 is a number but not a whole one, so Number.isInteger rejects it. Number.isInteger(2.5) is false without any conversion.
What it prints
TEA x2TEA x2invalid order
The three blind spots, and the tests that work
The first three lines are what typeof says. The last four are what to use instead. Worth reading twice.
1console.log(typeof null);2console.log(typeof []);3console.log(typeof NaN);4 5console.log(null === null);6console.log(Array.isArray([]));7console.log(Number.isNaN(NaN));8console.log(Number.isFinite("3"));- line 1
The famous one. Any check of the form typeof x === "object" is also true for null, so null gets through unless you test for it separately.
- line 2
Arrays are objects. If a function expects a settings object, an array will pass a typeof check and then behave nothing like one.
- line 3
NaN is a number by type. It will pass any typeof number check you write and then make every comparison false.
- line 8
false, because Number.isFinite does not convert. The older global isFinite("3") is true — that conversion is exactly what you do not want at a boundary.
What it prints
objectobjectnumbertruetruetruefalse
Collecting problems instead of stopping
Two fields, two rules, and one array. The valid case reports nothing; the invalid case reports both problems in one pass.
1function problemsWith(login) {2 const problems = [];3 4 if (typeof login.user !== "string" || login.user.trim() === "") {5 problems.push("user must not be empty");6 }7 8 if (typeof login.pin !== "string" || !/^\d{4}$/.test(login.pin)) {9 problems.push("pin must be four digits");10 }11 12 return problems;13}14 15console.log(problemsWith({ user: "ada", pin: "1234" }).length);16console.log(problemsWith({ user: " ", pin: "12" }).join(" | "));- line 4
The type test comes first in the same condition. Because || stops as soon as the left side is true, .trim() is only reached when user really is a string.
- line 4
Trimming before measuring is what makes three spaces count as empty. Validating the untrimmed text would have accepted it.
- line 8
Four digits and nothing else. The anchors at each end are the whole point: without them, "12345" and "pin1234" would both pass.
- line 12
An empty array means valid. There is no separate ok flag to keep in step with the list, which is one fewer thing to get wrong.
What it prints
0user must not be empty | pin must be four digits
Check yourself
Not scored, not stored. Getting one wrong is the useful part.
Three typeof results and one comparison. Two of the four lines surprise almost everybody the first time.
console.log(typeof null);console.log(typeof []);console.log(typeof NaN);console.log(NaN > 120);
A form calls saveProfile, which calls normaliseProfile, which calls writeRecord. Where should the input be validated?
What is Number.isFinite("3")?
Write it yourself
Exercise · core · 12 tests
Validate a sign-up form
Write validateSignup(form) for a sign-up screen. It returns an array of problems — empty when everything is acceptable — and it reports every problem at once rather than stopping at the first. Guard first: if form is not a plain object (null, an array, a string, a missing argument), throw a TypeError whose message starts with "validateSignup: ". A caller passing the wrong kind of thing has a bug, which is a different situation from a user typing their age wrong. Then check three fields, pushing these exact messages in this order. - "name must be at least 2 characters" — name must be a string with at least two characters once the spaces at each end are trimmed. - "age must be a whole number", and then, only if it is one, "age must be between 13 and 120" when it falls outside 13 to 120 inclusive. - "email must contain @" — email must be a string containing an @.
validateSignup returns an array of strings in field order: name, then age, then email. Age contributes at most one message. A missing field is treated exactly like a wrong one. Valid input returns an empty array. A form that is not a plain object throws a TypeError instead of returning anything.
Define validateSignup at the top level of your code — the tests call it by name.
Worth remembering
- Which three values does typeof describe misleadingly?
typeof null is "object", typeof [] is "object", and typeof NaN is "number". Use value === null, Array.isArray(value), and Number.isFinite or Number.isInteger instead — none of those convert their argument.
- Where should untrusted input be validated?
At the boundary — the one function the data enters through. Inside that line, code assumes the data is already good. Validating in every function makes each one half-trust its caller, lets the copies of the rules drift apart, and still leaves gaps.
- How does a guard clause differ from validation?
A guard clause protects a function from a caller's bug and stops immediately, because nothing after it is meaningful. Validation examines data a person legitimately got wrong and keeps going, collecting every problem so the user sees them all at once.
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: The results table showing "object" for null and for arrays and "number" for NaN, and the note that typeof null is a long-standing behaviour retained for compatibility.
Prefer to read the source in a structured breakdown? Turn this doc into a breakdown