Equality, null, and failing fast
JavaScript almost never stops you. Add a number to a string and you get a
string. Read a missing property and you get undefined. Call a function with no
arguments and it runs. This is convenient for five minutes and then it is the
reason a bug shows up four functions away from its cause.
Defensive code is not checking everything everywhere. It is being precise about where you validate, and then trusting yourself past that line.
Validate at the boundary
A boundary is anywhere data arrives from outside your control: a form, a URL, a
fetch response, localStorage, an event. Check it once, there, and convert
it into something your own code can trust.
// scattered — every function distrusts the last
function render(orders) {
if (!Array.isArray(orders)) return;
for (const o of orders) {
if (!o || typeof o.customer !== "string") continue;
…
}
}
// at the boundary
function parseOrders(raw) {
const data = JSON.parse(raw);
if (!Array.isArray(data)) throw new TypeError("stored orders are not an array");
return data.map(parseOrder); // throws on the first bad one
}
After parseOrders, everything downstream is an array of valid orders and says
so by not checking. A function that validates its own inputs everywhere is
telling you its callers cannot be trusted — fix that instead.
undefined is the whole game
Three operators do most of the work, and the differences matter.
const settings = { pieces: 0, note: "", express: false };
settings.pieces || 1 // 1 — wrong, 0 is a real answer
settings.pieces ?? 1 // 0 — right
settings.note || "none" // "none" — wrong, "" was deliberate
settings.note ?? "none" // "" — right
|| falls back on every falsy value: 0, "", false, NaN. ?? falls back
only on null and undefined. For anything that can legitimately be zero,
empty or false — and money, counts and checkboxes all can — ?? is the one you
want. This is the single most common real bug in modern JavaScript.
Optional chaining, similarly:
order?.customer?.name // undefined instead of a TypeError
orders?.[0]?.pieces
callback?.() // calls it only if it exists
Useful — and easy to overuse. a?.b?.c?.d usually means you do not know your own
data shape. Ask whether a can really be missing; if it cannot, a.b is honest
and fails loudly when you are wrong.
Fail loudly, early
function billPaise(ratePaise, count) {
return ratePaise * count;
}
billPaise(4500, "2"); // 9000 — works by accident
billPaise(4500, undefined); // NaN — silently poisons every sum after it
NaN is the worst kind of failure: it propagates. NaN + 1 is NaN,
total += NaN makes the total NaN forever, and the error surfaces in the
invoice total, not here.
function billPaise(ratePaise, count) {
if (!Number.isInteger(count) || count < 0) {
throw new TypeError(`count must be a non-negative integer, got ${JSON.stringify(count)}`);
}
return ratePaise * count;
}
Three things about that message: it says what was expected, it says what
arrived, and JSON.stringify distinguishes "2" from 2 and shows undefined
as text. got ${count} would have printed a bare 2 for a string.
Number.isInteger also quietly handles what typeof x === "number" does not:
NaN and Infinity are both numbers.
Errors
try {
await save(order);
} catch {
// swallowed. The order did not save and nobody knows.
}
An empty catch is a decision to lose information. If you genuinely mean to
ignore it, say so — catch { /* cache write is best-effort */ }.
try {
await save(order);
} catch (error) {
console.error("Could not save order", order.id, error);
showMessage("Could not save. Please try again.");
}
Log the detail, show the person something they can act on. A stack trace in
the UI helps nobody, and "Error: ECONNREFUSED" is not a sentence a customer can
read.
Catch narrowly:
// too wide — a typo inside parse() is now "invalid JSON"
try {
const data = JSON.parse(raw);
render(data);
} catch {
showMessage("Saved data is corrupt.");
}
// only the parse is guarded
let data;
try {
data = JSON.parse(raw);
} catch {
showMessage("Saved data is corrupt.");
return;
}
render(data);
And do not throw strings. throw "bad input" gives you no stack trace and no
.message. Throw Error — or a subclass, which lets a caller tell one failure
from another:
class ValidationError extends Error {
constructor(field, message) {
super(message);
this.name = "ValidationError";
this.field = field;
}
}
catch (error) {
if (error instanceof ValidationError) return showFieldError(error.field, error.message);
throw error; // not ours — let it go up
}
Re-throwing what you cannot handle is part of handling errors well. A
catch that absorbs everything hides the bugs you most need to see.
Async failures
save(order); // unhandled rejection, no error in your UI
await save(order); // throws where you can catch it
A promise you do not await is a promise whose failure you do not hear about.
Either await it or attach .catch(). If you are deliberately firing and
forgetting — analytics, say — write the .catch() anyway, even if it only logs.
const [a, b] = await Promise.all([loadOrders(), loadMenu()]); // one failure rejects both
const results = await Promise.allSettled([loadOrders(), loadMenu()]); // each reports itself
Promise.all is right when you need everything. allSettled is right when a
partial result is still useful — a dashboard that can render orders while the
menu is down.
Immutability, and its limit
function markDelivered(order) {
order.delivered = true; // mutates the caller's object
return order;
}
The caller did not ask for their data to change, and if anything else holds a reference to that order, it just changed underneath them.
function markDelivered(order) {
return { ...order, delivered: true };
}
Two things to know. Object.freeze is shallow — frozen.items.push(x) still
works. And in non-strict code, assigning to a frozen property fails silently;
in a module (always strict) it throws. Both are the kind of surprise you want to
meet in a lesson rather than in production.
const order = Object.freeze({ id: "1", items: [] });
order.items.push("atta"); // works — the array was never frozen
order.id = "2"; // TypeError in a module; silent in a script
Do not over-defend
function total(orders) {
if (orders === null) return 0;
if (orders === undefined) return 0;
if (!Array.isArray(orders)) return 0;
if (orders.length === 0) return 0;
…
}
Four guards that all return 0, so a caller who passes rubbish gets a total of
zero and never learns. null is not "no orders" — it is a bug, and a TypeError
with a stack trace is more useful than a plausible wrong answer.
A guard that hides a bug is worse than no guard. Validate at the boundary, throw for programmer errors, and let internal functions assume they were called correctly.
Check your work
Where to validate: at the boundary, once, converting into something trustworthy.
|| versus ??: || falls back on 0, "" and false too; ?? only on
null/undefined.
Why NaN is the worst failure: it propagates silently and surfaces far from
its cause.
What a good error message has: what was expected, what arrived, and enough
quoting to tell "2" from 2.
Why Number.isInteger over typeof: NaN and Infinity are numbers.
Why catch narrowly: a wide try blames the wrong thing.
Why re-throw: a catch that absorbs everything hides your bugs.
Why throw Error, not a string: no stack, no .message.
Promise.all versus allSettled: all-or-nothing versus partial results.
Two traps in Object.freeze: it is shallow, and it fails silently outside
strict mode.
When a guard is harmful: when it returns a plausible answer for input that was a bug.
Practice
- Write
settings.pieces || 1withpieces: 0and explain the output. Fix it. - Find every
||default in your capstone and decide if each should be??. - Call a function with
undefinedand follow theNaNthrough three more calls. Note where the error first became visible. - Add a throwing guard and confirm it fires at the call, not later.
- Write an error message including
JSON.stringifyof the bad value. - Find an empty
catch. Either log or comment why it is empty. - Narrow a wide
tryblock down to the one line that can fail. - Throw a string, catch it, and try to read
.stack. - Write a
ValidationErrorsubclass and handle only that, re-throwing the rest. Object.freezean object with an array and push to the array.- Assign to a frozen property in a module and in a plain script. Compare.
- Break one of two parallel requests and compare
Promise.allwithPromise.allSettled.
Next: the review that catches what tests do not.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship