JavaScript Errors and Defensive Control Flow
Handle JavaScript exceptions deliberately: throw and catch Error objects, preserve causes, clean up with finally, define custom errors, aggregate failures, and design defensive control-flow boundaries.
Intermediate
JavaScript exceptions are a control-flow mechanism: an operation can produce an abrupt completion, and the language can transfer control to a matching catch clause. Good error handling is not just wrapping code in try/catch. You need to distinguish programmer errors from expected failures, preserve useful cause information, clean up reliably, and choose where an error should be handled.
Throwing is an explicit control-flow transfer
The throw statement evaluates its expression and immediately produces a throw completion. JavaScript can throw any value, but application code should normally throw Error objects or subclasses so callers receive a predictable error shape and stack information.
Output
Ready to run.
Quick check
What does throw do?
throw new Error("failed");
Error objects carry structured information
The standard Error object exposes name and message through its prototype, while engines commonly provide stack information as implementation-defined debugging data. Error options can also provide a cause, allowing a higher-level error to preserve the lower-level failure without replacing it with an unrelated message.
Output
Ready to run.
Quick check
Why use the cause option?
new Error("outer", { cause: originalError });
try/catch defines an exception boundary
A try block encloses code where a throw completion may occur. If the try block completes normally, its catch clause is skipped. If it throws, the thrown value becomes the catch binding and the catch block gets a chance to recover, transform, log, or rethrow it.
Output
Ready to run.
Quick check
When does catch run?
try { throw new Error("x"); } catch (error) { /* handles x */ }
Catch bindings are scoped
A catch parameter is a lexical binding local to the catch clause. Optional catch binding lets you handle an error without naming it when the value itself is not needed.
Output
Ready to run.
Quick check
When is catch without a binding useful?
try { risky(); } catch { recover(); }
finally is for code that must run
A finally block runs after the try/catch path whether control leaves the protected code normally or abruptly. It is especially useful for cleanup. A return, throw, break, or continue inside finally can replace an earlier completion, so finally should normally perform cleanup rather than decide unrelated application results.
Output
Ready to run.
Quick check
What is a typical purpose of finally?
try { acquire(); } finally { release(); }
Do not accidentally override a useful completion in finally
Because finally participates in completion propagation, a return or throw inside finally can replace a value or error that was already in flight. This is a subtle source of swallowed failures and should generally be avoided in cleanup code.
Output
Ready to run.
Quick check
Why should return inside finally be avoided?
function f() { try { throw new Error("x"); } finally { return "hidden"; } }
Catch the failures you can actually handle
A broad catch block can turn useful failures into silent corruption. Prefer handling errors at the boundary where recovery is meaningful. When a failure cannot be handled safely, rethrow it so a higher-level boundary can decide what to do.
Output
Ready to run.
Quick check
What should happen to an error the current layer cannot safely handle?
catch (error) { if (error instanceof TypeError) recover(); else throw error; }
Use error classes to express categories
Custom Error subclasses let callers distinguish categories with instanceof while retaining the standard Error behavior. Set the name when a custom category benefits from a stable descriptive name, and pass a message plus optional cause when wrapping another failure.
Output
Ready to run.
Quick check
What is the main benefit of a custom Error subclass?
class ValidationError extends Error {}
AggregateError represents multiple failures
AggregateError groups multiple errors into one Error object. Its errors property contains the collected errors, which is useful when an operation intentionally evaluates multiple independent tasks and wants to report their failures together.
Output
Ready to run.
Quick check
What does AggregateError provide?
throw new AggregateError([errorA, errorB], "batch failed");
Error.isError checks Error objects more directly
Current ECMAScript includes Error.isError. It checks whether a value is an Error object according to the language specification, rather than requiring a particular constructor identity from a single realm. It is useful when the question is “is this an Error object?” rather than “is this instance of my particular subclass?”
Output
Ready to run.
Quick check
What question does Error.isError answer?
Error.isError(new Error("x"));
Rethrowing preserves the right boundary
A catch block can add context and rethrow the same error, or throw a new error with cause. This lets low-level code add useful context without pretending it has enough information to recover.
Output
Ready to run.
Quick check
What is a good way to add higher-level context?
catch (error) { throw new Error("load failed", { cause: error }); }
Cleanup and resource lifetime
When code owns a resource, cleanup belongs near the ownership boundary. try/finally is the classic synchronous mechanism. If an operation is asynchronous, cleanup must also respect the asynchronous lifetime of the resource; try/finally can still be used around await-based code.
Output
Ready to run.
Quick check
Where should resource cleanup normally live?
acquire(); try { use(); } finally { release(); }
Defensive control flow is not “catch everything”
Defensive code validates boundaries, chooses meaningful error categories, preserves causes, cleans up deterministically, and lets unrecoverable failures propagate. Catching every error and returning a default value can hide defects and make later failures harder to diagnose.
Output
Ready to run.
Quick check
Which strategy is most defensive?
function save(value) { if (value == null) throw new TypeError("value required"); }
Specification mental model: completions
ECMAScript specifies many operations in terms of completion records. Normal completion carries a value; abrupt completion represents control flow such as throw, return, break, or continue. try/catch/finally is defined in terms of these completions, which explains why finally can replace an earlier result.
Output
Ready to run.
Quick check
Why does the completion model matter to authors?
try { return 1; } finally { /* cleanup */ }
Authoring rules
Prefer Error objects over arbitrary thrown primitives. Give custom failures stable categories. Catch only where you can recover or add useful context. Preserve lower-level failures with cause. Put cleanup in finally. Avoid return or throw from finally unless deliberately replacing an earlier completion. Do not turn unexpected failures into silent defaults.
Practice: design an error boundary
Build a small function that parses configuration, throws ValidationError for bad input, wraps an unexpected lower-level failure with cause, and uses a caller-level catch to display a safe message. Then add a finally block that records cleanup. Trace which layer owns recovery and which layer merely adds context.
Specification references
Primary language authority: ECMA-262, current ECMAScript specification. Relevant sections include ThrowStatement and the try statement, Error objects, native Error types, AggregateError, Error.isError, and InstallErrorCause. Current specification URL: https://tc39.es/ecma262/multipage/ .
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.