063: 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
Errorobjects from domain functions; - use
try,catch, andfinallywith clear responsibilities; - read an error's name, message, and stack trace;
- preserve a lower-level failure with
Error'scausewhen wrapping is useful.
Retrieval warm-up
- What does
find()return for a missing product? - Where should a static
importdeclaration appear? - Why is returning
undefinedsometimes 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.
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
undefinedis a runtimeTypeError. - Rejecting quantity
0is a domain validation decision; code chooses whether to return an issue or throw.
Worked beginner example: protected cart update
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):
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:
- The error type (
TypeError,ReferenceError, and so on). - The message describing the failed operation.
- The first stack frame in code you own: filename, line, and column.
- Values and assumptions at that line.
- Earlier caller frames to understand how invalid data arrived.
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:
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:
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:
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:
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
tryblock 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.messageblindly: JavaScript permits non-Error throws; checkerror instanceof Error. - Returning from
finally: a return infinallycan 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
Errorobjects and built-in subtypes when meaningfully appropriate. - Keep
tryblocks small and catches purposeful. - Use
finallyonly 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.
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.
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:
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.
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:
{ 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
- MDN: Control flow and error handling
- MDN:
throw - MDN:
try...catch - MDN:
Error()andcause - ECMA-262: Error Objects
- ECMA-262:
trystatement
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.
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:
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.
