Error Handling
Dealing with things that go wrong: reading error messages, try, catch and finally, throwing your own errors, the built-in error types, and handling errors in asynchronous code.
Kinds of errors
The general categories of errors apply to JavaScript as to any language. The syllabus of the JSE exam names four classes, which differ in when you find out about them.
Syntax errors
A syntax error breaks the grammar of the language: a missing bracket, a misspelled keyword. The engine parses the whole script before running it, finds the error, and runs nothing at all — not even the lines above it:
console.log("Reading meters...");
const total = 412.5 + ;
console.log(total);
SyntaxError: Unexpected token ';'
A syntax error cannot be caught by the script that contains it; you
fix the code. (A syntax error in text that is parsed
while the program runs, such as
JSON.parse("{oops}"), is a different case: that is a
runtime error of type SyntaxError, and you can catch
it.)
Semantic and runtime errors
A semantic error is grammatically correct code that does not make sense: calling a number as if it were a function, using a variable that was never declared. Compiled languages often reject such code before it runs. JavaScript checks very little in advance, so it notices them only when it reaches the line, as a runtime error: the engine throws an exception and, if nothing handles it, the program stops there. Everything before that line has already run.
const pricePerKwh = 0.25;
console.log("Calculating invoice...");
console.log(pricePerKwh(415.8));
Calculating invoice... TypeError: pricePerKwh is not a function
Logic errors
A
logic error
produces no message at all: the program runs and gives the wrong
answer. JavaScript's loose rules make these common — for example
"5" + 3 is "53", not 8, and
[10, 9, 1].sort() sorts as text. Only tests and careful
checking find them (see
Debugging and Developer Tools).
const readings = ["412.5", "415.8"]; // read from a form: strings
const usage = readings[1] - readings[0]; // - converts to numbers: OK
const total = readings[0] + readings[1]; // + joins strings: logic error
console.log(usage.toFixed(1), total);
3.3 412.5415.8
Reading an error message
When an error is not handled, Node.js prints a report and stops.
Suppose billing.js contains:
function averageUsage(readings) {
return readings.reduce((sum, r) => sum + r.kwh, 0) / readings.length;
}
const customer = { id: "C-1042", name: "R. Jansen" };
console.log(averageUsage(customer.readings));
Running node billing.js prints:
/home/amara/billing.js:2
return readings.reduce((sum, r) => sum + r.kwh, 0) / readings.length;
^
TypeError: Cannot read properties of undefined (reading 'reduce')
at averageUsage (/home/amara/billing.js:2:19)
at Object.<anonymous> (/home/amara/billing.js:6:13)
at Module._compile (node:internal/modules/cjs/loader:1521:14)
...
Node.js v20.20.0
Read it in this order:
-
The line with the error type and message:
TypeError: Cannot read properties of undefined (reading 'reduce'). Something wasundefined, and the code tried to read.reducefrom it. -
The first
atline: where it happened — functionaverageUsage, filebilling.js, line 2, column 19. The source line with the^marker at the top shows the same spot. -
The lines below: how the program got there. Line
6 of the main script called
averageUsage. Lines starting withnode:internalare Node.js itself and can usually be ignored.
This list of calls is the
stack trace. Unlike a Python traceback, it lists the most recent call
first. As so often, the line that failed is not where the mistake
is: the customer object has no readings
property, so the real question is why it was passed in without one.
The browser console shows the same information, with clickable file
links (see
Debugging and Developer Tools).
Error objects and the built-in types
Every error the engine throws is an
error object: an instance of
Error or one of its subclasses. It has three useful
properties: name (the type, such as
"TypeError"), message (the description)
and stack (the stack trace as a string; not part of the
language standard, but every major engine provides it).
| Type | Thrown when | Example |
|---|---|---|
SyntaxError |
code or data cannot be parsed | JSON.parse("{kwh: 1}") |
ReferenceError |
a name is not declared, or used before its declaration | console.log(tarif) (typo) |
TypeError |
a value has the wrong type for the operation | undefined.length, (5)() |
RangeError |
a number is outside the allowed range |
(415.8).toFixed(200),
new Array(-1)
|
URIError |
a malformed URI is decoded | decodeURIComponent("%") |
AggregateError |
several errors at once | all promises in Promise.any() fail |
The two you will see most are worth knowing precisely. A
ReferenceError
means a variable name could not be resolved: it was never declared
(often a typo), or it is a let/const
variable used before its declaration line. A
TypeError means the
value exists but cannot be used that way: reading a property of
undefined or null, calling something that
is not a function, assigning to a const.
const attempts = [
() => JSON.parse("{kwh: 1}"),
() => tarif * 2, // typo for a variable "tariff"
() => undefined.length,
() => (415.8).toFixed(200),
];
for (const attempt of attempts) {
try {
attempt();
} catch (error) {
console.log(`${error.name}: ${error.message}`);
}
}
SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2) ReferenceError: tarif is not defined TypeError: Cannot read properties of undefined (reading 'length') RangeError: toFixed() digits argument must be between 0 and 100
try, catch and finally
The try statement lets your program deal with an error
instead of stopping. Put the code that might fail in the
try block. If it throws, the rest of that block is
skipped and the
catch block runs,
receiving the error object as its parameter. If nothing throws, the
catch block is skipped.
const lines = ['{"meter":"MTR-0042","kwh":415.8}', "{meter: MTR-0043}", '{"meter":"MTR-0044","kwh":98.1}'];
let total = 0;
for (const line of lines) {
try {
const reading = JSON.parse(line);
total += reading.kwh;
console.log(`OK ${reading.meter}`);
} catch (error) {
console.log(`Skipped ${line} (${error.name})`);
}
}
console.log(`Total: ${total.toFixed(1)} kWh`);
OK MTR-0042
Skipped {meter: MTR-0043} (SyntaxError)
OK MTR-0044
Total: 513.9 kWh
Since ES2019 you may leave out the parameter when you do not need
the error: catch { … }. Variables declared with
let or const inside try exist
only in that block, so declare them before the try if
you need them afterward.
finally
A
finally block runs
after try and catch, whatever happened: no
error, a caught error, an error that was not caught, or even a
return inside the try. It is the place for
clean-up that must always happen: closing a file or connection,
hiding a "loading" indicator, releasing a lock.
function importBatch(name, data) {
console.log(`[${name}] open connection`);
try {
const rows = JSON.parse(data);
console.log(`[${name}] imported ${rows.length} rows`);
return rows.length;
} catch (error) {
console.log(`[${name}] import failed: ${error.name}`);
return 0;
} finally {
console.log(`[${name}] close connection`); // runs in both cases
}
}
console.log(importBatch("monday", "[1, 2, 3]"));
console.log(importBatch("tuesday", "[1, 2,"));
[monday] open connection [monday] imported 3 rows [monday] close connection 3 [tuesday] open connection [tuesday] import failed: SyntaxError [tuesday] close connection 0
Catching only what you expect
A catch block catches everything, including
bugs you did not foresee. Handle the errors you expect and
rethrow the rest, so they are not silently hidden:
function parseReading(text) {
try {
const data = JSON.parse(text);
return data.kwh.toFixed(1);
} catch (error) {
if (error instanceof SyntaxError) {
return "invalid JSON";
}
throw error; // not ours to handle: pass it on
}
}
console.log(parseReading('{"kwh": 415.8}'));
console.log(parseReading("kwh=415.8"));
console.log(parseReading('{"meter": "MTR-0042"}')); // no kwh property
415.8 invalid JSON TypeError: Cannot read properties of undefined (reading 'toFixed')
Throwing your own errors
The
throw statement
signals an error yourself. It stops the current function
immediately, and the error travels up the call stack until a
catch block handles it — or, if none does, stops the
program. Throw when a function receives input it cannot work with,
rather than returning a strange value the caller may not notice.
function invoiceAmount(kwh, pricePerKwh) {
if (typeof kwh !== "number" || Number.isNaN(kwh)) {
throw new TypeError(`kwh must be a number, got ${typeof kwh}`);
}
if (kwh < 0) {
throw new RangeError(`kwh cannot be negative: ${kwh}`);
}
return Math.round(kwh * pricePerKwh * 100) / 100;
}
for (const kwh of [350, -12, "350"]) {
try {
console.log(`€ ${invoiceAmount(kwh, 0.25)}`);
} catch (error) {
console.log(`${error.name}: ${error.message}`);
}
}
€ 87.5 RangeError: kwh cannot be negative: -12 TypeError: kwh must be a number, got string
Custom error classes
For errors your program wants to recognize, make a subclass of
Error with extends (see
Classes, Prototypes and this). Set name so messages and logs show the right type,
and add properties for details the caller may need.
class MeterOfflineError extends Error {
constructor(meterId) {
super(`Meter ${meterId} did not respond`);
this.name = "MeterOfflineError";
this.meterId = meterId;
}
}
function readMeter(meterId) {
if (meterId === "MTR-0099") throw new MeterOfflineError(meterId);
return 415.8;
}
for (const id of ["MTR-0042", "MTR-0099"]) {
try {
console.log(id, readMeter(id));
} catch (error) {
if (!(error instanceof MeterOfflineError)) throw error;
console.log(`${error.name}: ${error.message} — ticket for ${error.meterId}`);
}
}
MTR-0042 415.8 MeterOfflineError: Meter MTR-0099 did not respond — ticket for MTR-0099
Wrapping errors with a cause
Low-level errors often make poor messages for the level above:
"Expected property name or '}' in JSON at position 1" says little to
someone importing a tariff file. Since ES2022 you can wrap them:
create a new, clearer error and pass the original as its
cause, with
new Error(message, { cause }). The original stays
available as error.cause, so no detail is lost, and
Node.js prints both in its report.
function loadTariff(text) {
try {
return JSON.parse(text);
} catch (error) {
throw new Error("Tariff file is not valid JSON", { cause: error });
}
}
try {
loadTariff("{price: 0.25}");
} catch (error) {
console.log(error.message);
console.log(` caused by ${error.cause.name}`);
}
Tariff file is not valid JSON caused by SyntaxError
Errors in asynchronous code
A try block only catches errors thrown while it is
running. A callback passed to setTimeout, an event
listener or a network request runs later, from the
event loop,
long after the try statement has finished:
try {
setTimeout(() => {
throw new Error("Meter MTR-0099 did not respond");
}, 10);
} catch (error) {
console.log("caught:", error.message); // never runs
}
console.log("try statement finished");
try statement finished Error: Meter MTR-0099 did not respond
Put the try inside the callback instead. With promises,
which modern asynchronous code uses (see
Asynchronous JavaScript),
a failure rejects the promise. Inside an
async function, await turns a rejection
back into a thrown error, so a normal try/catch
works again. Without async/await, attach a
handler with .catch().
function readMeter(meterId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (meterId === "MTR-0042") resolve(415.8);
else reject(new Error(`Meter ${meterId} did not respond`));
}, 10);
});
}
async function report(meterId) {
try {
const kwh = await readMeter(meterId); // a rejection is thrown here
console.log(`${meterId}: ${kwh} kWh`);
} catch (error) {
console.log(`${meterId}: ${error.message}`);
}
}
report("MTR-0042")
.then(() => report("MTR-0099"))
.then(() => readMeter("MTR-0100"))
.catch((error) => console.log(`.catch: ${error.message}`));
MTR-0042: 415.8 kWh MTR-0099: Meter MTR-0099 did not respond .catch: Meter MTR-0100 did not respond
A rejected promise that nobody handles is an
unhandled rejection. Since Node.js 15 it ends the program
with an error, just like an uncaught exception; browsers log it in
the console. Every promise chain should end in a catch,
either a .catch() or a try around the
await.
Good practices
-
Prevent what you can predict. Check input before
using it (
?.,??,Array.isArray(),Number.isFinite()); usetryfor what you cannot check in advance, such as parsing outside data or network calls. - Catch where you can act. A low-level function usually throws; the code near the user or the main loop decides what to do.
- Write messages for the reader. Include the value and the context: "Reading 400 for MTR-0042 is lower than 415.8" beats "invalid input".
- Show users a simple message, log the details. Stack traces and internal error text belong in the logs, not on a web page, where they help attackers more than users (see Web Application Security).