Topic 13.5
Defensive Coding
In one line
Defensive code checks what comes in before using it: guard clauses reject bad input at the top of a function, optional chaining and ?? handle values that may be missing, and anything unexpected fails loudly instead of quietly producing wrong results.
Think of it like this
A careful cook checks the ingredients before starting. Is the milk still fresh? Are there really three eggs, not two? If something is wrong, the cook says so before mixing anything, not after the cake comes out flat. And if a recipe says "a pinch of salt, if you have it", the cook knows that step is optional. Defensive code checks its ingredients first and knows which ones are optional.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Defensive coding
- Writing code that checks its input and expects things to go wrong.
- Validate
- Check that a value is the type and size you need before using it.
- Boundary
- The place where outside data (forms, URLs, servers) enters your code.
- Guard clause
- An
ifat the top of a function that handles a bad case and leaves at once. - Optional chaining (?.)
- Reads a property but gives
undefinedinstead of an error if the value before it isnullorundefined. - Nullish coalescing (??)
- Gives a default only when the value on its left is
nullorundefined. - Silent failure
- Something goes wrong but nothing reports it, so the result is quietly wrong.
- Assertion
- A check that something must be true, which throws an error if it isn't.
- Object.freeze
- Makes an object's own properties read-only (only the top level).
Step by step
01Start from nested ifs
This works, but the real calculation is buried three levels deep, and the special cases are spread around.
function shippingCost(order) {
if (order) {
if (order.items.length > 0) {
if (order.country === "IN") {
return order.weight > 5 ? 100 : 50;
} else {
return 500;
}
}
}
}
// shippingCost(null) returns undefined: a silent failure02Turn the checks into guard clauses
Each special case is now one line at the top that leaves the function. A missing order throws instead of quietly returning undefined. The main rule sits flat at the end.
function shippingCost(order) {
if (order == null) throw new TypeError("shippingCost needs an order");
if (!Array.isArray(order.items) || order.items.length === 0) return 0;
if (order.country !== "IN") return 500;
return order.weight > 5 ? 100 : 50;
}03Validate outside data at the boundary
Form fields are strings. Convert once, check once, and give a message the user understands. After this function, the rest of the code can trust that age is a sensible whole number.
function parseAge(text) {
const trimmed = String(text).trim();
if (trimmed === "") throw new Error("Age is required");
const age = Number(trimmed);
if (!Number.isInteger(age)) throw new TypeError("Age must be a whole number");
if (age < 0 || age > 130) throw new RangeError("Age must be from 0 to 130");
return age;
}04Handle optional data with ?. and ??
A user from a server may have no address. That's normal, not a bug, so read it with ?. and supply a default with ??.
const city = user.address?.city ?? "Unknown city";
const volume = user.settings?.volume ?? 50; // keeps a real 0
user.onSave?.(); // call it only if it exists05State what must be true with an assertion
Some things should be impossible if your code is right: a negative total, an unknown order status. An assertion turns "impossible" into an immediate, clear error instead of a wrong number on screen.
function assert(condition, message) {
if (!condition) throw new Error("Assertion failed: " + message);
}
function statusLabel(status) {
switch (status) {
case "paid": return "Paid";
case "shipped": return "On the way";
default: throw new Error("Unknown status: " + status);
}
}
const total = 120 - 30;
assert(total >= 0, "total can't be negative");Try it yourself
- 1
Add a guard
In the first example, add a guard that throws a
RangeErrorwhen any item has a negativekg. Add such an order to the list and predict the output before running it. - 2
Find another || trap
In the second example, give Ravi
settings: { nickname: "" }and printu.settings?.nickname || "(none)"andu.settings?.nickname ?? "(none)". Predict which one keeps the empty string. - 3
Tighten the age rules
In the third example, add
"0x1A"and"1e2"to the inputs.Numberreads them as 26 and 100. Predict whether they pass, then decide if your form should accept them and add a guard such as/^\d+$/.test(trimmed)if not.
Code & diagrams
Preview
Console
Nothing logged yet.Expected console output
Shipping: 0
Shipping: 500
Shipping: 100
TypeError: shippingCost needs an order`||` replaced Asha's real volume of 0 with 50. `??` only replaces `null` and `undefined`.
Preview
Console
Nothing logged yet.Expected console output
Asha | Pune | with ||: 50 | with ??: 0
Ravi | Unknown city | with ||: 50 | with ??: 50
Meera | Unknown city | with ||: 50 | with ??: 50
greet result: undefinedPreview
Console
Nothing logged yet.Expected console output
Number('') = 0 | Number('12abc') = NaN | parseInt('12abc') = 12
OK: 42
OK: 7
Age is required
Age must be a whole number, got "12abc"
Age must be from 0 to 130, got -3
Age must be a whole number, got "4.5"Preview
Console
Nothing logged yet.Expected console output
After the sloppy write: 0.18
Strict mode: TypeError: Cannot assign to read only property 'taxRate' of object '#<Object>'
Nested object: ChangedBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Use || for a setting that can be 0
Write const volume = settings.volume || 50; and save a volume of 0.
Break #2
Let NaN travel
Compute const total = price * Number(qtyInput.value); without checking, and type two in the box.
Break #3
Overuse optional chaining
Write order?.items?.[0]?.price?.toFixed?.(2) everywhere, even where an order must exist.
Myth vs fact
Myth
Defensive code means checking everything everywhere.
Fact
Check at the boundaries where data enters (forms, URLs, servers, public functions). Inside your own trusted code, repeating checks adds noise. TypeScript (Topic 12.6) can remove many internal checks.
Myth
?. makes code safe.
Fact
It makes code not crash. If the value must exist, a silent undefined is worse than a clear error.
Myth
Object.freeze makes an object completely unchangeable.
Fact
It's shallow: nested objects can still change. And in sloppy scripts, writes are ignored silently instead of throwing.
Myth
HTML validation like required and type="number" is enough.
Fact
It helps users, but anyone can bypass it, and values still arrive as strings. Validate in JavaScript too, and always again on the server.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Parse, don't validate: instead of a function that returns
true/falseand leaves the caller with the raw string, write one that returns a clean, typed value (parseAgereturns a number) or throws. The rest of the code then can't accidentally use the unchecked input. - ▸
For bigger apps, schema libraries such as Zod or Valibot describe the shape of incoming data once and both validate it and give TypeScript types. They are common for API responses and form data.
- ▸
a?.b.cshort-circuits the whole chain: ifais nullish,.cis never read, so it doesn't throw.?.can't be used on the left of an assignment (a?.b = 1is a SyntaxError).??can't be mixed with||or&&without parentheses, also a SyntaxError. - ▸
Client-side checks are for user experience. Security checks (who may do what, prices, limits) must run on the server, because anyone can change the JavaScript running in their own browser (see
/system-designfor API design).
Remember this
- 1
Defensive coding means writing code that expects things to go wrong: missing values, the wrong type, text where a number should be, an empty list. The most important rule is never trust data from outside your code: form inputs, the URL,
localStorage, JSON from a server, and arguments from other people's code. Check it once, at the boundary where it enters, then the rest of your code can trust it. - 2
Validating input means checking that a value is what you need. Use precise checks:
typeof x === "string",Number.isFinite(n)(a real number, notNaNorInfinity),Number.isInteger(n),Array.isArray(list),x !== null && typeof x === "object"for objects. Beware of loose conversions:Number("")is0,Number("12abc")isNaN, andparseInt("12abc")happily returns12. Text from a form is always a string, even in<input type="number">(useinput.valueAsNumberor convert it yourself). - 3
A guard clause is an
ifat the top of a function that handles a bad or special case and leaves at once withreturnorthrow. Instead of wrapping the real work in deeper and deeperifblocks, each guard deals with one case and gets out of the way, so the main logic sits flat at the bottom. Code becomes easier to read and every rule is visible in one place. - 4
Some values are allowed to be missing. Optional chaining
?.(ES2020) stops and givesundefinedinstead of throwing when the value before it isnullorundefined:user.address?.city,list?.[0],callback?.(). The nullish coalescing operator??(ES2020) supplies a default only fornullorundefined:settings.volume ?? 50. Prefer it to||, which also replaces0,""andfalse, so a real volume of0would wrongly become50. Use?.only where missing really is normal; if a value must exist, a guard that throws is better. - 5
A silent failure is a problem that produces no error: a wrong total, a
NaNshown as a price, an emptycatch(Topic 13.2), a check that returns early without saying why. Silent failures are the most expensive bugs, because they are found late, by users, far from their cause. Prefer failing loudly and early: throw a clear error (Topic 13.3), or at leastconsole.warnwhen you fall back to a default. An assertion, a tiny function likeassert(total >= 0, "total can't be negative"), states something that must always be true and throws if it isn't. - 6
Protect your own data too.
Object.freeze(obj)makes an object's properties read-only (only at the top level, it's shallow). In strict mode (modules and classes are always strict, Topic 12.1) writing to a frozen property throws a TypeError; in old sloppy scripts the write is silently ignored, another silent failure. Default parameters (function greet(name = "friend"), Topic 3.3) handle missing arguments, and aswitchshould end with adefaultthat throws for values you didn't plan for.
Explain it without notes
What is a guard clause, and why do many developers prefer it to nested if statements?
What is the difference between ?? and ||, and when does it matter?
What is a silent failure? Give two examples and how to avoid them.
Where should you validate data, and why there?
Practice
Rewrite with guard clauses so that null throws a TypeError and an empty name returns "Hello, guest": function greet(user) { if (user) { if (user.name) { return "Hello, " + user.name; } else { return "Hello, guest"; } } }
Write discountedPrice(price, percent) that throws a TypeError unless both are finite numbers, throws a RangeError unless percent is between 0 and 100, and returns the price after the discount. Show the result for (200, 10) and the error for (200, 150).
Trade-offs
- ↔
Strict validation rejects bad data early and clearly, but can also reject unusual valid input (names with apostrophes, international phone numbers); keep rules as loose as the real requirement.
- ↔
Throwing on unexpected data makes bugs loud; falling back to a default keeps the page working for users. A common middle ground: fall back, but report the problem with
console.warnor an error-tracking call.
Done when you can
I validate outside data once, where it enters my code.
I can write guard clauses instead of nested
ifblocks.I use
?.only for values that are allowed to be missing.I use
??instead of||when0,""orfalseare valid values.I avoid silent failures: no empty catch, no unchecked
NaN.I know
Object.freezeis shallow and silent in sloppy mode.