JavaScript · lesson 11 of 24
Beginner
11- 1 Introduction to JavaScript
- 2 Setting Up JavaScript Development Environment
- 3 Variables in JavaScript: var, let, and const
- 4 JavaScript Data Types
- 5 JavaScript Operators
- 6 Control Flow in JavaScript
- 7 Functions in JavaScript
- 8 Working with Strings in JavaScript
- 9 Arrays in JavaScript
- 10 Objects in JavaScript
- 18 DOM Manipulation in JavaScript
Intermediate
10- 11 Error Handling in JavaScript
- 12 Object-Oriented Programming in JavaScript
- 13 Prototypes and Prototype Chain in JavaScript
- 14 Closures in JavaScript
- 15 JavaScript Event Loop Explained
- 16 Asynchronous JavaScript: Callbacks, Promises, and Async/Await
- 17 JavaScript Modules: ESM and CommonJS
- 19 Browser Web APIs in JavaScript
- 20 Testing JavaScript with Jest and Vitest
- 24 JavaScript Interview Preparation: Top 35 Questions
Advanced
3Error Handling in JavaScript
Learn how to handle errors in JavaScript — try/catch/finally, built-in error types, custom error classes, rethrowing, and async error patterns.
By SkillByExample · Updated
An error you don’t handle stops your program; an error you handle badly hides the bug. This lesson covers both halves — the mechanics of throw/try/catch/finally, and the habits that keep errors informative. Every example runs with node.
try / catch / finally
catch runs only if something in try throws; finally runs either way.
function parseJSON(raw) {
try {
console.log(" try: parsing", JSON.stringify(raw));
return JSON.parse(raw);
} catch (err) {
console.log(" catch:", err.name, "-", err.message);
return null;
} finally {
console.log(" finally: always runs");
}
}
console.log("result:", parseJSON('{"name":"Alice"}'));
console.log("result:", parseJSON("not json"));
$ node trycatch.js
try: parsing "{\"name\":\"Alice\"}"
finally: always runs
result: { name: 'Alice' }
try: parsing "not json"
catch: SyntaxError - Unexpected token 'o', "not json" is not valid JSON
finally: always runs
result: null
finally ran in both cases — even after return in the try block, it runs before the function actually returns, which is why result: prints last each time. The error message from JSON.parse even quotes the bad input, which is more useful than anything you’d write yourself.
finally for Cleanup
Anything you open, finally closes — whether the work succeeded, failed, or returned early.
function openFile(path) {
console.log(`open ${path}`);
return { read: () => (path.endsWith(".json") ? '{"port": 8080}' : "port=8080"),
close: () => console.log(`close ${path}`) };
}
function readConfig(path) {
const file = openFile(path);
try {
return JSON.parse(file.read());
} catch (err) {
throw new Error(`Config error in ${path}`, { cause: err });
} finally {
file.close();
}
}
console.log(readConfig("app.json"));
try {
readConfig("app.ini");
} catch (err) {
console.log(err.message);
console.log(" cause:", err.cause.message);
}
$ node finally.js
open app.json
close app.json
{ port: 8080 }
open app.ini
close app.ini
Config error in app.ini
cause: Unexpected token 'p', "port=8080" is not valid JSON
Both files were closed. The second failure was rethrown with context — which file failed — while { cause: err } (ES2022) keeps the original SyntaxError attached instead of flattening it into a string. Error-tracking tools and Node’s own uncaught-error printer display the whole cause chain.
Built-in Error Types
Every built-in error extends Error and has a name and a message.
const attempts = {
TypeError: () => null.toString(),
RangeError: () => new Array(-1),
ReferenceError: () => undeclaredVariable,
SyntaxError: () => JSON.parse("{bad json}"),
URIError: () => decodeURIComponent("%"),
};
for (const [expected, fn] of Object.entries(attempts)) {
try {
fn();
} catch (e) {
console.log(e.name === expected, e instanceof Error, `${e.name}: ${e.message}`);
}
}
$ node builtin.js
true true TypeError: Cannot read properties of null (reading 'toString')
true true RangeError: Invalid array length
true true ReferenceError: undeclaredVariable is not defined
true true SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)
true true URIError: URI malformed
The TypeError is the one you’ll see most — “Cannot read properties of null/undefined” almost always means a lookup returned nothing and the code kept going. The message text differs between engines (V8, SpiderMonkey, JavaScriptCore), so branch on the type with instanceof or name, never on the message.
Custom Error Classes
Subclass Error to carry structured data — a status code, the field that failed — that a caller can branch on.
class AppError extends Error {
constructor(message, { code, status = 500, ...rest } = {}) {
super(message);
this.name = this.constructor.name;
Object.assign(this, { code, status }, rest);
}
}
class NotFoundError extends AppError {
constructor(resource, id) {
super(`${resource} ${id} not found`, { code: "NOT_FOUND", status: 404, resource, id });
}
}
class ValidationError extends AppError {
constructor(field, message) { super(message, { code: "VALIDATION", status: 400, field }); }
}
const users = new Map([[1, { id: 1, name: "Alice" }]]);
function handleRequest(id, email) {
try {
if (!email.includes("@")) throw new ValidationError("email", "Email must contain @");
if (!users.has(id)) throw new NotFoundError("User", id);
return { status: 200, user: users.get(id) };
} catch (err) {
if (err instanceof AppError) return { status: err.status, error: err.message, field: err.field };
throw err;
}
}
console.log(handleRequest(1, "alice@example.com"));
console.log(handleRequest(1, "alice"));
console.log(handleRequest(2, "bob@example.com"));
try { handleRequest(1, undefined); } catch (err) { console.log("rethrown:", err.name, err.message); }
$ node custom.js
{ status: 200, user: { id: 1, name: 'Alice' } }
{ status: 400, error: 'Email must contain @', field: 'email' }
{ status: 404, error: 'User 2 not found', field: undefined }
rethrown: TypeError Cannot read properties of undefined (reading 'includes')
this.name = this.constructor.name makes every subclass name itself, so logs and stack traces say NotFoundError rather than plain Error. The boundary branches on instanceof AppError — expected failures become 400/404 responses. The last call passed undefined as the email: that’s a bug, not a user error, and the TypeError was deliberately not turned into a response. Rethrow what you didn’t expect.
Async Errors
With await, a rejected promise throws at the await, so ordinary try/catch works. Without await, the rejection escapes.
const fetchData = () => Promise.reject(new Error("503 Service Unavailable"));
async function buggy() {
try {
const data = fetchData();
return "buggy finished";
} catch (err) {
return "buggy caught it";
}
}
async function fixed() {
try {
const data = await fetchData();
return "fixed finished";
} catch (err) {
return `fixed caught: ${err.message}`;
}
}
process.on("unhandledRejection", (reason) => console.log("unhandledRejection:", reason.message));
console.log(await buggy());
console.log(await fixed());
$ node asyncerr.mjs
buggy finished
fixed caught: 503 Service Unavailable
unhandledRejection: 503 Service Unavailable
buggy reported success — the catch never ran, because creating a rejected promise doesn’t throw. The rejection then surfaced on its own as unhandledRejection. Without that process.on handler, Node (since v15) would have printed the error and exited with code 1. With await, the rejection became an exception exactly where you’d expect.
The [error, value] Tuple
Some codebases wrap promises so they never throw, returning [err, value] Go-style.
async function attempt(promise) {
try {
return [null, await promise];
} catch (err) {
return [err, null];
}
}
const fetchUser = async (id) => {
if (id !== 1) throw new Error(`user ${id} not found`);
return { id, name: "Alice" };
};
for (const id of [1, 2]) {
const [err, user] = await attempt(fetchUser(id));
if (err) console.log("could not load:", err.message);
else console.log("loaded:", user.name);
}
$ node attempt.mjs
loaded: Alice
could not load: user 2 not found
Handling moves inline, next to the call. The trade-off: nothing forces you to check err — forget it and you get TypeError: Cannot read properties of null one line later, the very error the pattern was meant to avoid.
Global Handlers: the Last Resort
Global handlers catch what everything else missed. They are for logging and a clean exit — not for recovery.
process.on("uncaughtException", (err, origin) => {
console.log(`[fatal] ${origin}: ${err.message}`);
console.log("[fatal] flushing logs, exiting with code 1");
process.exit(1);
});
setTimeout(() => {
console.log("timer fired");
JSON.parse("{oops");
}, 10);
setTimeout(() => console.log("never runs"), 50);
console.log("started");
$ node global.js; echo "exit code: $?"
started
timer fired
[fatal] uncaughtException: Expected property name or '}' in JSON at position 1 (line 1 column 2)
[fatal] flushing logs, exiting with code 1
exit code: 1
The exception was thrown inside a timer callback — there’s no try on the stack to catch it, so it went straight to the global handler. Exiting is mandatory: after an uncaught exception you don’t know what state the program is in, which is why the second timer should never get to run. In browsers the equivalents are window.addEventListener("error", …) and "unhandledrejection".
Pitfalls
Two habits cause most error-handling bugs: swallowing, and returning from finally.
function swallow() {
try {
JSON.parse("{bad");
} catch (e) {}
return "looks fine";
}
function override() {
try {
throw new Error("the real problem");
} finally {
return "from finally";
}
}
console.log(swallow());
console.log(override());
$ node pitfalls.js
looks fine
from finally
Both functions report success. The empty catch discarded the parse error with no trace. The return inside finally is worse: it silently cancels the in-flight exception, and it would override a return in try the same way. Keep finally for cleanup only, and make every catch either handle the error meaningfully, add context and rethrow, or at least log it.
Key Takeaways
finallyruns aftertry/catchno matter how they exit — use it for cleanup, never forreturn.- Rethrow with context using
new Error(msg, { cause: err }). - Branch on error types (
instanceof), not message text. - Only catch what you can handle; rethrow the rest.
- A promise only throws where it’s
awaited — a missingawaitmeans a missing catch. - Global handlers log and exit; they don’t recover.
Practice
1. Write safeDivide(a, b) that throws a RangeError for division by zero, and a caller that handles only that type.
function safeDivide(a, b) {
if (b === 0) throw new RangeError("division by zero");
return a / b;
}
for (const [a, b] of [[10, 2], [1, 0]]) {
try {
console.log(`${a} / ${b} =`, safeDivide(a, b));
} catch (err) {
if (!(err instanceof RangeError)) throw err;
console.log(`${a} / ${b}: ${err.message}`);
}
}
console.log("1 / 0 without the check:", 1 / 0);
$ node p1.js
10 / 2 = 5
1 / 0: division by zero
1 / 0 without the check: Infinity
JavaScript doesn’t throw on division by zero — it returns Infinity — so if zero is invalid in your domain, you have to throw yourself.
2. Add a ConflictError (status 409) to the custom-error hierarchy and show that the boundary handles it with no changes.
class AppError extends Error {
constructor(message, { status = 500 } = {}) {
super(message);
this.name = this.constructor.name;
this.status = status;
}
}
class ConflictError extends AppError {
constructor(email) { super(`${email} already registered`, { status: 409 }); }
}
function boundary(fn) {
try { return fn(); }
catch (err) {
if (err instanceof AppError) return { status: err.status, name: err.name, error: err.message };
throw err;
}
}
console.log(boundary(() => { throw new ConflictError("alice@example.com"); }));
$ node p2.js
{
status: 409,
name: 'ConflictError',
error: 'alice@example.com already registered'
}
The boundary checks the base class, so every new subclass is handled automatically — and name came out right without the subclass setting it.
3. Wrap a failing step with { cause } twice and print the whole chain.
function readFile() { throw new Error("ENOENT: no such file 'db.json'"); }
function loadDb() {
try { readFile(); } catch (err) { throw new Error("could not load database", { cause: err }); }
}
function start() {
try { loadDb(); } catch (err) { throw new Error("startup failed", { cause: err }); }
}
try { start(); } catch (err) {
for (let e = err, depth = 0; e; e = e.cause, depth++) {
console.log(" ".repeat(depth * 2) + e.message);
}
}
$ node p3.js
startup failed
could not load database
ENOENT: no such file 'db.json'
Each layer adds what it knows, and the root cause survives at the bottom.
4. Make a withTimeout(promise, ms) that rejects with a custom TimeoutError, and catch it by type.
class TimeoutError extends Error { name = "TimeoutError"; }
function withTimeout(promise, ms) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new TimeoutError(`gave up after ${ms} ms`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
const slow = new Promise((resolve) => setTimeout(() => resolve("done"), 200));
try {
await withTimeout(slow, 50);
} catch (err) {
console.log(err instanceof TimeoutError, err.name, err.message);
}
$ node p4.mjs
true TimeoutError gave up after 50 ms
A class field (name = "TimeoutError") is a one-line alternative to setting this.name in a constructor.