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 money

A 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 knowUse thisNot this, because
it is a usable numberNumber.isFinite(x)typeof x === 'number' is true for NaN and Infinity
it is a whole numberNumber.isInteger(x)x % 1 === 0 is true for '4' and for Infinity it is NaN
it is safe integer mathsNumber.isSafeInteger(x)past 2 to the 53 you lose precision silently
it is an arrayArray.isArray(x)typeof [] is 'object'
it is a plain objecttypeof x === 'object' && x !== null && !Array.isArray(x)typeof null is 'object', a 1995 bug
the key is really thereObject.hasOwn(x, k)x.k !== undefined cannot tell missing from set to undefined
the string has contenttypeof x === 'string' && x.trim() !== ''if (x) accepts ' ' and rejects '0'... which is fine, but check you meant it
it is a function you can calltypeof 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 failureExampleHow to handle it
Expected, from a userblank email fielda validation result, shown in the UI
Expected, from the worldnetwork down, 404, disk fullcatch, retry or report, it is not a bug
A bug in your codea function called with undefinedthrow immediately, let it crash loudly
  1. Name the boundary Find the exact function where outside data becomes your data. There should be one per source.
  2. Parse into a shape Return a new object with only the fields you use, converted and trimmed.
  3. 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
numbers
Number.isFinite and Number.isInteger, never bare typeof
objects
x !== null && typeof x === 'object', plus Array.isArray
keys
Object.hasOwn(x, k) when missing and undefined mean different things
boundary
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 + 2 is 2, then 2 + '3' concatenates into '23'. No error, no NaN, just a string that looks almost like a total and will pass most if (total) checks. This is why the guard belongs where the data entered.
Which check correctly rejects NaN, Infinity and '42' while accepting 42?
Number.isFinite(x) — typeof NaN and typeof Infinity are both 'number'. !isNaN('42') is true because the global isNaN coerces first. Number.isFinite does no coercion and excludes both infinities and NaN.
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 holding raw, 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 undefined because 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 '', null and [] all become a perfectly valid 0.
  • Using typeof x === 'number', which happily accepts NaN.
  • 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/catch so 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.
  • TypeError for the wrong type, RangeError for an impossible amount.
  • Use Number.isFinite and Number.isInteger, and remember typeof 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.