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:

  1. The line with the error type and message: TypeError: Cannot read properties of undefined (reading 'reduce'). Something was undefined, and the code tried to read .reduce from it.
  2. The first at line: where it happened — function averageUsage, file billing.js, line 2, column 19. The source line with the ^ marker at the top shows the same spot.
  3. The lines below: how the program got there. Line 6 of the main script called averageUsage. Lines starting with node:internal are 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()); use try for 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).