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.
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.
| Type | When |
|---|---|
TypeError | A value is the wrong type — null.foo, calling a non-function. |
RangeError | A number is outside the allowed range. |
SyntaxError | Invalid code or JSON. |
ReferenceError | A name that does not exist. |
DOMException | A 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.
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.
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
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
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.
// 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.
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.
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
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");