Course: JavaScript Advanced
Lesson 16: Error Architecture
Level
Advanced
Estimated Reading Time
10–15 minutes
Goal
By the end of this lesson, the learner will be able to:
- Create custom Error subclasses that carry structured information about specific failure types
- Implement structured error handling that distinguishes between error categories
- Design error boundaries in UI logic that prevent one failure from crashing an entire page
Frontend Development Context
Poor error handling is one of the leading causes of poor user experience in web applications. When an API call fails, does the user see a helpful message or a blank screen? When a form input is invalid, does the UI communicate why clearly? When a network error occurs during checkout, does the app let the user retry or just silently fail? The difference between these outcomes is error architecture — deliberately designed error types, recovery paths, and user-facing error communication.
At the advanced level, error handling is not about catching errors to suppress them — it is about capturing enough structured information at the point of failure to make good decisions higher up the call chain. Custom Error classes let you express "this is a validation error," "this is a network error," or "this is a permission error" in code, so the callers handling those errors can respond appropriately rather than treating every error the same way.
Explanation
JavaScript's built-in Error class can be extended with class MyError extends Error. Subclasses can carry additional properties — an HTTP status code, a field name, an error code string — and preserve their type so callers can use instanceof to check which kind of error occurred. Always call super(message) in the constructor and set this.name to the class name so stack traces and error messages are readable.
Error hierarchies group related error types under a common base class. For example, an AppError base class might be extended by ValidationError, NetworkError, and AuthError. Catch blocks can then match broadly (catch (err) { if (err instanceof AppError) ... }) or specifically (if (err instanceof ValidationError) ... ). This hierarchy lets you handle categories of errors at different levels of the call stack.
Structured error handling means thinking about where errors should be caught and what should happen there. A low-level data-fetching function should catch network errors and convert them into structured NetworkError instances. A UI controller should catch those and decide whether to show a retry option, a fallback, or redirect the user. A global handler should catch anything that slipped through and log it for monitoring.
Error boundaries in UI logic (the JavaScript equivalent of React's ErrorBoundary components) are functions or wrapper classes that execute a piece of logic and intercept failures, providing a fallback behavior instead of propagating the error. This prevents one failed widget from crashing the entire page. The pattern is: try to render the section; if it fails, render a fallback instead of re-throwing.
The finally block runs regardless of whether the try succeeded or the catch ran. It is the correct place for cleanup: closing a loading spinner, releasing a resource, resetting a flag. Even if catch re-throws an error, finally still runs.
Code Example
This example defines a custom Error hierarchy, a structured fetch wrapper that converts HTTP failures into typed errors, and a UI-level error boundary function.
// --- Custom Error hierarchy ---
class AppError extends Error {
constructor(message, code) {
super(message);
this.name = "AppError";
this.code = code;
}
}
class NetworkError extends AppError {
constructor(message, statusCode) {
super(message, "NETWORK_ERROR");
this.name = "NetworkError";
this.statusCode = statusCode;
}
}
class ValidationError extends AppError {
constructor(message, field) {
super(message, "VALIDATION_ERROR");
this.name = "ValidationError";
this.field = field;
}
}
class AuthError extends AppError {
constructor(message) {
super(message, "AUTH_ERROR");
this.name = "AuthError";
}
}
// --- Structured fetch wrapper ---
async function apiFetch(url, options = {}) {
let response;
try {
response = await fetch(url, options);
} catch (err) {
throw new NetworkError("Network request failed — check your connection", 0);
}
if (response.status === 401 || response.status === 403) {
throw new AuthError("You do not have permission to access this resource");
}
if (!response.ok) {
throw new NetworkError(
`Server returned ${response.status}: ${response.statusText}`,
response.status
);
}
return response.json();
}
// --- Error boundary for UI sections ---
async function renderSection(sectionId, fetchFn) {
const container = document.querySelector(`#${sectionId}`);
container.innerHTML = "<p>Loading...</p>";
try {
const data = await fetchFn();
container.innerHTML = `<p>${JSON.stringify(data)}</p>`;
} catch (err) {
if (err instanceof AuthError) {
container.innerHTML = `<p class="error">Please log in to view this section.</p>`;
} else if (err instanceof NetworkError) {
container.innerHTML = `
<p class="error">Failed to load (${err.statusCode}): ${err.message}</p>
<button onclick="renderSection('${sectionId}', fetchFn)">Retry</button>
`;
} else if (err instanceof ValidationError) {
container.innerHTML = `<p class="error">Invalid input for "${err.field}": ${err.message}</p>`;
} else {
container.innerHTML = `<p class="error">An unexpected error occurred.</p>`;
console.error("Unhandled error in section:", sectionId, err);
}
}
}
// --- Validation with typed errors ---
function validateEmail(email) {
if (!email || typeof email !== "string") {
throw new ValidationError("Email is required", "email");
}
if (!email.includes("@")) {
throw new ValidationError("Email must contain @", "email");
}
}
try {
validateEmail("notanemail");
} catch (err) {
if (err instanceof ValidationError) {
console.log(`Field "${err.field}" failed: ${err.message}`);
}
}
Code Explanation
The AppError base class adds a code property to the standard Error. Setting this.name in each subclass ensures that err.name in a stack trace or log entry says "NetworkError" rather than just "Error". Subclasses add their own specific properties: statusCode for NetworkError, field for ValidationError.
apiFetch wraps fetch and converts all failure modes into typed errors. A network-level failure (no connection, DNS failure) becomes a NetworkError with status 0. A 401 or 403 becomes an AuthError. Any other non-2xx response becomes a NetworkError with the HTTP status code. The function itself never returns a bad response — it either returns parsed JSON or throws a typed error.
renderSection is the UI-level error boundary. It tries to fetch and render a section, and if any error is thrown, it checks the error type with instanceof and renders an appropriate fallback — not a blank section, not a crash, but targeted user-facing feedback. An AuthError shows a login prompt. A NetworkError shows the status and a retry button. Unknown errors are logged but still show a generic message rather than crashing.
validateEmail throws a ValidationError with both a message and a field name. The caller can display err.field to highlight the specific input that failed and err.message to explain why — which is exactly the information needed to render good form validation UI.
Pattern Highlights
✅ Positive Pattern: Set
this.nameexplicitly in custom Error subclasses — without it,err.nameshows "Error" in logs and stack traces regardless of the actual class, making debugging much harder.
⚠️ Neutral Note: Deep
instanceofchains in catch blocks can become brittle if the error hierarchy changes. An alternative is to use an errorcodestring property and switch on that — it's more flexible when error types come from multiple sources or libraries.
❌ Negative Pattern: Do not swallow errors silently with an empty
catchblock — catching an error without logging it, rethrowing it, or showing user feedback makes failures invisible and debugging nearly impossible.
Common Mistakes
Mistake 1: Throwing plain strings instead of Error objects
Throwing a string instead of an Error loses the stack trace, making it impossible to know where the error originated.
function processPayment(amount) {
if (amount <= 0) {
throw "Amount must be positive"; // no stack trace, no instanceof check possible
}
}
Always throw an Error (or a subclass): throw new ValidationError("Amount must be positive", "amount"). The stack trace is essential for debugging production issues.
Mistake 2: Catching too broadly and losing error type information
A broad catch block that converts every error to a generic message throws away the structured information that was carefully put into the error.
try {
await apiFetch("/api/user");
} catch (err) {
showMessage("Something went wrong."); // treats NetworkError, AuthError, all the same
}
Check instanceof or err.code to provide targeted responses. Show a login prompt for auth errors, a retry button for network errors, and form field highlights for validation errors.
Mistake 3: Not using finally for cleanup
If cleanup code is only in the try block, it won't run when an error is thrown.
async function saveData() {
setLoading(true);
try {
await fetch("/api/save", { method: "POST" });
setLoading(false); // not reached if fetch throws
} catch (err) {
handleError(err);
// setLoading(false) was forgotten in catch — spinner runs forever
}
}
Put setLoading(false) in a finally block so it runs regardless of success or failure.
Quick Recap
- Extend the built-in
Errorclass to create custom error types that carry structured information (status codes, field names, error codes) and supportinstanceofchecks for type-specific handling. - Structured error handling means converting low-level failures into typed errors close to the source, then making recovery decisions higher up in the call stack where context is available.
- UI-level error boundaries catch errors from sections or components and render targeted fallbacks — a login prompt for auth errors, a retry button for network errors — instead of propagating errors that crash the whole page.
- Always set
this.namein custom Error subclasses, never swallow errors silently, and usefinallyfor cleanup code that must run regardless of outcome.