Sign in

Libre University uses your GitHub account. Signing in is only needed to sit a final test, so the score is kept on your profile.

Errors and exceptions

Programs go wrong in three distinct ways, and confusing them is why beginners spend hours looking in the wrong place.

The previous lesson ended on a bug that changed a shared configuration object and made a distant part of the program misbehave. That is the hardest grade. This lesson is about the machinery for the two easier ones, and about writing code so that the hard grade happens less often. If you have arrived here directly, what is assumed is functions, objects and arrays.

Three grades of wrong

A syntax error means the text is not a program. The machine cannot even begin, and it tells you before a single line runs. This is the best kind of error there is: it arrives immediately, it points at a location, and it cannot reach a user.

A runtime error, or exception, means the program was valid and something went wrong while it ran: a property read from undefined, a file that was not there, malformed input. Execution stops at that point and, unless something catches it, the program dies with a message and a stack trace. This is the second best kind. It is loud, it is located, and it happens near the mistake more often than not.

A logic error means the program ran to completion and gave the wrong answer. Nothing is reported. The grade chain in an earlier lesson that tested >= 50 before >= 70 is one: every result is a plausible grade, and the only way to find out is to know what the answer should have been. These cost more than the other two combined, and the whole design advice at the end of this lesson is aimed at converting them into the second kind.

The rest of this course is about the third grade. This lesson is about making the machine as loud as possible so that fewer bugs get to be that kind.

Syntax errors, and what a message really points at

if (x > 3 {
  console.log("big");
}

The parser reads if (, then an expression, then expects ) and finds {, so it reports a SyntaxError at that point. The thing to internalise is that a syntax error's reported position is where the parser gave up, not necessarily where the mistake is. A missing closing brace at line 20 is often reported at line 60, or at the end of the file, because everything in between remained parseable. When the reported line looks blameless, the mistake is above it, and the fastest way to find it is consistent indentation plus an editor that highlights matching brackets.

The commonest sources are unbalanced brackets or quotes, a missing comma in an object literal, and using a reserved word as a name. All are prevented rather than cured, which is why a linter is worth setting up on your first day and why every professional codebase runs one.

Runtime errors, and the four you will meet

When a runtime error is thrown, an Error object is created. It carries a name, a message and a stack. Four built-in kinds cover almost everything a beginner hits:

A ReferenceError means a name does not exist: nope() gives ReferenceError: nope is not defined. Almost always a typo or a missing import.

A TypeError means a value is not the kind of thing the operation needs, and it is the one you will see most. Reading a property of null or undefined gives it, and so does calling something that is not a function. The message names the operation: null is not an object (evaluating 'null.x'). The exact wording varies between engines, and the useful half is always the expression it quotes.

A RangeError means a value is outside an allowed range. a.length = -1 gives RangeError: Invalid array length, and a recursion that never reaches its base case gives RangeError: Maximum call stack size exceeded, which is the error that says your base case is wrong.

A SyntaxError at runtime comes from parsing text at runtime, and in practice that means JSON.parse. Feed it a truncated file and it throws rather than returning null, which is correct behaviour and means every JSON.parse on data you did not write needs handling.

Reading a stack trace

A stack trace is the list of function calls that were in progress when the error was thrown, innermost first. Take this file, report.js:

function meanOf(values) {
  return total(values) / values.length;
}
function total(values) {
  let sum = 0;
  for (const v of values) sum = sum + v.score;
  return sum;
}
function report(rows) {
  console.log("mean:", meanOf(rows));
}
report([{ score: 3 }, null]);

Running it produces:

TypeError: null is not an object (evaluating 'v.score')
      at total (report.js:6:39)
      at meanOf (report.js:2:10)
      at report (report.js:10:24)
      at report.js:12:1

Read it as a route. The bottom line is where the program started, line 12. Each line above is a call made by the line below it, and the top line is where it actually stopped: line 6, inside total.

Now the crucial distinction, and it is the reason this section exists. The trace says where the program stopped, not where it went wrong. Nothing is wrong with line 6: it does exactly what it should, given a null in the array. The mistake is at line 12, where a bad row was put into the data in the first place, four frames down. The trace's value is that it hands you the chain to walk: start at the top, decide whether that line is wrong or merely the victim, and if it is a victim ask which caller supplied the bad value and move down one frame.

Three practical notes. The numbers after the file are line and column. Frames from inside libraries can usually be skipped, since the bug is far more likely to be in the argument you passed than in a package thousands of people use. And an error inside a callback often has a short and uninformative trace, because the callback was invoked by the language rather than by your code, which is one reason to keep callbacks small.

Example. A program dies with this trace. Where do you look first, and what is the likely cause?

TypeError: undefined is not a function (evaluating 'row.name.trim()')
      at clean (users.js:14:22)
      at buildIndex (users.js:31:18)
      at users.js:48:1

Line 14 stopped it, and the quoted expression tells you row.name exists but is not a string, or is absent so that .trim is undefined. Line 14 is probably not the bug: clean is entitled to expect a row with a name. Go down a frame to line 31 and ask where buildIndex got its rows, and then to line 48 for the source of the data. The likely cause is a record missing a name field, and the fix is either to validate the input or to give clean an explicit guard.

Now you. A trace reads RangeError: Maximum call stack size exceeded with hundreds of identical frames for a function called walk. What kind of mistake is this, and where do you look?

Answer

The recursion never reaches its base case. Look at walk itself rather than at its caller: either the base case is missing or its condition is never satisfied, or the recursive call is passing a value that does not get smaller, so the descent never ends. A structure containing a cycle, where a node reaches itself, produces the same symptom with a perfectly correct base case, which is worth checking second.

Throwing on purpose

You can raise an error yourself, and this is how a function refuses input it cannot honour:

function withdraw(balance, amount) {
  if (amount <= 0) throw new Error("amount must be positive");
  if (amount > balance) throw new Error("insufficient funds");
  return balance - amount;
}

throw stops the function immediately and unwinds outward through its callers until something catches it. Always throw an Error object rather than a bare string: throw "oops" is legal and gives you a value with no stack, which throws away the most useful part.

Where a caller needs to tell one failure from another, define your own kind:

class ValidationError extends Error {
  constructor(message) {
    super(message);
    this.name = "ValidationError";
  }
}

That is the only piece of class syntax this course uses, and it earns its place: a caller can then write if (err instanceof ValidationError) and handle bad input differently from a network failure, which is a distinction that matters constantly in real programs.

Throwing is not the only way to report failure. The earlier lessons returned null for an impossible input, and both are defensible. The rough division that works: return a value for outcomes the caller should expect and will routinely handle, such as "no user with that name"; throw for conditions that mean somebody made a mistake, such as a negative price. The failure mode of null is that it is easy to ignore, and ignoring it produces a TypeError three functions later instead of a clear message here.

Catching, and the shape of a handler

try runs a block and catch receives the error if one was thrown:

function loadConfig(text) {
  try {
    return JSON.parse(text);
  } catch (err) {
    console.warn("config unreadable, using defaults:", err.message);
    return { retries: 3 };
  }
}

That is a good handler, and what makes it good is that it does something. It has a plan for the failure, it says out loud that the plan was used, and it returns a value the rest of the program can work with. A handler that cannot do any of those things should not exist.

finally runs whichever way the block exits, on success, on error, and even on a return inside the try, which makes it the place to release a resource such as a file handle or a lock. It runs before the return value reaches the caller, so the ordering is safe.

Catch narrowly. A try wrapped around fifty lines will catch errors from all fifty, including typos in code that has nothing to do with the failure you were guarding against, and will report them all as the same thing. Wrap the one call that can fail.

The rule that matters most is the one about the empty handler:

try {
  save(user);
} catch (err) {
  // ignore
}

This is worse than not catching at all, and it is worth being precise about why. A crash is information: it names the failure, points at a line, and stops before the damage spreads. Swallowing the error destroys all of that and lets the program continue in a state it was not designed for, so the failure shows up later as missing data with no trace and no message. The rule to carry: catch an error only if you can do something about it. If you cannot, let it travel to somebody who can, which at the top of a program means logging it with its stack and stopping.

Example. What is wrong with each of these, and what should they be?

try {
  const data = JSON.parse(text);
  const user = data.users[0];
  sendEmail(user.email);
} catch (err) {
  return null;
}

Two things. The try covers three separate operations, so a typo inside sendEmail is reported identically to malformed JSON, and the caller cannot tell which happened. And returning null discards the error entirely: whoever called this now has a null and no idea why. Wrap only the JSON.parse, handle the malformed case explicitly, and let anything else travel.

Now you. A function reads a file and parses it. What is wrong with catch (err) { return {}; }, and what would you write instead?

Answer

An empty object is indistinguishable from a file that legitimately contained one, so every caller silently proceeds with no data and no explanation, and the real cause, a missing file or a broken parse, is lost. Either handle the two cases separately and say which happened, or let the error travel: catch (err) { throw new Error("could not read " + path + ": " + err.message); } adds context and keeps the failure loud.

Failing loudly on purpose

The last idea in this lesson is the one that converts logic errors into runtime errors, which is the best trade available in programming.

Check what you assume, at the point where you assume it. A function that requires a non-empty array of numbers should say so in code, not in a comment:

function mean(values) {
  if (!Array.isArray(values)) throw new TypeError("mean: expected an array");
  if (values.length === 0) throw new RangeError("mean: empty array");
  return values.reduce((a, b) => a + b, 0) / values.length;
}

Compare the two futures. Without the checks, mean([]) returns NaN, which flows into a report, gets formatted, and appears on a screen as a blank or a dash that somebody notices next quarter. With them, the program stops at the exact call that supplied the empty array, with a message naming the function. The second costs three lines and a crash you can fix in a minute.

Validate at the edge, where data enters: the point where a file is read, a request arrives, a form is submitted. Inside your own code, after the edge, values can be trusted, and checking every argument in every function is noise that buries the checks worth reading. This is the same argument as converting types at the edge from the first lesson, and it is the same discipline.

Example. Add validation to this so a bad row fails at the point it enters rather than later, and say what it changes.

function addOrder(orders, row) {
  orders.push({ id: row.id, amount: Number(row.amount) });
}
function addOrder(orders, row) {
  if (typeof row.id !== "number") throw new TypeError("addOrder: id must be a number");
  const amount = Number(row.amount);
  if (Number.isNaN(amount)) throw new TypeError("addOrder: amount is not a number: " + row.amount);
  orders.push({ id: row.id, amount });
}

Without the checks, a row whose amount is "12,50" becomes NaN, and the total of every order in the system becomes NaN with no indication of which row was responsible. With them, the program stops on that row and prints the text that failed to convert, which is the entire diagnosis.

Now you. function discount(price, percent) { return price * (1 - percent / 100); } is called somewhere with percent as the string "20". What does it return, and what check would have caught it?

Answer

"20" / 100 coerces to the number 20, so the arithmetic happens to work and it returns the right answer, which is worse luck than an error would have been: the same function called with "20%" gives NaN and a silently wrong total. A check of if (typeof percent !== "number") throw new TypeError("discount: percent must be a number") catches both at the call that made the mistake, and forces the conversion to happen at the edge where the string arrived.

When nothing throws and the answer is still wrong

Errors and validation cover the two grades that announce themselves. The third does not, and no amount of throwing will find a grade table whose branches are in the wrong order, or an average that omits the last element, or the shared object of the previous lesson.

For those, the machine tells you nothing and you have to work it out. That work has a method, it is learnable, and it is not the thing most people do, which is to stare at the code and change lines hopefully. The next lesson is the method.