Try/Catch and Error Types

8 min read

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() — all TypeError. The single most common runtime error in JavaScript.
  • ReferenceError — using a variable that doesn't exist. console.log(xyz) (where xyz was 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 — but JSON.parse throws SyntaxError on bad input.
  • RangeError — a value is outside the legal range. new Array(-1) or recursing too deep both throw RangeError. 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 TypeError from user.name when user is undefined isn'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 single try { /* 50 lines */ } makes the eventual catch impossible 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 TypeError from user.name when user is undefined is 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.

  1. In your js-for-qa folder, create a file users-bad.json with deliberately broken JSON: { "name": "Alice", } (trailing comma).

  2. 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);
  3. Run with node loader.js. Confirm good.ok === true (assuming users.json exists from earlier chapters), bad.ok === false with a SyntaxError message, and missing.ok === false with an ENOENT (file not found) message.

  4. Add a finally block to loadJsonFixture that prints loadJsonFixture: ${path}. Confirm it runs for every call regardless of outcome.

  5. Stretch: write a function firstValid(...paths) that tries each path in turn, returns the first one that loads cleanly, and throws a descriptive Error("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.

// tip to track lessons you complete and pick up where you left off across devices.