Errors, validation, and defensive code · Lesson 43 of 77 · concept

Throwing, and the objects you throw

About 50 minutes.

By the end of this lesson you can

  • Throw an error on purpose, with a message that tells the reader what to change.
  • Choose between TypeError, RangeError and Error for a given failure.
  • Attach machine-readable detail to an error with a subclass, a field, or cause.
  • Explain what is lost when code throws a string instead of an Error.

You are allowed to throw

Explanation

Every error so far came from the engine. You can throw too, and you should: the engine only knows about the rules of JavaScript, whereas you know about the rules of your program. JavaScript has no opinion on whether a volume of 12 is acceptable. You do.

The statement is throw, followed by the value. In practice that value should almost always be a fresh error object: throw new Error("..."), or one of the more specific types.

A thrown error is not caught by the function that threw it. It leaves immediately and becomes the caller's problem, and then the caller's caller's, until somebody handles it. Deciding where that somebody should be is lesson three; deciding what they receive is this lesson.

throw is not a different flavour of return

A mental model

return hands a value back to the one place that called you, and that place carries on with the next line. throw hands a value back to whoever is prepared to catch it, and every line in between is abandoned.

That difference is why a throw cannot be ignored and a returned error code can. If a function returns the string "error" and the caller does not look at it, nothing happens and the program continues with a wrong value. If the same function throws, the caller either handles it or is stopped by it.

The cost of that guarantee is that throwing takes the decision away from the caller. That is right for a genuine mistake and wrong for an ordinary bad input, which is the subject of lesson five.

Write the message for the person reading it at 2am

Why it matters

Invalid input is not a message. It tells the reader that something is wrong, which they already knew, and nothing else.

A message worth writing has three ingredients: where the failure was noticed, what was expected, and what actually arrived. createUser: name must be a non-empty string, received number 5 has all three. Someone reading it in a log at 2am knows which function to open, what the rule is, and what broke it, without running anything.

Include the received value, but be deliberate about it. A value is worth showing when it is small and safe: a number, a short string, a type name. Never paste an entire request body, and never paste anything you would not want written down permanently — a log file is a permanent copy of whatever you put in it, in a place with different access rules from the original.

Choosing the type

Explanation

TypeError: the value is the wrong kind of thing for the operation. A string where a number was needed, null where an object was needed.

RangeError: the value is the right kind of thing and outside what is allowed. A volume of 12 on a dial that goes to 11, a page number of zero, a percentage of 150.

Error: everything else — a rule of your program that was broken, where neither kind nor range is really the issue. A subclass of Error, covered next, is usually better than plain Error for anything that happens more than once.

Two types are best left to the engine. SyntaxError describes text that is not JavaScript or not JSON, and ReferenceError describes a name that does not exist; throwing either from your own code sends a reader hunting for a problem of a kind that is not there.

When a name and a message are not enough

Explanation

Sometimes the handler needs to do more than print. A form wants to know which field was wrong so it can highlight it, and reading that back out of an English sentence is not a plan.

Extend Error with a class, set this.name in the constructor, and add whatever fields the handler needs. The result is still a real error: instanceof Error is true, the message and stack still work, and any handler that only wants to print it can carry on doing exactly that.

When one failure is caused by another, keep the original. The Error constructor takes a second argument, an options object with a cause property: new Error("loadConfig failed", { cause: original }). The handler then has both the sentence that makes sense at this level and the underlying failure it came from, instead of one at the expense of the other.

Throwing a string costs you everything

A common mistake

throw "something broke" is legal. JavaScript lets you throw any value at all, and beginners reach for a string because it is shorter.

What arrives at the catch block is then a bare string. It has no name, so nothing can branch on the kind of failure. It has no stack, so nothing can say where it came from. error instanceof Error is false, so every defensive handler downstream — including ones written by other people — has to cope with a value that is not an error object.

The rule is simple and has no useful exceptions: throw errors, not values. The corresponding rule for catch is that you must nonetheless assume somebody else broke this rule, which is why lesson three checks what it caught.

An error does not survive JSON.stringify

A common mistake

JSON.stringify(new Error("boom")) produces {} — an empty object. The information is not lost from the error, but none of it is copied out.

There are two reasons. message is an own property but a non-enumerable one, and JSON.stringify only copies enumerable own properties. name is not an own property at all: it lives on Error.prototype, shared by every error of that type, and JSON.stringify never looks up the prototype chain.

So anything that sends an error somewhere else — a log line, a network message, a stored record — has to build the shape it wants by hand: { name: error.name, message: error.message }. Discovering this in production, with a log full of empty braces, is a rite of passage worth skipping.

Worked examples

Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.

A message with all three ingredients

One guard clause, and a message naming where it fired, what was expected, and what arrived instead.

1function setAge(person, age) {2  if (!Number.isInteger(age)) {3    throw new TypeError(4      "setAge: age must be a whole number, received " + typeof age + " " + JSON.stringify(age),5    );6  }7  return { name: person, age: age };8}9 10try {11  setAge("Ada", "36");12} catch (error) {13  console.log(error.name);14  console.log(error.message);15}
  • line 2

    Number.isInteger does not convert its argument, so the string "36" fails it. One check covers strings, null, undefined, NaN and decimals.

  • line 4

    Three ingredients in one line: setAge is where, must be a whole number is what was expected, and the typeof plus the value is what arrived.

  • line 4

    JSON.stringify around the value is what puts quotes around "36", so the reader can see it was text rather than the number 36.

  • line 7

    The happy path is the last line of the function and is not indented inside anything. That is the payoff of guarding at the top.

What it prints

TypeErrorsetAge: age must be a whole number, received string "36"

A subclass that carries a field

When the handler needs a machine-readable detail — here, which field was wrong — put it on the error rather than in the sentence.

1class ValidationError extends Error {2  constructor(message, field) {3    super(message);4    this.name = "ValidationError";5    this.field = field;6  }7}8 9try {10  throw new ValidationError("must not be empty", "email");11} catch (error) {12  console.log(error.name);13  console.log(error.message);14  console.log(error.field);15  console.log(error instanceof ValidationError);16  console.log(error instanceof Error);17}
  • line 3

    super(message) hands the message to Error, which is what makes message, the stack and text conversion work as usual.

  • line 4

    Setting name explicitly is not optional. Without this line the name stays "Error", because name is inherited from Error.prototype rather than derived from the class.

  • line 5

    Any extra fields you want go on afterwards. A handler reads error.field instead of trying to parse it back out of the message.

  • line 15

    Both instanceof checks are true. A handler that only knows about Error still works, and one that knows about ValidationError can do more.

What it prints

ValidationErrormust not be emptyemailtruetrue

Adding context without discarding the original

Wrapping a low-level failure in a sentence that makes sense at this level, while keeping the failure itself attached.

1function loadConfig(text) {2  try {3    return JSON.parse(text);4  } catch (error) {5    throw new Error("loadConfig: settings.json is not valid JSON", { cause: error });6  }7}8 9try {10  loadConfig("{oops}");11} catch (error) {12  console.log(error.message);13  console.log(error.cause.name);14  console.log(error.cause instanceof SyntaxError);15}
  • line 5

    The new message says something the caller can act on. JSON.parse's own message describes a character position in a string the caller never saw.

  • line 5

    The second argument is an options object, and cause is its only standard property. Without it, the original failure would be gone for good.

  • line 13

    error.cause is the original error object, not a copy of its text, so its name, message and stack are all still there.

What it prints

loadConfig: settings.json is not valid JSONSyntaxErrortrue

An error turns into an empty object

The surprise that costs an afternoon the first time: serialising an error and getting nothing at all, plus the two-line fix.

1const failure = new RangeError("volume must be 0 to 11");2 3console.log(JSON.stringify(failure));4console.log(Object.keys(failure).length);5 6const sendable = { name: failure.name, message: failure.message };7console.log(JSON.stringify(sendable));
  • line 3

    Empty braces. Nothing was thrown and nothing warned you; the log line simply contains no information.

  • line 4

    Zero enumerable own properties. message is an own property but non-enumerable, and name lives on the prototype.

  • line 6

    The fix is to name what you want. Reading the properties works perfectly well — it is only the automatic copying that skips them.

What it prints

{}0{"name":"RangeError","message":"volume must be 0 to 11"}

Check yourself

Not scored, not stored. Getting one wrong is the useful part.

A function takes a percentage. The caller passes the number 150. Which error type fits best?

This code throws a string instead of an error and then inspects what the catch block received. Predict all three lines.

try {  throw "the disk is full";} catch (error) {  console.log(typeof error);  console.log(error instanceof Error);  console.log(error.name);}

What does JSON.stringify(new Error("boom")) produce?

Write it yourself

Exercise · core · 12 tests

Throw the right type, with the right message

An amplifier accepts a volume from 0 to 11 inclusive. Write setVolume(level) so that a caller who gets it wrong is told exactly what was wrong, in the right error type. - If level is not a finite number, throw a TypeError. NaN and Infinity are numbers as far as typeof is concerned, but they are not usable volumes, so they belong in this branch too. - If level is a finite number outside 0 to 11, throw a RangeError. - Otherwise return the string "Volume set to " followed by the level. Every message you throw must start with "setVolume: " and must contain the value that was actually received, so that whoever reads the log does not have to guess.

What your code must do

setVolume returns a string only for a finite number from 0 to 11 inclusive; both 0 and 11 are valid. Everything else throws — TypeError for a value that is not a usable number, including NaN, Infinity, strings, null and a missing argument, and RangeError for a real number outside the range. Both messages begin with "setVolume: " and include the received value.

Define setVolume at the top level of your code — the tests call it by name.

Runs on your device in a sandbox with no network and no access to this page.

Worth remembering

  • When do you throw a TypeError, and when a RangeError?

    TypeError when the value is the wrong kind of thing for the operation — a string where a number was needed. RangeError when it is the right kind and outside the values allowed — a volume of 12 on a dial that goes to 11.

  • What do you lose by writing throw "something broke" instead of throwing an Error?

    The name, the stack, and any caller's ability to test error instanceof Error. The catch block receives a bare string, so every handler downstream has to defend against a value that is not an error object.

  • Why does JSON.stringify of an error produce {}?

    message is an own property but not an enumerable one, and name is inherited from Error.prototype rather than owned by the instance. JSON.stringify copies only enumerable own properties, so build { name, message } yourself before sending an error anywhere.

Check this lesson against the source

We wrote the explanation; we did not invent the facts. This is the page that backs them.

MDN — throw

Check there: That throw accepts any expression as its operand, that execution of the current function stops at the throw, and that control passes to the first catch block in the call stack.

Prefer to read the source in a structured breakdown? Turn this doc into a breakdown

0 of 1 exercise in this lesson solved on this device

Consent version 2026-07-31.1

Cookie preferences