Command Palette

Search for a command to run...

Hectal
PHASE 13Intermediate ~24 min· topic 5 of 6

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 if at the top of a function that handles a bad case and leaves at once.
Optional chaining (?.)
Reads a property but gives undefined instead of an error if the value before it is null or undefined.
Nullish coalescing (??)
Gives a default only when the value on its left is null or undefined.
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.

shipping.jswhole filejavascript
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 failure

02Turn 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.

shipping.jswhole filejavascript
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;
}
Turn the checks into guard clausesdiagram
Rendering diagram…

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.

age.jswhole filejavascript
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 ??.

profile.jswhole filejavascript
const city = user.address?.city ?? "Unknown city";
const volume = user.settings?.volume ?? 50; // keeps a real 0
user.onSave?.(); // call it only if it exists

05State 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.

assert.jswhole filejavascript
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. 1

    Add a guard

    In the first example, add a guard that throws a RangeError when any item has a negative kg. Add such an order to the list and predict the output before running it.

  2. 2

    Find another || trap

    In the second example, give Ravi settings: { nickname: "" } and print u.settings?.nickname || "(none)" and u.settings?.nickname ?? "(none)". Predict which one keeps the empty string.

  3. 3

    Tighten the age rules

    In the third example, add "0x1A" and "1e2" to the inputs. Number reads 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

Guard clauses keep the main rule flat New tab

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Shipping: 0
Shipping: 500
Shipping: 100
TypeError: shippingCost needs an order
Optional chaining and ?? on messy data ES2020+ New tab

`||` replaced Asha's real volume of 0 with 50. `??` only replaces `null` and `undefined`.

Preview

Press Run to see the page.

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: undefined
Silent conversions vs validation at the boundary New tab

Preview

Press Run to see the page.

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"
Frozen objects: silent in sloppy code, loud in strict code New tab

Preview

Press Run to see the page.

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: Changed

Break 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.

terminal
$ the page shows
── what you'll see ──
The volume jumps back to 50 every time the user sets it to 0. No error appears.

Break #2

Let NaN travel

Compute const total = price * Number(qtyInput.value); without checking, and type two in the box.

terminal
$ the page shows
── what you'll see ──
Total: NaN

Break #3

Overuse optional chaining

Write order?.items?.[0]?.price?.toFixed?.(2) everywhere, even where an order must exist.

terminal
$ the page shows
── what you'll see ──
Prices show as blank or "undefined" when the data is broken, and no error points to why.

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/false and leaves the caller with the raw string, write one that returns a clean, typed value (parseAge returns 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.c short-circuits the whole chain: if a is nullish, .c is never read, so it doesn't throw. ?. can't be used on the left of an assignment (a?.b = 1 is 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-design for API design).

Remember this

  1. 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. 2

    Validating input means checking that a value is what you need. Use precise checks: typeof x === "string", Number.isFinite(n) (a real number, not NaN or Infinity), Number.isInteger(n), Array.isArray(list), x !== null && typeof x === "object" for objects. Beware of loose conversions: Number("") is 0, Number("12abc") is NaN, and parseInt("12abc") happily returns 12. Text from a form is always a string, even in <input type="number"> (use input.valueAsNumber or convert it yourself).

  3. 3

    A guard clause is an if at the top of a function that handles a bad or special case and leaves at once with return or throw. Instead of wrapping the real work in deeper and deeper if blocks, 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. 4

    Some values are allowed to be missing. Optional chaining ?. (ES2020) stops and gives undefined instead of throwing when the value before it is null or undefined: user.address?.city, list?.[0], callback?.(). The nullish coalescing operator ?? (ES2020) supplies a default only for null or undefined: settings.volume ?? 50. Prefer it to ||, which also replaces 0, "" and false, so a real volume of 0 would wrongly become 50. Use ?. only where missing really is normal; if a value must exist, a guard that throws is better.

  5. 5

    A silent failure is a problem that produces no error: a wrong total, a NaN shown as a price, an empty catch (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 least console.warn when you fall back to a default. An assertion, a tiny function like assert(total >= 0, "total can't be negative"), states something that must always be true and throws if it isn't.

  6. 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 a switch should end with a default that throws for values you didn't plan for.

Explain it without notes

01

What is a guard clause, and why do many developers prefer it to nested if statements?

02

What is the difference between ?? and ||, and when does it matter?

03

What is a silent failure? Give two examples and how to avoid them.

04

Where should you validate data, and why there?

Practice

01

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"; } } }

02

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.warn or 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 if blocks.

  • I use ?. only for values that are allowed to be missing.

  • I use ?? instead of || when 0, "" or false are valid values.

  • I avoid silent failures: no empty catch, no unchecked NaN.

  • I know Object.freeze is shallow and silent in sloppy mode.