JavaScript Input Validation: Guard Clauses and Assertions
Validate JavaScript input at the edge with guard clauses that name the bad value, check numbers with Number.isFinite, and assert invariants with node:assert.
- Course: JavaScript study plan
- Module: Errors and error handling
- Kind: Lesson
- Reading time: 12 min
- Runtime: Node 16
How do you validate input in JavaScript?
Validate input in JavaScript where it enters the program — request bodies, CLI arguments, files, environment variables — once, converting it into clean, well-typed values the rest of the code can trust. Use guard clauses at the top of a function that throw a TypeError or RangeError naming the parameter, the rule and the actual value, and check numbers with Number.isFinite, since typeof admits NaN.
Lesson
Most exceptions worth having are thrown on purpose, at the edge, the moment a bad value arrives — because the alternative is a TypeError twelve calls later in code that had nothing to do with the mistake. This lesson is the craft of that early failure: validating inputs where they enter, guard clauses that keep the happy path flat, TypeError/RangeError with messages that name the value, collecting all problems for a form, invariants asserted with node:assert, and the specific traps around JSON.parse, numbers and property lookups that make JavaScript validation different from other languages.
Validate at the edge
Data enters a program at a few places: request bodies, CLI arguments, files, environment variables, third-party responses, user events. Validate there, once, and convert to well-typed values; code behind the edge then trusts its inputs. Validation sprinkled through internal functions is repetitive, inconsistent and still misses the edges. The internal functions get assertions instead (below) — cheap checks of things that must already be true.
Guard clauses
function withdraw(account, amount) {
if (typeof amount !== "number" || Number.isNaN(amount)) throw new TypeError(`amount must be a number, got ${typeof amount}`);
if (amount <= 0) throw new RangeError(`amount must be positive, got ${amount}`);
if (amount > account.balance) throw new InsufficientFundsError(account.id, amount, account.balance);
account.balance -= amount; // the happy path, unindented, last
}
Check and throw at the top, in order from "wrong type" to "wrong value" to "domain rule"; the real work follows without nesting. Each guard names the parameter, the rule, and the actual value — got ${amount} is the difference between a message that fixes the bug and one that starts a search. Interpolate typeof x for type failures, JSON.stringify(x) for small values, never entire request bodies or secrets.
JavaScript-specific checks
- Numbers.
typeof x === "number"admitsNaNandInfinity. UseNumber.isFinite(x)for "a real number",Number.isInteger(x)for integers,Number.isSafeIntegerfor ids.Number("")is0andNumber(" 12 ")is12— parse then check, do not trustNumber()alone;parseInt("12px")is12, which may or may not be what you want. - Strings. Check
typeof, thentrim(), then length and pattern. An empty string is falsy —if (!name)rejects it, which is usually right, butif (!count)rejects a legitimate0. Compare againstundefined/""explicitly when zero is valid. - Objects and arrays.
typeof [] === "object"andtypeof null === "object": testArray.isArray(x)andx !== null && typeof x === "object". Test fields withObject.hasOwn(x, "k")or"k" in x;x.k !== undefinedcannot tell "absent" from "present as undefined". - Optional values.
x ?? defaulttreatsnullandundefinedas missing and keeps0/""/false;x || defaultdoes not. Choose deliberately. JSON.parse. Always insidetry/catchfor external text; it throwsSyntaxError. Parsing succeeds for"null","42"and"[]"— check the shape after parsing, not just that parsing worked.- Prototype keys. Data keyed by user strings: reject or skip
__proto__,constructor,prototype, or use aMap.
Collect everything, or fail on the first?
A function called from code fails on the first problem — one throw, one message. A form or config file should report all problems at once, or the user fixes one and discovers the next on every round trip. Collect into an array and throw one error (or return a result) at the end:
function validateUser(u) {
const errors = [];
if (typeof u.name !== "string" || u.name.trim() === "") errors.push("name: required");
if (!Number.isInteger(u.age) || u.age < 0 || u.age > 150) errors.push(`age: must be 0–150, got ${JSON.stringify(u.age)}`);
if (typeof u.email !== "string" || !u.email.includes("@")) errors.push("email: must contain @");
if (errors.length) throw new ValidationError(errors);
return { name: u.name.trim(), age: u.age, email: u.email.toLowerCase() };
}
Validation is also normalisation: return the cleaned value (trimmed, lower-cased, defaults applied) so the rest of the program handles one canonical shape. Schema libraries (zod, Joi, Ajv) do exactly this at scale; the hand-rolled version teaches what they generate.
Assertions: invariants, not input
const assert = require("node:assert/strict");
function take(queue) {
assert(queue.length > 0, "take() on an empty queue"); // AssertionError if false
assert.equal(typeof queue[0].id, "string");
…
}
An assertion states something the code believes is already true — a precondition guaranteed by the caller, a postcondition of your own algorithm, a loop invariant. It is not validation of external input: if it fails, the bug is in the program, not in the data, and the right outcome is a loud AssertionError (err.code === "ERR_ASSERTION", err.actual/err.expected populated) that stops the operation. node:assert/strict uses === semantics for equal/deepEqual; the legacy assert.equal uses == — prefer strict. Assertions document assumptions for the reader as much as they check them; they should never have side effects, because some codebases strip them in production.
Fail fast
The principle behind all of this: detect a bad state as early and as close to its cause as possible, and stop rather than continue with corrupted data. A crash with a good message at the edge costs minutes; a wrong number written to a database because a NaN slipped through costs a weekend. Fail fast is not "crash the server" — the boundary decides the outcome (400 to the client, a logged failure for the job); it is "do not proceed past a check you know has failed".
Common mistakes
if (!value)when0,""orfalseare valid inputs.- Trusting
typeof x === "number"(NaN passes) orNumber(text)(empty string gives 0). JSON.parsewithouttry/catch, or without checking the parsed shape.- Validating deep inside internal functions instead of at the edge; or asserting external input instead of validating it.
- Messages without the actual value; messages that leak secrets.
- Returning the first error from a form validator.
Interview angle
- "Where do you validate input?" At the edges, once, converting to trusted, normalised values; internals assert invariants.
- "How do you check for a real number in JavaScript?"
Number.isFinite(x)(orNumber.isInteger), nottypeofalone. - "Assertion versus validation?" Validation guards external data and produces a user-facing outcome; an assertion checks an internal invariant and its failure means a bug.
- "Why collect all validation errors?" One round trip for the user; the caller sees the whole picture.
- "What is fail fast?" Detect bad state as early and as near its cause as possible and stop, rather than continue and corrupt.
Key takeaways
- Validate at the edge, normalise, and trust inside; guard clauses first, happy path last, flat.
- Messages name the parameter, the rule and the actual value (
got …); never secrets. Number.isFinite/isInteger,Array.isArray,x !== null && typeof x === "object",Object.hasOwn,??versus||,JSON.parseintry, guard prototype keys.- Forms and configs collect every error; functions fail on the first.
node:assert/strictfor invariants — a failed assertion is a bug, not bad input; fail fast.
Common questions
How do you check if a value is a real number in JavaScript?
Use Number.isFinite(x), or Number.isInteger(x) for whole numbers. typeof x === "number" also accepts NaN and Infinity, and Number() is lenient — Number("") is 0 — so convert the text first, then check the result.
What is the difference between validation and an assertion?
Validation checks external data — a request body, a file, user input — and its failure is an expected outcome such as a 400 response. An assertion checks an internal invariant the code believes is already true; if it fails, the bug is in the program, and a loud AssertionError should stop the operation.
What is a guard clause?
A guard clause is a check at the top of a function that throws or returns as soon as an input is invalid, ordered from wrong type to wrong value to domain rule. The happy path then runs last, unindented, instead of nested inside if blocks.
Should a form validator stop at the first error?
No. A form or config file should collect every problem into an array and report them together, or the user fixes one error and discovers the next on every round trip. A function called from code, by contrast, fails on the first problem with one throw.
What does fail fast mean?
Fail fast means detecting a bad state as early and as close to its cause as possible, and stopping instead of continuing with corrupt data. It does not mean crashing the server: the boundary still decides the outcome, such as a 400 response, but nothing proceeds past a check that has failed.
Exercises
Validate, collect and normalise
Write validateUser(u): throw a TypeError("user must be an object, got <null|array|typeof>") when u is not a plain object; otherwise collect every problem — name: required (non-empty string after trim), age: must be an integer 0-150, got <JSON>, email: must contain @ — and throw one Error with the problems joined by ; ; when valid return the normalised record (trimmed name, lower-cased email). Input lines are JSON users, or assert <a> <b>, which runs assert.equal(Number(a), Number(b)) from node:assert/strict and prints assert <a> <b>: passed or AssertionError code=<code> actual=<> expected=<>. Print ok: <JSON>, invalid: <message> or type error: <message>.
Example: {"name":" Ada ","age":36,"email":"ADA@X.io"}, {"name":"","age":-1,"email":"nope"}, assert 2 3, [1,2] →
ok: {"name":"Ada","age":36,"email":"ada@x.io"}
invalid: name: required; age: must be an integer 0-150, got -1; email: must contain @
AssertionError code=ERR_ASSERTION actual=2 expected=3
type error: user must be an object, got arrayResult objects for parsing and lookups
Write parseJson(text) returning { ok: true, value } or { ok: false, error: "parse failed (<error name>)" } — never throwing — and getPath(obj, "a.b.c") that walks the path with Object.hasOwn, returning { ok: true, value } or { ok: false, error: "missing '<key>' at <path so far or <root>>" } (a non-object along the way counts as missing). Line 1 is the JSON text; every following line is a path. If parsing failed print its error and nothing else; otherwise print <path> = <JSON of value> or <path>: <error> per path.
Example: {"db":{"host":"x","port":5432},"tags":["a"]} then db.port, db.user, tags, db.host.length →
db.port = 5432
db.user: missing 'user' at db
tags = ["a"]
db.host.length: missing 'length' at db.host
length is a property of the string, but not an own property of a plain-data object — the lookup is deliberately strict.
In this module: Errors and error handling
- throw, try, catch, finally — the mechanics, precisely
- The built-in error types, and designing your own
- Where to catch — strategies, boundaries and the result-object alternative
- Validation, guard clauses and assertions — failing early with a useful message (this lesson)
- Reading stack traces and decoding the errors you will see most
- Checkpoint — Errors
← Where to catch — strategies, boundaries and the result-object alternative · Reading stack traces and decoding the errors you will see most →