Defensive Code and Validation
Mental model: Check at the door, then trust the room. Every value that crosses into your code gets parsed into a shape you know, or it does not get in.
Level: intermediate · about 15 minutes
function applyDiscount(order, percent) {
return order.total * (1 - percent / 100);
}
console.log(applyDiscount({ total: 100 }, 10)); // -> 90, correct
console.log(applyDiscount({ total: 100 }, '10')); // -> 90, the string coerced quietly
console.log(applyDiscount({ total: 100 }, 'ten')); // -> NaN, no error anywhere
console.log(applyDiscount({ total: 100 }, 500)); // -> -400, you now owe the customer moneyA function that trusts its arguments. Every line below is a bug report waiting to happen.
Notice where the damage happens. Nothing throws. NaN travels through totals, into the database, out to a receipt, and the stack trace you eventually get points at the code that rendered the number, not the code that produced it. Fail fast means moving the failure back to the moment the bad value arrived, while you still know where it came from.
Guard clauses
Nested, happy path buried
function ship(order) {
if (order) {
if (order.items.length) {
if (order.address) {
return send(order);
} else {
throw new Error('no address');
}
}
}
}
Guards first, happy path flat
function ship(order) {
if (!order) throw new TypeError('order required');
if (!order.items?.length) throw new RangeError('order is empty');
if (!order.address) throw new TypeError('address required');
return send(order);
}A guard clause handles one bad case and leaves immediately. The reward is that the real work sits at indentation level one, and the preconditions read like a contract at the top of the function.
function applyDiscount(order, percent) {
if (typeof order !== 'object' || order === null) throw new TypeError('order must be an object');
if (!Number.isFinite(order.total)) throw new TypeError('order.total must be a finite number');
if (!Number.isFinite(percent)) throw new TypeError('percent must be a finite number');
if (percent < 0 || percent > 100) throw new RangeError('percent must be between 0 and 100');
return order.total * (1 - percent / 100);
}
console.log(applyDiscount({ total: 100 }, 10)); // -> 90
try { applyDiscount({ total: 100 }, 'ten'); } catch (err) { console.log(err.name, err.message); }
try { applyDiscount({ total: 100 }, 500); } catch (err) { console.log(err.name, err.message); }The same discount function, guarded. Read the guards as documentation.
Validate at the boundary, trust inside
untrusted boundary trusted core
--------- -------- ------------
fetch response ---\
localStorage ----\ +----------------+ +-----------------+
URL / form input ----> | parse + guard | ----> | plain functions |
file contents ----/ +----------------+ | assume the shape|
third party SDK ---/ | +-----------------+
v
ValidationError
The mistake is not "too little validation", it is validation sprayed everywhere. If every function re-checks its arguments, you get triple the code and still no guarantee, because each check has its own idea of what valid means. Validate once where data enters, produce a known shape, and let the core functions be honest about assuming it.
typeof is not the check you want
| You want to know | Use this | Not this, because |
|---|---|---|
| it is a usable number | Number.isFinite(x) | typeof x === 'number' is true for NaN and Infinity |
| it is a whole number | Number.isInteger(x) | x % 1 === 0 is true for '4' and for Infinity it is NaN |
| it is safe integer maths | Number.isSafeInteger(x) | past 2 to the 53 you lose precision silently |
| it is an array | Array.isArray(x) | typeof [] is 'object' |
| it is a plain object | typeof x === 'object' && x !== null && !Array.isArray(x) | typeof null is 'object', a 1995 bug |
| the key is really there | Object.hasOwn(x, k) | x.k !== undefined cannot tell missing from set to undefined |
| the string has content | typeof x === 'string' && x.trim() !== '' | if (x) accepts ' ' and rejects '0'... which is fine, but check you meant it |
| it is a function you can call | typeof x === 'function' | nothing, this one is genuinely fine |
console.log(isFinite('5'), Number.isFinite('5'));The global isFinite coerces its argument first, so the string becomes 5 and passes. Number.isFinite never coerces: a non-number is simply not a finite number. The same split exists for isNaN and Number.isNaN. Always use the Number. versions in a guard.
const values = [42, 4.5, '42', NaN, Infinity, null, undefined, '', [], 2 ** 53];
for (const v of values) {
console.log(
String(JSON.stringify(v)).padEnd(18),
'finite:', String(Number.isFinite(v)).padEnd(6),
'integer:', String(Number.isInteger(v)).padEnd(6),
'safe:', Number.isSafeInteger(v)
);
}
// note '' and [] coerce to 0 with Number(), yet fail every check above. That is the point.The numeric edge cases a guard has to survive.
Parse, do not just validate
A boolean tells you nothing
if (isValidUser(raw)) {
// TypeScript and your brain both
// still think raw is "whatever".
// Every line below re-guesses
// the shape, and the check is
// free to drift out of sync.
save(raw.name.trim(), raw.age);
}
A shape you can rely on
const result = parseUser(raw);
if (!result.ok) {
return showErrors(result.errors);
}
const user = result.value;
// user is exactly { name, age },
// trimmed, coerced, complete.
save(user.name, user.age);This is the "parse, do not validate" idea. A validator returns a boolean and leaves you holding the raw value. A parser returns a new value that could not exist unless it was valid, so the guarantee travels with the data instead of living in a comment.
function parseUser(raw) {
const errors = [];
if (typeof raw !== 'object' || raw === null) return { ok: false, errors: ['user must be an object'] };
const name = typeof raw.name === 'string' ? raw.name.trim() : '';
if (!name) errors.push('name is required');
const age = raw.age;
if (!Number.isInteger(age) || age < 0 || age > 130) errors.push('age must be a whole number from 0 to 130');
return errors.length ? { ok: false, errors } : { ok: true, value: { name, age } };
}
console.log(parseUser({ name: ' Ada ', age: 36 })); // -> { ok: true, value: { name: 'Ada', age: 36 } }
console.log(parseUser({ name: '', age: '36' })); // -> { ok: false, errors: [ 2 items ] }
console.log(parseUser(null));A parser: collects every problem, and returns a clean object or the reasons why not.
Invariants and assertion helpers
function invariant(condition, message) {
if (!condition) throw new Error('Invariant violated: ' + message);
}
function requireFinite(value, name) {
if (!Number.isFinite(value)) throw new TypeError(name + ' must be a finite number, received ' + String(value));
return value;
}
function average(numbers) {
invariant(Array.isArray(numbers), 'average expects an array');
invariant(numbers.length > 0, 'average of an empty list is undefined');
const total = numbers.reduce((sum, n) => sum + requireFinite(n, 'every item'), 0);
return total / numbers.length;
}
console.log(average([2, 4, 9])); // -> 5
try { average([]); } catch (err) { console.log(err.message); }
try { average([1, '2']); } catch (err) { console.log(err.message); }Two helpers worth having in every project.
An invariant is a statement that must be true for the program to make sense, like "the cart total is never negative" or "this list is sorted". It is not user input validation. It is a claim about your own code, so when one fails the correct reaction is to fix the source, not to show a friendly message.
| Kind of failure | Example | How to handle it |
|---|---|---|
| Expected, from a user | blank email field | a validation result, shown in the UI |
| Expected, from the world | network down, 404, disk full | catch, retry or report, it is not a bug |
| A bug in your code | a function called with undefined | throw immediately, let it crash loudly |
- Name the boundary Find the exact function where outside data becomes your data. There should be one per source.
- Parse into a shape Return a new object with only the fields you use, converted and trimmed.
- Guard the core Inside, guards are for programmer errors only, and there are far fewer of them.
fail fast- throw where the bad value arrived, not where it finally hurt
guard clause- one bad case, one early exit, happy path stays flat
numbersNumber.isFiniteandNumber.isInteger, never baretypeofobjectsx !== null && typeof x === 'object', plusArray.isArraykeysObject.hasOwn(x, k)when missing and undefined mean different thingsboundary- validate once at the edge, return a parsed shape
forms- collect every error before reporting
invariants- about your code, not the user. Never catch them politely
Try it yourself
Break the guards
function applyDiscount(order, percent) {
if (typeof order !== 'object' || order === null) throw new TypeError('order must be an object');
if (!Number.isFinite(order.total)) throw new TypeError('order.total must be a finite number');
if (!Number.isFinite(percent)) throw new TypeError('percent must be a finite number');
if (percent < 0 || percent > 100) throw new RangeError('percent must be between 0 and 100');
return order.total * (1 - percent / 100);
}
const attempts = [
[{ total: 100 }, 10],
[{ total: 100 }, '10'],
[{ total: '100' }, 10],
[null, 10],
[{ total: 100 }, NaN],
[{ total: 100 }, -5],
[[], 10],
];
for (const [order, percent] of attempts) {
try {
console.log('ok ', applyDiscount(order, percent));
} catch (err) {
console.log('fail', err.name + ':', err.message);
}
}
Add a guard so a percent of exactly 100 is allowed but 100.5 is not. Then try to find an input that still slips through.
Trust nothing from the wire
const payload = '{"items":[{"sku":"tea","qty":2},{"sku":"","qty":"3"},{"qty":1}]}';
function parseCart(text) {
let raw;
try {
raw = JSON.parse(text);
} catch {
return { ok: false, errors: ['body is not valid JSON'] };
}
if (!Array.isArray(raw.items)) return { ok: false, errors: ['items must be an array'] };
const errors = [];
const items = [];
raw.items.forEach((item, i) => {
const sku = typeof item?.sku === 'string' ? item.sku.trim() : '';
if (!sku) errors.push('items[' + i + '].sku is required');
if (!Number.isInteger(item?.qty) || item.qty < 1) errors.push('items[' + i + '].qty must be 1 or more');
if (sku && Number.isInteger(item?.qty)) items.push({ sku, qty: item.qty });
});
return errors.length ? { ok: false, errors } : { ok: true, value: { items } };
}
console.log(parseCart(payload));
console.log(parseCart('{"items":[{"sku":"tea","qty":2}]}'));
Change the payload so items is an object rather than an array, and make the parser survive it. Then add a maximum length.
Exercises
Guard the discount
Write applyDiscount(order, percent). Throw a TypeError when order is not a non-null object, when order.total is not a finite number, or when percent is not a finite number. Throw a RangeError when percent is outside 0 to 100 inclusive. Otherwise return order.total * (1 - percent / 100).
Parse settings instead of validating them
Write parseSettings(raw) that returns { ok: true, value } or { ok: false, errors }. Rules: if raw is not a non-null object, return { ok: false, errors: ['settings must be an object'] }. theme may be omitted (defaults to 'light') but otherwise must be 'light' or 'dark', error text 'theme must be light or dark'. fontSize may be omitted (defaults to 14) but otherwise must be an integer from 8 to 32, error text 'fontSize must be an integer from 8 to 32'. Collect both errors, theme first. On success value holds exactly theme and fontSize, so unknown keys are dropped.
Check yourself
- What does this log?
'23'—0 + 2is2, then2 + '3'concatenates into'23'. No error, noNaN, just a string that looks almost like a total and will pass mostif (total)checks. This is why the guard belongs where the data entered.- Which check correctly rejects
NaN,Infinityand'42'while accepting42? Number.isFinite(x)—typeof NaNandtypeof Infinityare both'number'.!isNaN('42')is true because the globalisNaNcoerces first.Number.isFinitedoes no coercion and excludes both infinities andNaN.- Why prefer a parser that returns
{ ok, value }over a validator that returns a boolean? - The guarantee travels with the returned value instead of living in your memory — After
if (isValid(raw))you are still holdingraw, so every later line re-guesses the shape and the check can drift out of sync with the use. A parser hands back a value that could not exist unless it was valid. - A helper is called with
undefinedbecause of a typo in your own code. What should happen? - Throw immediately and let it crash loudly — That is a programmer error, not an operational one. Defaulting or swallowing it hides the bug and produces wrong data instead of a stack trace. Expected failures (bad user input, network down) get handled. Impossible states get thrown.
Common mistakes
- Coercing first and checking afterwards, so
'',nulland[]all become a perfectly valid0. - Using
typeof x === 'number', which happily acceptsNaN. - Forgetting
typeof null === 'object'in an object guard. - Validating in every function instead of once at the boundary, so no two checks agree.
- Returning a boolean from a validator and then re-reading the raw object anyway.
- Wrapping a whole handler in
try/catchso your own invariant failures become quiet 500s. - Stopping at the first error in a form, making the user resubmit to discover the next one.
Takeaways
- Fail fast: throw where the bad value arrived, not three modules later where it finally hurt.
- Guard clauses put preconditions at the top and keep the real work unindented.
TypeErrorfor the wrong type,RangeErrorfor an impossible amount.- Use
Number.isFiniteandNumber.isInteger, and remembertypeof null === 'object'. - Validate once at the boundary and return a parsed shape, then let the core trust it.
- Collect all validation errors before reporting, at least for anything a human is filling in.
- Invariants are claims about your own code. When one fails, fix the code, do not catch it.