Module: JavaScript
JavaScript·063·6 MIN READ

063: Errors, Exceptions, and Recovery

TOPICS COVERED: Errors, Exceptions, and Recovery

Learning outcomes

By the end of this lesson, you can:

  • distinguish syntax errors, runtime errors, and expected validation failures;
  • throw useful Error objects from domain functions;
  • use try, catch, and finally with clear responsibilities;
  • read an error's name, message, and stack trace;
  • preserve a lower-level failure with Error's cause when wrapping is useful.

Retrieval warm-up

  1. What does find() return for a missing product?
  2. Where should a static import declaration appear?
  3. Why is returning undefined sometimes unsafe for a required lookup?

Expected answers: undefined, module top level, and callers may continue with invalid/missing data unless they check.

Vocabulary

  • Syntax error: Parse-time failure preventing the whole script/module from running. — Source: MDN: SyntaxError
  • Runtime error: Failure thrown while executing otherwise-valid code. — Source: MDN: Control flow and error handling
  • Exception: Thrown Error object propagating until caught. — Source: MDN: try...catch
  • Throw: Statement raising a value to signal failure. — Source: MDN: throw
  • Catch: Clause receiving the thrown value for handling. — Source: MDN: try...catch
  • Finally: Block running after try/catch regardless of outcome. — Source: MDN: try...catch
  • Stack trace: Report of active frames showing the error’s origin path. — Source: MDN: Error.prototype.stack
  • Defensive programming: validate assumptions at useful boundaries (course term).
  • Cause: the original failure retained when creating a higher-level error (course term).
  • Exception (official): "An exception is an error that is thrown and can be caught." — Source: MDN: try...catch
  • Stack trace (official): "A stack trace is a report of active stack frames at a point in time." — Source: MDN: Error — Stack

Beginner mental model

Errors are information traveling up the call stack. A function should return a normal result for a normal case. If it cannot fulfill its contract, it may throw an Error. A suitable higher layer decides whether to recover, report, or rethrow.

js
function requireProduct(products, productId) {
  const product = products.find((item) => item.id === productId);
  if (!product) {
    throw new Error(`Product ${productId} was not found`);
  }
  return product;
}

Throw Error objects rather than strings. They provide standard name and message fields and typically a useful stack. JavaScript technically allows any value to be thrown, so catch code should not blindly assume the value is an Error.

Not every invalid input requires exceptions. A form field can return validation messages because invalid user input is expected. Throw when the function cannot honor its promised operation or when continuing would produce misleading state.

Three broad failure categories:

  • A missing ) is a syntax error, usually found before execution of that script/module.
  • Accessing a property on undefined is a runtime TypeError.
  • Rejecting quantity 0 is a domain validation decision; code chooses whether to return an issue or throw.

Worked beginner example: protected cart update

js
const products = [
  { id: "p1", name: "Notebook", price: 4, stock: 12 },
  { id: "p3", name: "Water Bottle", price: 16, stock: 2 },
];

function addItem(cart, products, productId, quantity) {
  if (!Number.isInteger(quantity) || quantity < 1) {
    throw new RangeError("Quantity must be a positive integer");
  }

  const product = products.find((item) => item.id === productId);

  if (!product) {
    throw new Error(`Product ${productId} was not found`);
  }

  if (quantity > product.stock) {
    throw new RangeError(
      `Only ${product.stock} ${product.name} item(s) are available`,
    );
  }

  return [...cart, { productId, quantity }];
}

let cart = [];

try {
  cart = addItem(cart, products, "p3", 3);
  console.log("Item added");
} catch (error) {
  if (error instanceof Error) {
    console.error(`${error.name}: ${error.message}`);
  } else {
    console.error("An unknown value was thrown", error);
  }
} finally {
  console.log("Cart operation finished");
}

console.log(cart);

Output (error styling varies by console):

text
RangeError: Only 2 Water Bottle item(s) are available
Cart operation finished
[]

Control jumps from the throw to catch; "Item added" is skipped. The pure update never assigned a partial cart. finally runs after success or failure. Use finally for real cleanup such as resetting a loading flag or releasing a resource, not for ordinary output that can simply follow the statement.

RangeError communicates that a numeric value is outside the permitted range. A general Error is sufficient for a missing domain record. Avoid inventing custom classes at this stage.

Reading a stack trace

When an error is unexpected, do not immediately surround everything with try...catch. Read:

  1. The error type (TypeError, ReferenceError, and so on).
  2. The message describing the failed operation.
  3. The first stack frame in code you own: filename, line, and column.
  4. Values and assumptions at that line.
  5. Earlier caller frames to understand how invalid data arrived.
js
try {
  addItem([], products, "missing", 1);
} catch (error) {
  console.error(error.name);
  console.error(error.message);
  console.error(error.stack);
}

stack is extremely useful but its exact formatting is host-dependent, so do not parse it for application logic.

Intermediate example: validation versus exceptions

Collect all expected input issues without throwing:

js
function validateCartItem(input) {
  const issues = [];

  if (typeof input.productId !== "string" || input.productId.trim() === "") {
    issues.push("Product ID is required");
  }

  if (!Number.isInteger(input.quantity) || input.quantity < 1) {
    issues.push("Quantity must be a positive integer");
  }

  return issues;
}

const issues = validateCartItem({ productId: "", quantity: 0 });
console.log(issues);
// ["Product ID is required", "Quantity must be a positive integer"]

Then throw only at a boundary that requires valid data:

js
function createCartItem(input) {
  const issues = validateCartItem(input);

  if (issues.length > 0) {
    throw new Error(`Invalid cart item: ${issues.join("; ")}`);
  }

  return { productId: input.productId, quantity: input.quantity };
}

This distinction prevents exception handling from replacing ordinary conditional logic.

Wrapping with a cause

Error(message, { cause }) is appropriate when a layer can add useful context while retaining the original error:

js
function prepareCheckout(rawItem) {
  try {
    return createCartItem(rawItem);
  } catch (error) {
    throw new Error("Checkout preparation failed", { cause: error });
  }
}

try {
  prepareCheckout({ productId: "", quantity: 0 });
} catch (error) {
  console.error(error.message);        // Checkout preparation failed
  console.error(error.cause?.message); // Invalid cart item: ...
}

Do not catch merely to throw the same error or replace it while discarding context. Add a cause only when the new abstraction-level message helps.

Optional advanced extension

Selective handling lets unexpected programmer errors continue upward:

js
try {
  cart = addItem(cart, products, "p3", 10);
} catch (error) {
  if (error instanceof RangeError) {
    console.error(`Please adjust the quantity: ${error.message}`);
  } else {
    throw error;
  }
}

A catch-all that logs "Something went wrong" and continues can hide corrupted assumptions. Handle only failures this layer can resolve.

Common mistakes and debugging

  • Throwing strings: use new Error(message) or an appropriate built-in error subtype.
  • Catching too broadly: narrow the try block to operations that may throw and that this layer can address.
  • Swallowing errors: logging and continuing may leave invalid state. Recover intentionally or rethrow.
  • Using exceptions as all validation: expected user mistakes often deserve returned issue lists.
  • Accessing error.message blindly: JavaScript permits non-Error throws; check error instanceof Error.
  • Returning from finally: a return in finally can override a prior return or thrown exception. Avoid it.
  • Wrapping without cause: preserve the original as { cause: error } when adding context.
  • Ignoring the first owned stack frame: start debugging where your code first appears, not at the longest framework frame.

Best practices

  • Validate inputs near domain boundaries with specific, actionable messages.
  • Throw standard Error objects and built-in subtypes when meaningfully appropriate.
  • Keep try blocks small and catches purposeful.
  • Use finally only for unconditional cleanup.
  • Preserve original failures when wrapping with added context.
  • Keep state updates atomic: validate first, then return the new state.
  • Never expose sensitive internal data in user-facing error messages.

Exercises

Core

Write requirePositivePrice(price) that returns the price or throws RangeError. Catch and print its message for 0.

js
function requirePositivePrice(price) {
  if (typeof price !== "number" || !Number.isFinite(price) || price <= 0) {
    throw new RangeError("Price must be a positive finite number");
  }
  return price;
}

try {
  console.log(requirePositivePrice(0));
} catch (error) {
  console.error(error instanceof Error ? error.message : "Unknown error");
}

Output: Price must be a positive finite number

Practice

Write requireProduct(products, id). Use it in getProductLabel; add context with cause when lookup fails.

js
function requireProduct(products, id) {
  const product = products.find((item) => item.id === id);
  if (!product) {
    throw new Error(`No product has ID ${id}`);
  }
  return product;
}

function getProductLabel(products, id) {
  try {
    const product = requireProduct(products, id);
    return `${product.name} - $${product.price}`;
  } catch (error) {
    throw new Error("Could not build product label", { cause: error });
  }
}

try {
  getProductLabel(products, "p99");
} catch (error) {
  console.error(error.message);
  console.error(error.cause?.message);
}

Output:

text
Could not build product label
No product has ID p99

Professional Extension

Write safeAddItem returning { ok: true, cart } on success or { ok: false, message } for expected RangeError/missing-product errors, without changing the original cart.

js
function safeAddItem(cart, products, productId, quantity) {
  try {
    return {
      ok: true,
      cart: addItem(cart, products, productId, quantity),
    };
  } catch (error) {
    if (error instanceof Error) {
      return { ok: false, message: error.message };
    }
    throw error;
  }
}

const originalCart = [];
console.log(safeAddItem(originalCart, products, "p3", 4));
console.log(originalCart);

Output:

text
{ ok: false, message: "Only 2 Water Bottle item(s) are available" }
[]

Recap

Differentiate syntax, runtime, and validation failures. Why throw an Error instead of a string? When should a caller catch? What always executes in finally? When does { cause } add value, and why should a catch sometimes rethrow?

Official references

Testing asynchronous and callback utilities

Timing utilities are not correct merely because one happy-path callback ran. Tests should control time with fake timers, or use deliberately awaited real timers when demonstrating behavior. Cover repeated calls, argument and this forwarding, cancellation, trailing execution, exceptions, and teardown. For promises, assert both fulfillment and rejection and always return/await the test promise so an assertion cannot run after the test finishes.

js
async function assertRejects(promise, message) {
  try {
    await promise;
    throw new Error("Expected rejection");
  } catch (error) {
    console.assert(error.message === message);
  }
}

await assertRejects(
  Promise.reject(new Error("network")),
  "network",
);

An async function's synchronous try/catch catches only work performed before an await; put the await inside the try when the rejection belongs there:

js
async function loadLabel(load) {
  try {
    return await load();
  } catch (error) {
    throw new Error("Could not load label", { cause: error });
  }
}

Interview questions: Why does try { Promise.reject(...) } catch {} not catch the rejection? How do you prevent a timer callback from firing after component teardown? Which failures should a test assert by type versus message? What does an Error cause preserve?

Error taxonomy and recovery

Separate syntax errors, runtime errors, validation failures, domain failures, and transport failures. Catch an error only where the program can add context, recover, retry safely, or convert it into a user-facing result. A catch-all that returns an empty array hides defects.

For interview preparation, explain throwing versus returning a typed result, finally behavior, how errors cross async boundaries, and why a rejected Promise is not handled by a synchronous try/catch unless it is awaited inside that try block.