Errors and debugging

Throw errors that say something, catch only what you can handle, and read a stack.

27of 32
≈ 11Min read
9Parts

Throw an Error, never a string

err.message, err.name and err.stack all come from the Error object. A thrown string gives you one of those and no way to tell where it came from.

because only an Error carries a stack
throw new Error("Lead time must be positive");

throw "something went wrong";     // no stack, no name, no cause — useless

The built-in types

TypeError: Cannot read properties of null (reading "addEventListener") is the most common error on the web, and it always means the same thing: a querySelector returned null.

TypeWhen
TypeErrorA value is the wrong type — null.foo, calling a non-function.
RangeErrorA number is outside the allowed range.
SyntaxErrorInvalid code or JSON.
ReferenceErrorA name that does not exist.
DOMExceptionA browser API refused — AbortError, SecurityError, QuotaExceededError.

Your own error types

The last line is the important one. Catching an error you do not recognise and swallowing it turns a crash you could have fixed into a page that quietly does nothing.

so the caller can tell them apart
class HttpError extends Error {
  constructor(status, body) {
    super(`HTTP ${status}`);
    this.name = "HttpError";     // shows at the front of the stack
    this.status = status;
    this.body = body;
  }
}

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

try {
  await save();
} catch (err) {
  if (err instanceof ValidationError) return showFieldError(err.field, err.message);
  if (err instanceof HttpError && err.status === 401) return signIn();
  throw err;                     // anything unexpected keeps going up
}

cause

Wrapping an error to add context is right; losing the original is not. cause keeps the chain, and devtools shows it.

keep the original
try {
  await loadDoors();
} catch (err) {
  throw new Error("Could not load the catalogue", { cause: err });
}

// later
console.error(err.message, err.cause);

try, catch, finally

and the one guarantee finally gives you
function read() {
  const handle = open();
  try {
    return parse(handle);        // even a return runs finally first
  } catch (err) {
    return null;
  } finally {
    handle.close();              // always, however we leave
  }
}

try { risky(); } catch { fallback(); }    // no binding needed
A return in finally overrides everything

finally { return 1; } discards the value and any error being thrown. Use finally for cleanup only.

What not to catch

Distinguish operational errors — the network failed, the file is missing, the input is wrong — from programmer errors, which are bugs. Handle the first; let the second throw loudly.

three anti-patterns
// 1. swallowing
try { save(); } catch {}            // now nobody will ever know

// 2. catching everything at the top and logging "Error"
catch (err) { console.log("Error"); }

// 3. catching a programmer error
try { obj.method(); } catch { }     // this bug should CRASH so you fix it

Errors that nobody catches

These are for reporting, not recovery — by the time they fire, whatever was going to break has broken. But a production site without them has no idea what its readers are seeing.

the two global hooks
addEventListener("error", (e) => {
  report({ message: e.message, source: e.filename, line: e.lineno, error: e.error });
});

addEventListener("unhandledrejection", (e) => {
  report({ message: "Unhandled promise", reason: e.reason });
  e.preventDefault();        // stop the console noise if you have handled it
});

Reading a stack trace

  • The top line is the failure; everything below is how you got there.
  • Click the file:line to jump to the source. With source maps you land in your original file, not the bundle.
  • console.error(err) prints the stack; console.log(err.message) throws it away.
  • In devtools, "Pause on exceptions" stops at the throw with everything still in scope.
top to bottom is innermost to outermost
TypeError: Cannot read properties of null (reading 'value')
    at readForm (form.js:12:22)      ← where it threw
    at onSubmit (form.js:31:16)      ← who called that
    at HTMLFormElement.<anonymous>   ← and who called that

Debugging without console.log

faster, once you are used to it
debugger;                       // a breakpoint in the code

// in the console, with an element selected in the Elements panel
$0.parentElement
$$(".card").length

console.table(doors);
console.trace();                // how did I get here?
console.count("render");        // how many times?
console.time("t"); console.timeEnd("t");

Practice