Errors happen. A fixture file doesn't exist. An API returns invalid JSON. A user object is missing a field your test expected. Without proper handling, any one of these crashes the script and produces an unreadable stack trace. try/catch is JavaScript's mechanism for catching errors gracefully — running fallback logic instead of letting one failure take down everything. This lesson covers the syntax, the error object, the built-in error types, and when to wrap something in try.
The basic try/catch
The shape:
try {
riskyCode();
} catch (error) {
handleError(error);
}JavaScript runs the code in try. If anything throws an error, execution jumps immediately to catch. The variable error holds the thrown value — usually an Error object with details about what went wrong.
A real example — parsing JSON that might be malformed:
const text = '{ "name": "Alice", "role": "admin" }';
try {
const user = JSON.parse(text);
console.log("Loaded:", user.name);
} catch (error) {
console.error("Could not parse JSON:", error.message);
}If the JSON is valid, the try block runs to completion. If not, the catch runs with a useful message. Either way, the script keeps running — the error is captured, not propagated.
Output (with valid JSON):
Loaded: Alice
Output (if text were '{ "name": "Alice", }' — note the trailing comma):
Could not parse JSON: Expected double-quoted property name in JSON at position 22
The error object
What catch receives is, by convention, an Error object — though technically you can throw any value. Three properties cover almost everything you'll ever need:
try {
null.name; // throws a TypeError
} catch (error) {
console.log(error.name); // "TypeError"
console.log(error.message); // "Cannot read properties of null (reading 'name')"
console.log(error.stack); // multi-line trace of where the error happened
}error.name— the type of error (a string like"TypeError").error.message— a human-readable description of what went wrong.error.stack— the call stack at the moment the error was thrown. Multi-line. Indispensable for finding where a deeply nested error originated.
Log all three when handling errors in test code; future-you will thank present-you.
The finally block
Sometimes you have cleanup code that needs to run regardless of whether the try succeeded or threw — close a database connection, delete a temp file, reset a flag. finally runs after either path:
let connection;
try {
connection = openConnection();
doWork(connection);
} catch (error) {
console.error("Work failed:", error.message);
} finally {
if (connection) connection.close();
}The finally block runs whether the try succeeded, the catch ran, or even if the catch itself threw a new error. It's the right home for any "must always happen" cleanup.
Built-in error types
JavaScript has a small set of standard error subclasses. Knowing the four you'll meet most often makes error messages stop being scary.
TypeError— calling a method on something that isn't what you expected.null.name,undefined.length,notAFunction()— allTypeError. The single most common runtime error in JavaScript.ReferenceError— using a variable that doesn't exist.console.log(xyz)(wherexyzwas never declared) →ReferenceError. Often a typo or a missing import.SyntaxError— JavaScript couldn't parse the code at all. Missing bracket, extra comma in JSON, mismatched quotes. Usually thrown at parse time, not runtime — butJSON.parsethrowsSyntaxErroron bad input.RangeError— a value is outside the legal range.new Array(-1)or recursing too deep both throwRangeError. Less common than the others.
Knowing the type narrows the search instantly. "TypeError on read of undefined" means a variable isn't what you thought; "ReferenceError" means a name doesn't exist.
Throwing your own errors
You can throw any value — but conventionally you throw new Error("message") so the receiver gets the standard properties:
function loadFixture(path) {
if (!path) {
throw new Error("loadFixture: path is required");
}
// ...
}
try {
loadFixture();
} catch (error) {
console.error(error.message); // "loadFixture: path is required"
}Custom errors should have descriptive messages — "path is required" tells the caller what to fix; "error" tells them nothing. In a test suite, a thrown error that reads users.json is missing — run \npm run seed` first` turns 20 minutes of confusion into 20 seconds.
When to use try/catch
Three guidelines:
- Around anything that talks to the outside world. File reads, API calls, JSON parsing — anywhere the failure could come from data you didn't write.
- Around
awaited promises that might reject (chapter 5) —try { await fetch(...) } catch (error) { ... }is the canonical async error pattern. - NOT around your own programmer mistakes. A
TypeErrorfromuser.namewhenuserisundefinedisn't an error to handle — it's a bug to fix. Catching and ignoring it just hides the bug.
In short: catch the failures of things outside your control. Fix the failures of things inside it.
A safe JSON parser
A real test helper that wraps JSON.parse and returns a useful diagnostic on failure:
function safeParse(text, label = "input") {
try {
return { ok: true, value: JSON.parse(text) };
} catch (error) {
return { ok: false, error: `Invalid JSON in ${label}: ${error.message}` };
}
}
const result = safeParse('{ bad json }', "users.json");
if (!result.ok) {
console.error(result.error);
} else {
console.log("Parsed:", result.value);
}Output:
Invalid JSON in users.json: Expected property name or '}' in JSON at position 2
The function never throws — it returns a tagged object. The caller decides how to react. That pattern (returning a { ok, value } or { ok, error } shape) is common in test utilities because it lets calling code use a normal if instead of nested try/catch.
How try/catch/finally actually flows
⚠️ Common mistakes
- Empty catch blocks.
try { ... } catch (e) {}swallows every error silently. The script keeps running but your data is wrong. Always log or rethrow — never just absorb. If you genuinely don't care about the error, leave a comment saying so. - Wrapping too much in one
try. A singletry { /* 50 lines */ }makes the eventualcatchimpossible to reason about — which line threw? Wrap the smallest unit that might fail. One try per risky operation, not one per file. - Catching errors from your own bugs. Catching
TypeErrorfromuser.namewhenuserisundefinedis the wrong shape — that's a bug, not an external failure. Fix the cause, don't paper over the symptom.
🎯 Practice task
Build a defensive fixture loader. 15-20 minutes.
-
In your
js-for-qafolder, create a fileusers-bad.jsonwith deliberately broken JSON:{ "name": "Alice", }(trailing comma). -
Create
loader.js:const fs = require("node:fs"); function loadJsonFixture(path) { try { const text = fs.readFileSync(path, "utf-8"); return { ok: true, value: JSON.parse(text) }; } catch (error) { return { ok: false, error: `${error.name}: ${error.message}` }; } } const good = loadJsonFixture("users.json"); const bad = loadJsonFixture("users-bad.json"); const missing = loadJsonFixture("does-not-exist.json"); console.log(good); console.log(bad); console.log(missing); -
Run with
node loader.js. Confirmgood.ok === true(assumingusers.jsonexists from earlier chapters),bad.ok === falsewith aSyntaxErrormessage, andmissing.ok === falsewith anENOENT(file not found) message. -
Add a
finallyblock toloadJsonFixturethat printsloadJsonFixture: ${path}. Confirm it runs for every call regardless of outcome. -
Stretch: write a function
firstValid(...paths)that tries each path in turn, returns the first one that loads cleanly, and throws a descriptiveError("no valid fixture found")if every path fails. Use it:const users = firstValid("users-staging.json", "users-default.json").value;.
The next lesson covers the tools that make hunting bugs faster: structured console methods and the VS Code debugger.