Why Error Handling Matters
Every program encounters errors. Networks fail, users provide unexpected input, APIs return malformed data. Without proper error handling, these situations crash your application and leave users staring at a blank screen.
Good error handling means:
- Your app keeps running when something goes wrong
- Users see helpful messages instead of cryptic stack traces
- Developers get detailed logs to diagnose and fix issues
- Data stays consistent — partial operations get rolled back
// Without error handling — app crashes
const data = JSON.parse('invalid json'); // SyntaxError: Unexpected token i
console.log('This never runs');
// With error handling — app continues
try {
const data = JSON.parse('invalid json');
} catch (error) {
console.log('Invalid JSON, using defaults');
}
console.log('This runs fine');
JavaScript Error Types
JavaScript has several built-in error types, each representing a different kind of problem.
Error Type When It Occurs
───────────────────────────────────────────────────────────────
Error Base error type (generic)
SyntaxError Code cannot be parsed
ReferenceError Variable does not exist
TypeError Value used in wrong way (wrong type)
RangeError Value outside allowed range
URIError Invalid URI function usage
EvalError Error in eval() (rarely seen)
AggregateError Multiple errors wrapped together (ES2021)
SyntaxError
Thrown when the JavaScript engine cannot parse your code.
// SyntaxError — happens at parse time, cannot be caught in same scope
// eval('const x = ;'); // SyntaxError: Unexpected token ';'
// But you CAN catch SyntaxError from JSON.parse or eval
try {
JSON.parse('{ invalid }');
} catch (error) {
console.log(error instanceof SyntaxError); // true
console.log(error.message); // Expected property name or '}' ...
}
ReferenceError
Thrown when accessing a variable that does not exist.
try {
console.log(undeclaredVariable);
} catch (error) {
console.log(error instanceof ReferenceError); // true
console.log(error.message); // undeclaredVariable is not defined
}
TypeError
The most common error type. Thrown when a value is not the type you expected.
try {
null.toString();
} catch (error) {
console.log(error instanceof TypeError); // true
console.log(error.message); // Cannot read properties of null (reading 'toString')
}
try {
const num = 42;
num();
} catch (error) {
console.log(error.message); // num is not a function
}
try {
const obj = Object.freeze({ x: 1 });
obj.x = 2;
} catch (error) {
// Only in strict mode
console.log(error.message); // Cannot assign to read only property 'x'
}
RangeError
Thrown when a value is outside the allowed range.
try {
new Array(-1);
} catch (error) {
console.log(error instanceof RangeError); // true
console.log(error.message); // Invalid array length
}
try {
(1).toFixed(200);
} catch (error) {
console.log(error.message); // toFixed() digits argument must be between 0 and 100
}
try {
function recurse() { recurse(); }
recurse();
} catch (error) {
console.log(error instanceof RangeError); // true
console.log(error.message); // Maximum call stack size exceeded
}
Try-Catch-Finally
The try...catch...finally statement handles errors gracefully.
Basic Structure
try {
// Code that might throw
const result = riskyOperation();
console.log(result);
} catch (error) {
// Runs ONLY if try block throws
console.error('Something went wrong:', error.message);
} finally {
// Runs ALWAYS — whether try succeeded or catch ran
console.log('Cleanup complete');
}
Execution Flow
try block succeeds:
try {
doSomething(); ──> runs
} catch (error) {
handleError(); ──> SKIPPED
} finally {
cleanup(); ──> runs
}
try block throws:
try {
doSomething(); ──> throws Error
moreCode(); ──> SKIPPED (everything after throw)
} catch (error) {
handleError(); ──> runs (receives the error)
} finally {
cleanup(); ──> runs
}
Try-Catch Without Finally
try {
const data = JSON.parse(userInput);
processData(data);
} catch (error) {
showUserMessage('Invalid data format');
}
Try-Finally Without Catch
Useful when you need cleanup but want the error to propagate.
function readFile(path) {
const file = openFile(path);
try {
return processFile(file);
} finally {
closeFile(file); // always close, even if processFile throws
}
// If processFile throws, the error propagates AFTER finally runs
}
Catch Binding Is Optional (ES2019)
If you do not need the error object, you can omit it.
// Before ES2019 — had to declare the parameter
try {
JSON.parse(input);
} catch (error) {
return fallbackValue;
}
// ES2019+ — catch binding optional
try {
JSON.parse(input);
} catch {
return fallbackValue;
}
The throw Statement
Use throw to create your own errors. You can throw any value, but throwing Error objects (or subclasses) is best practice because they include stack traces.
// Throw an Error object (best practice)
throw new Error('Something went wrong');
// Throw a TypeError
throw new TypeError('Expected a string, got number');
// You CAN throw anything (but don't)
throw 'error string'; // works but no stack trace
throw 42; // works but terrible practice
throw { code: 'ERR_404' }; // works but no stack trace
Validating Input with throw
function divide(a, b) {
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;
}
try {
console.log(divide(10, 2)); // 5
console.log(divide(10, 0)); // throws RangeError
} catch (error) {
if (error instanceof RangeError) {
console.log('Math error:', error.message);
} else if (error instanceof TypeError) {
console.log('Type error:', error.message);
} else {
throw error; // re-throw unknown errors
}
}
Re-throwing Errors
Catch only the errors you know how to handle. Re-throw everything else.
function processConfig(json) {
try {
const config = JSON.parse(json);
validateConfig(config);
return config;
} catch (error) {
if (error instanceof SyntaxError) {
// We know how to handle parse errors
console.warn('Invalid JSON, using defaults');
return getDefaultConfig();
}
// Unknown error — let it propagate
throw error;
}
}
Error Properties
Every Error object has these properties:
Property Description Example
───────────────────────────────────────────────────────────────
message Human-readable description "Cannot read properties of null"
name Error type name "TypeError"
stack Stack trace (non-standard but "TypeError: ... at foo (file.js:10)"
supported everywhere)
cause The underlying error (ES2022) original error object
message and name
const error = new TypeError('Expected string');
console.log(error.name); // TypeError
console.log(error.message); // Expected string
console.log(error.toString()); // TypeError: Expected string
stack
The stack trace shows the call chain that led to the error. Format varies by engine, but generally includes file names and line numbers.
function a() { b(); }
function b() { c(); }
function c() { throw new Error('Deep error'); }
try {
a();
} catch (error) {
console.log(error.stack);
// Error: Deep error
// at c (script.js:3:24)
// at b (script.js:2:16)
// at a (script.js:1:16)
// at script.js:6:3
}
cause (ES2022)
Chain errors together to preserve the original cause while adding context.
async function fetchUserData(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
throw new Error(`Failed to fetch user ${userId}`, { cause: error });
}
}
try {
await fetchUserData(123);
} catch (error) {
console.log(error.message); // Failed to fetch user 123
console.log(error.cause); // Error: HTTP 404 (or network error)
console.log(error.cause.message); // HTTP 404
}
This creates an error chain that preserves full context:
Error: Failed to fetch user 123
└── cause: Error: HTTP 404
└── cause: TypeError: Failed to fetch (if network error)
Custom Error Classes
Create your own error types when built-in types are not specific enough.
class ValidationError extends Error {
constructor(field, message) {
super(message);
this.name = 'ValidationError';
this.field = field;
}
}
class NotFoundError extends Error {
constructor(resource, id) {
super(`${resource} with id ${id} not found`);
this.name = 'NotFoundError';
this.resource = resource;
this.id = id;
this.statusCode = 404;
}
}
class AuthenticationError extends Error {
constructor(message = 'Authentication required') {
super(message);
this.name = 'AuthenticationError';
this.statusCode = 401;
}
}
// Usage
function findUser(id) {
const user = database.get(id);
if (!user) {
throw new NotFoundError('User', id);
}
return user;
}
try {
const user = findUser(999);
} catch (error) {
if (error instanceof NotFoundError) {
console.log(`${error.resource} not found`); // User not found
console.log(error.statusCode); // 404
} else if (error instanceof ValidationError) {
console.log(`Invalid ${error.field}: ${error.message}`);
} else {
throw error;
}
}
Custom Error Hierarchy
For larger applications, create an error hierarchy.
// Base application error
class AppError extends Error {
constructor(message, statusCode = 500, cause = undefined) {
super(message, { cause });
this.name = this.constructor.name;
this.statusCode = statusCode;
this.isOperational = true; // distinguishes from programming errors
}
}
// Specific error types
class BadRequestError extends AppError {
constructor(message, cause) {
super(message, 400, cause);
}
}
class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized', cause) {
super(message, 401, cause);
}
}
class ForbiddenError extends AppError {
constructor(message = 'Forbidden', cause) {
super(message, 403, cause);
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource', cause) {
super(`${resource} not found`, 404, cause);
}
}
// Usage in an Express-like handler
function errorHandler(error, req, res) {
if (error instanceof AppError) {
// Operational error — send appropriate response
res.status(error.statusCode).json({
error: error.name,
message: error.message,
});
} else {
// Programming error — log and send generic response
console.error('Unexpected error:', error);
res.status(500).json({
error: 'InternalServerError',
message: 'Something went wrong',
});
}
}
Finally Block Behavior
The finally block has some surprising behaviors worth understanding.
Finally Always Runs
function test() {
try {
return 'from try';
} finally {
console.log('finally runs'); // This runs before the return
}
}
console.log(test());
// finally runs
// from try
Finally Can Override Return
function test() {
try {
return 'try';
} catch (error) {
return 'catch';
} finally {
return 'finally'; // WARNING: overrides previous return
}
}
console.log(test()); // 'finally' (not 'try')
Best practice: Never return from a finally block. It silently swallows errors and overrides return values.
Finally Runs Even After throw
function test() {
try {
throw new Error('oops');
} finally {
console.log('cleanup runs'); // runs before error propagates
}
}
try {
test();
} catch (error) {
console.log(error.message); // oops
}
// Output:
// cleanup runs
// oops
Finally for Resource Cleanup
The primary use case for finally is ensuring resources get cleaned up.
class DatabaseConnection {
constructor() {
this.connected = false;
}
connect() {
this.connected = true;
console.log('Connected');
}
disconnect() {
this.connected = false;
console.log('Disconnected');
}
query(sql) {
if (!this.connected) throw new Error('Not connected');
// simulate query
if (sql.includes('DROP')) throw new Error('Dangerous query');
return [{ id: 1 }];
}
}
function runQuery(sql) {
const db = new DatabaseConnection();
db.connect();
try {
const results = db.query(sql);
return results;
} catch (error) {
console.error('Query failed:', error.message);
return [];
} finally {
db.disconnect(); // ALWAYS disconnects, even if query throws
}
}
runQuery('SELECT * FROM users');
// Connected
// Disconnected
// returns [{ id: 1 }]
runQuery('DROP TABLE users');
// Connected
// Query failed: Dangerous query
// Disconnected
// returns []
Error Handling Patterns
Pattern 1: Fail Fast
Validate inputs early and throw immediately if something is wrong.
function createUser(name, email, age) {
// Validate everything upfront
if (!name || typeof name !== 'string') {
throw new TypeError('name must be a non-empty string');
}
if (!email || !email.includes('@')) {
throw new TypeError('email must be a valid email address');
}
if (typeof age !== 'number' || age < 0 || age > 150) {
throw new RangeError('age must be between 0 and 150');
}
// All validations passed — safe to proceed
return { name, email, age, createdAt: new Date() };
}
Pattern 2: Error-First Callbacks (Node.js Style)
function readConfig(path, callback) {
fs.readFile(path, 'utf8', (err, data) => {
if (err) {
callback(new Error(`Failed to read config: ${path}`, { cause: err }));
return;
}
try {
const config = JSON.parse(data);
callback(null, config);
} catch (parseError) {
callback(new Error('Invalid config format', { cause: parseError }));
}
});
}
// Usage
readConfig('config.json', (error, config) => {
if (error) {
console.error(error.message);
return;
}
startApp(config);
});
Pattern 3: Result Objects (No Exceptions)
Instead of throwing, return an object indicating success or failure.
function parseJSON(text) {
try {
const data = JSON.parse(text);
return { ok: true, data };
} catch (error) {
return { ok: false, error: error.message };
}
}
// Usage — no try/catch needed by the caller
const result = parseJSON('{"valid": true}');
if (result.ok) {
console.log(result.data); // { valid: true }
} else {
console.log(result.error);
}
const bad = parseJSON('not json');
if (!bad.ok) {
console.log(bad.error); // Unexpected token 'o' ...
}
Pattern 4: Wrapping Async Operations
async function safeFetch(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
}
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
throw new TypeError('Expected JSON response');
}
return await response.json();
} catch (error) {
if (error instanceof TypeError && error.message === 'Failed to fetch') {
throw new Error('Network error: check your connection', { cause: error });
}
throw error;
}
}
Error Logging Strategies
Structured Logging
class Logger {
static error(message, context = {}) {
const entry = {
level: 'error',
message,
timestamp: new Date().toISOString(),
...context,
};
// In development
console.error(JSON.stringify(entry, null, 2));
// In production, send to logging service
// sendToLoggingService(entry);
}
static warn(message, context = {}) {
console.warn(JSON.stringify({
level: 'warn',
message,
timestamp: new Date().toISOString(),
...context,
}));
}
}
// Usage
try {
processOrder(order);
} catch (error) {
Logger.error('Order processing failed', {
orderId: order.id,
userId: order.userId,
errorName: error.name,
errorMessage: error.message,
stack: error.stack,
});
}
Global Error Handlers
Catch unhandled errors at the application level.
// Browser — unhandled errors
window.addEventListener('error', (event) => {
Logger.error('Unhandled error', {
message: event.message,
filename: event.filename,
line: event.lineno,
column: event.colno,
});
});
// Browser — unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
Logger.error('Unhandled promise rejection', {
reason: event.reason?.message || String(event.reason),
stack: event.reason?.stack,
});
event.preventDefault(); // prevents default console error
});
// Node.js equivalents
process.on('uncaughtException', (error) => {
Logger.error('Uncaught exception', {
message: error.message,
stack: error.stack,
});
process.exit(1); // exit after logging — state may be corrupted
});
process.on('unhandledRejection', (reason) => {
Logger.error('Unhandled rejection', {
reason: reason?.message || String(reason),
});
});
Common Mistakes
Mistake 1: Catching and Swallowing Errors
// BAD — error is silently swallowed
try {
riskyOperation();
} catch (error) {
// empty catch block — bugs become invisible
}
// GOOD — at minimum, log the error
try {
riskyOperation();
} catch (error) {
console.error('Operation failed:', error);
}
Mistake 2: Catching Too Broadly
// BAD — catches everything including programming errors
try {
const result = someComplexOperation();
processResult(result);
saveToDatabase(result);
} catch (error) {
showUserMessage('Something went wrong');
}
// GOOD — narrow try blocks, specific handling
try {
const result = someComplexOperation();
processResult(result);
try {
saveToDatabase(result);
} catch (dbError) {
Logger.error('Database save failed', { error: dbError });
queueForRetry(result);
}
} catch (error) {
showUserMessage('Processing failed');
Logger.error('Complex operation failed', { error });
}
Mistake 3: Losing the Original Error
// BAD — original error details lost
try {
fetchData();
} catch (error) {
throw new Error('Data fetch failed'); // original error is gone
}
// GOOD — preserve the cause chain
try {
fetchData();
} catch (error) {
throw new Error('Data fetch failed', { cause: error });
}
Mistake 4: Using Exceptions for Flow Control
// BAD — exceptions are expensive, this is a normal condition
function findItem(arr, target) {
try {
arr.forEach(item => {
if (item === target) throw item; // abusing throw for flow control
});
return null;
} catch (found) {
return found;
}
}
// GOOD — use normal control flow
function findItem(arr, target) {
return arr.find(item => item === target) ?? null;
}
Error Handling Decision Guide
Situation Strategy
──────────────────────────────────────────────────────────────────
Input validation Fail fast with throw
Expected failure (network, parse) try-catch with specific handling
Resource cleanup try-finally
Unknown/unexpected error Re-throw, let it propagate
Multiple error types instanceof checks in catch
Async operations try-catch with await (or .catch)
Global safety net window.onerror, process.on
API responses Custom error classes with status codes
Library/package code Custom error types, detailed messages
Key Takeaways
- JavaScript has seven built-in error types —
TypeErroris the most common try-catch-finallyis the primary error handling mechanismfinallyalways runs — use it for cleanup, never for returning values- Always throw
Errorobjects (or subclasses), never strings or plain objects - Use
error.cause(ES2022) to chain errors and preserve context - Create custom error classes for domain-specific error handling
- Fail fast — validate inputs early and throw immediately
- Never swallow errors with empty catch blocks
- Keep try blocks small — catch only the errors you expect
- Set up global error handlers as a safety net, not as your primary strategy