FullStack Course LogoFullStack Course
Module: JavaScript
JavaScript·063·8 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

Before getting into error handling, recall three details from the earlier lessons:

  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 or missing data unless they check.

Vocabulary

  • Syntax error: A parse-time failure that prevents the whole script or module from running. — Source: MDN: SyntaxError
  • Runtime error: A failure thrown while otherwise-valid code is executing. — Source: MDN: Control flow and error handling
  • Exception: A thrown Error object that propagates until something catches it. — Source: MDN: try...catch
  • Throw: The statement that raises a value to signal failure. — Source: MDN: throw
  • Catch: The clause that receives the thrown value so code can handle it. — Source: MDN: try...catch
  • Finally: A block that runs after try/catch, regardless of whether the operation succeeded or failed. — Source: MDN: try...catch
  • Stack trace: A report of active frames that shows the path to the error's origin. — Source: MDN: Error.prototype.stack
  • Defensive programming: Validating assumptions at useful boundaries (course term).
  • Cause: The original failure retained when a higher-level error is created (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

Think of an error as information moving upward through the call stack. A function returns a normal result when it can fulfill its normal contract. If it cannot, it may throw an Error; a higher layer that understands the situation decides whether to recover, report the problem, or rethrow it.

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. An Error gives you standard name and message fields and typically a useful stack. JavaScript does technically allow any value to be thrown, though, so catch code must not assume blindly that the received value is an Error.

Exceptions are not the right response to every invalid input. A form can return validation messages because invalid user input is an expected part of that interaction. Throw when the function cannot honor its promised operation, or when continuing would leave the application with misleading state.

There are three broad failure categories to keep separate:

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

Worked beginner example: protected cart update

Here the update validates all of its assumptions before returning a new cart. That means a failed operation cannot assign a partially updated cart.

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 transfers from the throw directly to catch, so "Item added" is skipped. Because the pure update did not assign until it had succeeded, the cart remains unchanged. finally runs after either success or failure. In real code, use it for unconditional cleanup such as resetting a loading flag or releasing a resource; ordinary output can usually follow the statement without being placed in finally.

RangeError tells the caller that a numeric value is outside the permitted range. A general Error is enough for a missing domain record. At this point, avoid inventing custom error classes before there is a concrete need for them.

Reading a stack trace

When an error is unexpected, do not respond by immediately wrapping a large section of code in try...catch. First read the failure:

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

The stack property is extremely useful for diagnosis, but its exact format depends on the host. Do not parse it as application data or build application logic around its formatting.

Intermediate example: validation versus exceptions

For expected input problems, collect the issues and return them together instead of throwing on the first one:

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"]

A later boundary can require valid data and throw if that requirement is not met:

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 separation keeps exception handling from replacing ordinary conditional logic. The validator describes expected input problems; the creator enforces its stronger contract.

Wrapping with a cause

Error(message, { cause }) is useful when a layer can add meaningful context without losing the original failure:

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 only to throw the same error again, or replace an error while discarding its context. Add a cause when the new message explains the failure at a useful abstraction level.

Optional advanced extension

Selective handling allows failures this layer understands to be handled while 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 conceal a broken assumption and leave the application in a corrupted state. Handle only failures this layer can actually resolve.

Common mistakes and debugging

  • Throwing strings: use new Error(message) or an appropriate built-in error subtype.
  • Catching too broadly: keep the try block focused on operations that may throw and that this layer can address.
  • Swallowing errors: logging and continuing may leave invalid state. Recover deliberately 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 previous return or thrown exception. Avoid it.
  • Wrapping without cause: preserve the original with { cause: error } when adding context.
  • Ignoring the first owned stack frame: begin debugging where your code first appears, rather than at the longest framework frame.

Best practices

  • Validate inputs near domain boundaries and use specific, actionable messages.
  • Throw standard Error objects and built-in subtypes when their meaning is appropriate.
  • Keep try blocks small and catches purposeful.
  • Use finally only for unconditional cleanup.
  • Preserve original failures when wrapping them with additional 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) so it returns the price or throws RangeError. Catch the error 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, and add context with cause when the 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 so it returns { ok: true, cart } on success or { ok: false, message } for expected RangeError or 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

Can you 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 proven correct merely because one happy-path callback ran. Tests should control time with fake timers, or deliberately await real timers when the behavior itself is what you are demonstrating. Cover repeated calls, argument and this forwarding, cancellation, trailing execution, exceptions, and teardown. For promises, assert both fulfillment and rejection, and always return or await the test promise so an assertion cannot run after the test has finished.

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",
);

In an async function, a synchronous try/catch catches only work performed before an await. Put the await inside the try when that rejection belongs to the operation being handled:

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 rather than message? What does an Error cause preserve?

Error taxonomy and recovery

Keep syntax errors, runtime errors, validation failures, domain failures, and transport failures distinct. Catch an error only at a point where the program can add useful context, recover, retry safely, or turn it into a user-facing result. A catch-all that returns an empty array hides defects instead of handling them.

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

Reader page: /javascript/lesson/063/errors-exceptions-and-recovery