Skip to content

Errors, Validation, and Boundaries

JavaScript lets any expression be thrown, but application code should almost always throw an Error. An error carries a message, a stack trace, and an identity that can communicate the kind of failure:

js
throw new Error("Report destination is not configured");

Throwing immediately stops the current control flow. The runtime walks outward until it finds a matching catch; if none exists, the operation or process fails. That makes exceptions appropriate for paths where the current operation cannot produce its promised result.

js
function parseSettings(text) {
  try {
    return JSON.parse(text);
  } catch (error) {
    throw new Error("Settings are not valid JSON", { cause: error });
  }
}

The new error explains the application-level operation, while cause preserves the original failure. Replacing an error without retaining its cause loses the most useful debugging evidence.

Catch errors where you can add meaning or recover

A catch block is not automatically responsible error handling:

js
try {
  return await loadReport(path);
} catch (error) {
  console.log(error);
}

This silently changes the function's result to undefined. The caller may fail much later, far from the actual cause. Catch when you can recover, translate the failure at a boundary, or perform final presentation. Otherwise allow it to propagate.

At a command-line boundary, presentation belongs near the entry point:

js
try {
  const report = await loadReport(process.argv[2]);
  console.log(formatReport(report));
} catch (error) {
  console.error(error instanceof Error ? error.message : String(error));
  process.exitCode = 1;
}

Setting process.exitCode allows pending output to flush; immediately calling process.exit() can truncate asynchronous writes.

finally runs whether the protected code succeeds or fails, so it is for unconditional cleanup:

js
const connection = await pool.acquire();
try {
  return await connection.query(statement);
} finally {
  connection.release();
}

Avoid returning from finally, which can replace the original result or error.

Not every unsuccessful outcome is exceptional

An invalid developer assumption is a programmer error. A missing record, rejected form, or unavailable optional feature may be an expected domain outcome. Model expected branches explicitly when the caller is supposed to choose among them:

js
function withdraw(account, amount) {
  if (amount <= 0) {
    return { ok: false, reason: "invalid-amount" };
  }
  if (account.balance < amount) {
    return { ok: false, reason: "insufficient-funds" };
  }

  account.balance -= amount;
  return { ok: true, balance: account.balance };
}

Exceptions are more natural when a low-level operation cannot fulfill its contract: a file cannot be read, JSON is malformed, or an invariant has been violated. There is no universal rule; consistency and caller needs matter more than slogans.

Custom errors let code distinguish categories without inspecting human-readable messages:

js
class ValidationError extends Error {
  constructor(message, issues, options) {
    super(message, options);
    this.name = "ValidationError";
    this.issues = issues;
  }
}

Use instanceof ValidationError within an application you control. Across network or serialization boundaries, classes and prototype identity disappear, so stable data such as { code, message, details } is more portable. For Node system failures, documented error codes are more stable than matching message text.

External data begins as untrusted data

JSON.parse checks JSON syntax, not application shape:

js
const value = JSON.parse(text);

value could be null, a string, or an object with the wrong properties. JavaScript requires runtime validation. Later, TypeScript will not change that fact because its types are erased.

A narrow validator can establish the shape expected by a reporting program:

js
function parseTransaction(value, index) {
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
    throw new ValidationError(`Transaction ${index} must be an object`, [
      { path: `[${index}]`, problem: "expected object" },
    ]);
  }

  if (typeof value.id !== "string" || value.id.length === 0) {
    throw new ValidationError(`Transaction ${index} has no valid id`, [
      { path: `[${index}].id`, problem: "expected non-empty string" },
    ]);
  }

  if (typeof value.amount !== "number" || !Number.isFinite(value.amount)) {
    throw new ValidationError(`Transaction ${index} has no valid amount`, [
      { path: `[${index}].amount`, problem: "expected finite number" },
    ]);
  }

  return { id: value.id, amount: value.amount };
}

Validation belongs at boundaries: command-line arguments, environment variables, HTTP responses, decoded JSON, form input, and database results. Once a boundary has normalized data, inner code can work with a stronger contract instead of repeating defensive checks everywhere.

Validation may also normalize representation:

js
function parseCurrency(value) {
  if (typeof value !== "string") {
    throw new ValidationError("Currency must be text", []);
  }

  const currency = value.trim().toUpperCase();
  if (!/^[A-Z]{3}$/.test(currency)) {
    throw new ValidationError("Currency must contain three letters", []);
  }
  return currency;
}

After this function succeeds, downstream code receives one canonical form.

Libraries such as Zod, Valibot, and JSON Schema validators reduce repetitive validation for larger schemas. They are ecosystem choices, not substitutes for deciding what input your program accepts or how errors should be presented.

Preserve useful context without leaking internals

Each boundary can add the information it uniquely knows:

js
async function readTransactions(path, { readFile }) {
  let text;
  try {
    text = await readFile(path, "utf8");
  } catch (error) {
    throw new Error(`Could not read transaction file: ${path}`, { cause: error });
  }

  let raw;
  try {
    raw = JSON.parse(text);
  } catch (error) {
    throw new Error(`Transaction file contains malformed JSON: ${path}`, {
      cause: error,
    });
  }

  if (!Array.isArray(raw)) {
    throw new ValidationError("Transaction file must contain an array", []);
  }
  return raw.map(parseTransaction);
}

The filesystem layer knows the path; the parser knows the expected document; the UI or CLI knows what is safe and useful to show. Logging a complete stack on a developer machine may be appropriate. Sending filesystem paths and stack traces to a browser user usually is not.

When reviewing failure behavior, consider each branch: Does malformed input fail at the boundary? Is the original cause retained? Does cleanup occur? Does an expected negative result remain distinguishable? These questions are more valuable than merely asserting that “an error was thrown.”

Errors turn implicit assumptions into observable contracts. Promises, the subject of the next article, preserve the same success-and-failure model across asynchronous completion.

Further reference