Errors are inevitable in real applications โ networks fail, users provide invalid input, APIs return unexpected data, and code has bugs. The difference between a professional application and a fragile one is how it handles errors. JavaScript provides try/catch/finally for synchronous error handling, the Error object hierarchy for structured errors, and patterns for propagating and handling errors in async code. In this lesson you will build a solid mental model for error handling that applies from simple scripts to production applications.
Error Handling Constructs
| Construct | Purpose | Always Runs? |
|---|---|---|
try { } |
Wrap code that might throw | Yes โ always starts here |
catch (err) { } |
Handle the error โ err is the thrown value | Only if try block throws |
finally { } |
Cleanup โ runs whether or not an error occurred | Always โ even after return |
throw expression |
Manually throw any value as an error | Immediately exits current block |
Built-in Error Types
| Error Type | When It Occurs | Example |
|---|---|---|
Error |
Base class โ general errors | new Error('Something went wrong') |
TypeError |
Wrong type โ calling non-function, accessing null property | null.name |
ReferenceError |
Variable not defined | console.log(undeclaredVar) |
SyntaxError |
Invalid JavaScript syntax | JSON.parse('bad json') |
RangeError |
Value out of allowed range | new Array(-1) |
Custom Error |
Application-specific errors | class ValidationError extends Error { } |
Error Object Properties
| Property | Type | Contains |
|---|---|---|
err.message |
string | Human-readable error description |
err.name |
string | Error type: ‘TypeError’, ‘RangeError’, etc. |
err.stack |
string | Stack trace โ file, line, column of each call |
err instanceof TypeError |
boolean | Check specific error type in catch block |
finally runs even if the try block contains a return statement. This makes it the right place for cleanup that must always happen โ closing database connections, releasing locks, hiding loading spinners. However, if finally itself contains a return, it overrides the try block’s return value, which is almost never what you want.Error objects, never raw strings: throw new Error('message') not throw 'message'. Error objects have a .stack property with a full stack trace, which is invaluable for debugging. A thrown string gives you no context about where or why the error occurred. For domain-specific errors, extend Error with custom classes.catch (err) { } โ they silently swallow errors and make bugs invisible. At minimum, console.error(err) inside every catch so you know something failed. In production, send errors to a logging service. Swallowed errors are one of the hardest categories of bugs to diagnose.Basic Example
// โโ Basic try/catch/finally โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function divideNumbers(a, b) {
try {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new TypeError('Both arguments must be numbers');
}
if (b === 0) {
throw new RangeError('Cannot divide by zero');
}
return a / b;
} catch (err) {
console.error(`[${err.name}] ${err.message}`);
return null;
} finally {
console.log('divideNumbers() completed'); // always runs
}
}
console.log(divideNumbers(10, 2)); // 5 + 'completed'
console.log(divideNumbers(10, 0)); // null + '[RangeError]...' + 'completed'
console.log(divideNumbers('a', 2)); // null + '[TypeError]...' + 'completed'
// โโ Custom Error classes โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
class ValidationError extends Error {
constructor(field, message) {
super(message);
this.name = 'ValidationError';
this.field = field;
this.timestamp = new Date().toISOString();
}
}
class NetworkError extends Error {
constructor(message, statusCode) {
super(message);
this.name = 'NetworkError';
this.statusCode = statusCode;
}
}
// โโ instanceof to handle different error types โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function processUserInput(data) {
try {
if (!data.email?.includes('@')) {
throw new ValidationError('email', 'Must be a valid email address');
}
if (!data.age || data.age < 13) {
throw new ValidationError('age', 'Must be 13 or older');
}
// Simulate network call
if (data.forceError) {
throw new NetworkError('Service unavailable', 503);
}
return { success: true, message: 'User processed successfully' };
} catch (err) {
if (err instanceof ValidationError) {
// Validation errors are user's fault โ show friendly message
return { success: false, field: err.field, message: err.message };
}
if (err instanceof NetworkError) {
// Network errors are transient โ suggest retry
return { success: false, message: `Server error ${err.statusCode} โ please try again` };
}
// Unknown error โ re-throw for outer error handler
throw err;
}
}
console.log(processUserInput({ email: 'alice@example.com', age: 25 }));
// { success: true, message: 'User processed successfully' }
console.log(processUserInput({ email: 'notanemail', age: 25 }));
// { success: false, field: 'email', message: 'Must be a valid email address' }
console.log(processUserInput({ email: 'b@b.com', age: 25, forceError: true }));
// { success: false, message: 'Server error 503 โ please try again' }
// โโ JSON.parse error handling โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function safeJsonParse(str, fallback = null) {
try {
return JSON.parse(str);
} catch {
return fallback; // silently return fallback for invalid JSON
}
}
console.log(safeJsonParse('{"name":"Bob"}')); // { name: 'Bob' }
console.log(safeJsonParse('invalid json')); // null
console.log(safeJsonParse('', [])); // []
How It Works
Step 1 โ throw Exits the Current Block Immediately
When throw executes, JavaScript stops running the current function and unwinds the call stack โ moving up through calling functions โ until it finds a catch block. If no catch is found, the error becomes an unhandled exception. Anything after the throw in the same block is never reached.
Step 2 โ catch Receives the Thrown Value
Whatever value you pass to throw โ an Error object, a string, a number โ becomes the err parameter in catch (err). Because any value can be thrown, always check err instanceof Error before accessing err.message if you cannot guarantee what was thrown.
Step 3 โ Custom Errors Enable instanceof Checks
By extending Error, custom error classes get the full Error API (message, stack) plus your own properties (field, statusCode). The instanceof check in catch lets you handle each error type differently โ show a form validation message for ValidationError, offer a retry for NetworkError, and re-throw anything unexpected.
Step 4 โ Re-throwing Unknown Errors
The pattern if (err instanceof KnownError) { handle } else { throw err; } is a best practice. Your catch block should only handle errors it knows about. Rethrowing unexpected errors lets an outer error handler or a global error listener deal with them โ rather than silently swallowing bugs you did not anticipate.
Step 5 โ finally for Guaranteed Cleanup
Database connections, file handles, loading spinners, and network request cancellation tokens all need to be cleaned up whether the operation succeeded or failed. Putting cleanup in finally guarantees it runs โ even if catch re-throws the error or try contains a return.
Real-World Example: API Client with Error Handling
// api-client.js
class ApiError extends Error {
constructor(message, status, body) {
super(message);
this.name = 'ApiError';
this.status = status;
this.body = body;
}
}
async function apiFetch(url, options = {}) {
let response;
try {
response = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new ApiError(
body.message ?? `Request failed with status ${response.status}`,
response.status,
body
);
}
return body;
} catch (err) {
if (err instanceof ApiError) {
// Known HTTP error โ handle by status
if (err.status === 401) {
redirectToLogin();
return null;
}
if (err.status === 429) {
console.warn('Rate limited โ retry after delay');
}
throw err; // re-throw for caller to handle
}
// Network error (fetch itself failed โ no internet, DNS failure)
throw new NetworkError(`Network request failed: ${err.message}`, 0);
} finally {
// Could hide a loading spinner here
// hideLoader();
}
}
// Usage with async/await error handling
async function loadUserProfile(userId) {
try {
const user = await apiFetch(`/api/users/${userId}`);
return user;
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
return null; // user not found is not an error for the caller
}
throw err; // everything else propagates up
}
}
Common Mistakes
Mistake 1 โ Empty catch block silently swallows errors
โ Wrong โ errors disappear with no trace:
try {
riskyOperation();
} catch (err) {
// nothing here โ error is gone forever
}
โ Correct โ always at minimum log the error:
try {
riskyOperation();
} catch (err) {
console.error('riskyOperation failed:', err);
// handle or rethrow
}
Mistake 2 โ Throwing a string instead of an Error object
โ Wrong โ string has no stack trace, no name, no useful debug info:
throw 'Something went wrong';
// catch (err) โ err is just a string, no err.stack available
โ Correct โ throw an Error object for full debug information:
throw new Error('Something went wrong');
// catch (err) โ err.message, err.stack, err.name all available
Mistake 3 โ Wrapping entire function in try/catch unnecessarily
โ Wrong โ catches errors from unrelated code too broadly:
try {
validateInput(data); // synchronous โ can't throw async errors anyway
processData(data);
saveToDatabase(data); // only THIS needs try/catch
updateUI(data);
} catch (err) { }
โ Correct โ wrap only the operation that can fail:
validateInput(data);
processData(data);
try {
await saveToDatabase(data);
} catch (err) {
console.error('Database save failed:', err);
throw err;
}
updateUI(data);
Quick Reference
| Construct | Example | Notes |
|---|---|---|
try/catch |
try { } catch (err) { } |
Wrap only the risky operation |
finally |
} finally { cleanup(); } |
Always runs โ use for cleanup |
throw |
throw new Error('msg') |
Always throw Error objects |
| Custom error | class AppError extends Error { } |
Enables instanceof checks in catch |
| instanceof check | if (err instanceof TypeError) |
Handle different errors differently |
| Re-throw | catch (err) { if (!known) throw err; } |
Only handle what you understand |
| Safe parse | try { JSON.parse(s) } catch { return null } |
Fallback for expected parse failures |