Error Handling
Error handling is a pivotal aspect of any program, as it enables you to address unexpected situations and ensures your program continues to run accurately. In this guide, you’ll learn about different error types in JavaScript, such as syntax errors and runtime errors, and how to manage them using try-catch statements and throw statements.
Proper error handling makes your applications more robust, easier to debug, and provides better user experiences by gracefully handling failures instead of crashing.
Basic Error Handling with try-catch
Just like in any other language, JavaScript uses try/catch/finally to manage errors. However, JavaScript has some unique characteristics you need to be aware of:
- Always check for
undefinedandNaN voidresults inundefinedundefined + 1yieldsNaN
let o = {}; // equivalent to new Object();
o.toString(); // works fine
// If uncommented, the following line would throw an error.
// o.tostring(); // TypeError: o.tostring is not a function
try {
o.tostring(); // This will throw an error
} catch (error) {
console.log(error.message); // "o.tostring is not a function"
} finally {
console.log("Always executes, irrespective of errors");
}
The try-catch-finally structure:
tryblock: Contains code that might throw an errorcatchblock: Handles the error if one occursfinallyblock: Always executes, whether an error occurred or not
When is finally useful?
function readFile(filename) {
let file = null;
try {
file = openFile(filename);
return processFile(file);
} catch (error) {
console.error("Error processing file:", error.message);
return null;
} finally {
// Cleanup always happens, even if we return in try or catch
if (file) {
closeFile(file);
}
}
}
The Error Object
Different implementations exist across JavaScript environments, but the standard Error object always has these properties:
message: A brief error messagename: The type of the error (e.g., “TypeError”, “ReferenceError”)stack: A stack trace showing where the error occurred
try {
o.tostring(); // Intentional error
} catch (error) {
console.log(error.message); // "o.tostring is not a function"
console.log(error.name); // "TypeError"
console.log(error.stack); // Full stack trace
}
Built-in Error Types
JavaScript has several built-in error types for different situations:
// ReferenceError - accessing undefined variable
try {
console.log(nonExistentVariable);
} catch (error) {
console.log(error.name); // "ReferenceError"
}
// TypeError - using wrong type
try {
null.toString();
} catch (error) {
console.log(error.name); // "TypeError"
}
// RangeError - number out of range
try {
let arr = new Array(-1);
} catch (error) {
console.log(error.name); // "RangeError"
}
// SyntaxError - invalid syntax
try {
eval("function test() { invalid syntax }");
} catch (error) {
console.log(error.name); // "SyntaxError"
}
// URIError - invalid URI functions usage
try {
decodeURIComponent('%');
} catch (error) {
console.log(error.name); // "URIError"
}
Using throw to Generate Errors
The throw statement is used to create custom errors. It’s a way to signal that an error has occurred while executing a function or a block of code. When a throw statement is encountered, the JavaScript runtime throws an exception. The execution of the current function will stop and control will be passed to the first catch block in the call stack. If no catch block exists among caller functions, the program will terminate.
Basic throw usage:
function add(a, b) {
if (arguments.length !== 2) {
throw new Error("Incorrect number of arguments");
}
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error("Arguments must be numbers");
}
return a + b;
}
try {
console.log(add(1, 2)); // 3 - works fine
console.log(add(1)); // Throws error
} catch (error) {
console.log(error.message); // "Incorrect number of arguments"
}
You can throw anything:
// Throwing an Error object (recommended)
throw new Error("Something went wrong");
// Throwing a string (not recommended)
throw "Error occurred";
// Throwing a number
throw 404;
// Throwing an object
throw { code: 404, message: "Not found" };
Always throw Error objects
While JavaScript allows you to throw any value, you should always throw instances of Error or its subclasses. This ensures:
- Stack traces are captured
- Error handling code can rely on consistent properties
- Better debugging experience
Custom Error Classes
For more structured error handling, create custom error classes:
// Custom error class
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = "ValidationError";
}
}
class DatabaseError extends Error {
constructor(message, query) {
super(message);
this.name = "DatabaseError";
this.query = query;
}
}
// Usage
function validateAge(age) {
if (typeof age !== 'number') {
throw new ValidationError("Age must be a number");
}
if (age < 0 || age > 150) {
throw new ValidationError("Age must be between 0 and 150");
}
return true;
}
function queryDatabase(sql) {
// Simulate database error
throw new DatabaseError("Connection failed", sql);
}
// Handling different error types
try {
validateAge("invalid");
} catch (error) {
if (error instanceof ValidationError) {
console.log("Validation failed:", error.message);
} else if (error instanceof DatabaseError) {
console.log("Database error:", error.message);
console.log("Query was:", error.query);
} else {
console.log("Unknown error:", error);
}
}
Real-world example - API request handler:
class ApiError extends Error {
constructor(message, statusCode, endpoint) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.endpoint = endpoint;
}
}
async function fetchUser(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new ApiError(
`Failed to fetch user ${userId}`,
response.status,
`/api/users/${userId}`
);
}
return await response.json();
} catch (error) {
if (error instanceof ApiError) {
console.error(`API Error [${error.statusCode}]:`, error.message);
console.error(`Endpoint:`, error.endpoint);
} else {
console.error("Network error:", error.message);
}
throw error; // Re-throw for caller to handle
}
}
Error Propagation
When an error occurs in a function and it’s not caught there, it will bubble up through the call stack to the first catch block it encounters.
function f1() {
f2();
}
function f2() {
f3();
}
function f3() {
throw new Error("Error occurred in f3");
}
try {
f1();
} catch (error) {
console.log(error.message); // "Error occurred in f3"
console.log(error.stack); // Shows full call stack: f3 -> f2 -> f1
}
Understanding the call stack:
function calculateTotal(items) {
return items.reduce((sum, item) => sum + getPrice(item), 0);
}
function getPrice(item) {
if (!item.price) {
throw new Error(`Missing price for item: ${item.name}`);
}
return item.price;
}
let cart = [
{ name: "Book", price: 20 },
{ name: "Pen" }, // Missing price!
{ name: "Notebook", price: 15 }
];
try {
let total = calculateTotal(cart);
} catch (error) {
console.error(error.message); // "Missing price for item: Pen"
// The error bubbled from getPrice -> reduce -> calculateTotal
}
Async Error Handling
Modern JavaScript uses async/await extensively, and error handling works slightly differently with asynchronous code.
Promises with .catch()
function fetchData(url) {
return fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.catch(error => {
console.error("Fetch error:", error.message);
throw error; // Re-throw if you want caller to handle it
});
}
// Usage
fetchData('https://api.example.com/data')
.then(data => console.log(data))
.catch(error => console.error("Failed:", error));
Async/Await with try-catch
async function fetchUserData(userId) {
try {
let response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
let user = await response.json();
return user;
} catch (error) {
console.error("Error fetching user:", error.message);
throw error;
}
}
// Usage
async function displayUser(userId) {
try {
let user = await fetchUserData(userId);
console.log("User:", user);
} catch (error) {
console.error("Could not display user:", error.message);
}
}
Multiple async operations:
async function loadDashboard(userId) {
try {
// Run in parallel
let [user, posts, notifications] = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
fetchNotifications(userId)
]);
return { user, posts, notifications };
} catch (error) {
// If ANY request fails, we catch it here
console.error("Dashboard loading failed:", error.message);
throw error;
}
}
// Handle individual failures differently
async function loadDashboardSafe(userId) {
let [userResult, postsResult, notificationsResult] = await Promise.allSettled([
fetchUser(userId),
fetchPosts(userId),
fetchNotifications(userId)
]);
return {
user: userResult.status === 'fulfilled' ? userResult.value : null,
posts: postsResult.status === 'fulfilled' ? postsResult.value : [],
notifications: notificationsResult.status === 'fulfilled' ? notificationsResult.value : []
};
}
Error Handling Best Practices
1. Don’t Silently Catch Errors
// ❌ Bad - swallowing errors
try {
riskyOperation();
} catch (error) {
// Empty catch - error is lost!
}
// ✅ Good - at least log the error
try {
riskyOperation();
} catch (error) {
console.error("Operation failed:", error);
// Maybe show user-friendly message
}
// ✅ Better - handle specific errors, re-throw others
try {
riskyOperation();
} catch (error) {
if (error instanceof ValidationError) {
showUserError(error.message);
} else {
// Log and re-throw unexpected errors
console.error("Unexpected error:", error);
throw error;
}
}
2. Use Specific Error Types
// ❌ Bad - generic errors
function divide(a, b) {
if (b === 0) {
throw new Error("Error");
}
return a / b;
}
// ✅ Good - specific, descriptive errors
function divide(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new TypeError("Arguments must be numbers");
}
if (b === 0) {
throw new RangeError("Cannot divide by zero");
}
return a / b;
}
3. Provide Context in Error Messages
// ❌ Bad - vague error message
function getUser(id) {
if (!id) {
throw new Error("Invalid input");
}
// ...
}
// ✅ Good - specific, helpful error message
function getUser(id) {
if (!id) {
throw new Error(`User ID is required. Received: ${id}`);
}
if (typeof id !== 'number') {
throw new TypeError(`User ID must be a number. Received type: ${typeof id}`);
}
// ...
}
4. Clean Up Resources in finally
function processFile(filename) {
let fileHandle = null;
try {
fileHandle = openFile(filename);
let data = readFile(fileHandle);
return processData(data);
} catch (error) {
console.error(`Error processing ${filename}:`, error.message);
throw error;
} finally {
// Always close the file, even if errors occurred
if (fileHandle) {
closeFile(fileHandle);
}
}
}
5. Use Error Boundaries (in UI frameworks)
For React and similar frameworks, use error boundaries to catch rendering errors:
// React Error Boundary example
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error("Component error:", error, errorInfo);
// Log to error reporting service
logErrorToService(error, errorInfo);
}
render() {
if (this.state.hasError) {
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
Logging
Proper logging is essential for debugging and monitoring applications in production. Use appropriate logging levels and services.
Console Logging Levels
// Different log levels for different purposes
console.log("General information"); // General output
console.info("Informational message"); // FYI messages
console.warn("Warning message"); // Warning - something unexpected
console.error("Error message"); // Error - something failed
console.debug("Debug message"); // Detailed debugging info
// Grouping related logs
console.group("User Login Process");
console.log("Validating credentials...");
console.log("Fetching user data...");
console.log("Creating session...");
console.groupEnd();
// Timing operations
console.time("Database Query");
// ... perform query ...
console.timeEnd("Database Query"); // "Database Query: 45.123ms"
Professional Logging Frameworks
Employ robust logging frameworks for production applications:
Modern logging services:
- Sentry - Error tracking and performance monitoring
- LogRocket - Session replay with error tracking
- Datadog - Full-stack monitoring
- New Relic - Application performance monitoring
- Papertrail - Log aggregation
- Loggly - Cloud-based log management
Client-side error tracking:
// Example with Sentry
import * as Sentry from "@sentry/browser";
Sentry.init({
dsn: "your-sentry-dsn",
environment: "production",
beforeSend(event, hint) {
// Filter out sensitive information
if (event.user) {
delete event.user.email;
}
return event;
}
});
// Errors are automatically captured
try {
riskyOperation();
} catch (error) {
// Manually capture with additional context
Sentry.captureException(error, {
tags: {
section: "checkout",
},
extra: {
userId: currentUser.id,
}
});
}
Classic image beacon for logging:
function log(app, text) {
let img = new Image();
img.src = `https://log.example.com?app=${app}&text=${encodeURIComponent(text)}`;
}
// Usage
try {
processCheckout();
} catch (error) {
log("shop", `Checkout error: ${error.message}`);
}
Logging sensitive information
Never log sensitive information such as:
- Passwords or authentication tokens
- Credit card numbers
- Personal identification numbers
- API keys or secrets
- Private user data (emails, addresses, phone numbers)
Always sanitize data before logging in production.
Assignment(s)
Testing
Testing is crucial for ensuring your error handling works correctly. Numerous test frameworks and tools can assist you:
Modern Testing Frameworks
Unit Testing:
- Jest - Popular, zero-config testing framework
- Vitest - Fast, Vite-native unit test framework
- Mocha - Flexible testing framework
- Jasmine - Behavior-driven testing
End-to-End Testing:
- Playwright - Modern, cross-browser automation (recommended)
- Cypress - Developer-friendly E2E testing
- Puppeteer - Headless Chrome automation
- Selenium - Cross-browser testing (older but stable)
Testing Libraries:
- Testing Library - User-centric testing utilities
- Sinon - Spies, stubs, and mocks
- Chai - Assertion library
CI/CD Integration:
- GitHub Actions - Built-in CI/CD for GitHub
- GitLab CI - Integrated CI/CD for GitLab
- CircleCI - Cloud-based CI/CD
- Jenkins - Self-hosted automation server
Testing Error Handling
// Example with Jest
describe('User validation', () => {
test('should throw ValidationError for invalid age', () => {
expect(() => {
validateAge("invalid");
}).toThrow(ValidationError);
expect(() => {
validateAge(-5);
}).toThrow("Age must be between 0 and 150");
});
test('should handle API errors gracefully', async () => {
// Mock fetch to simulate error
global.fetch = jest.fn(() =>
Promise.reject(new Error("Network error"))
);
await expect(fetchUser(123)).rejects.toThrow("Network error");
});
});
// Example with Playwright for E2E testing
test('should show error message when login fails', async ({ page }) => {
await page.goto('https://example.com/login');
await page.fill('#username', 'invalid');
await page.fill('#password', 'wrong');
await page.click('#login-button');
// Verify error message is displayed
await expect(page.locator('.error-message')).toContainText('Invalid credentials');
});
Summary
Effective error handling is essential for building robust, maintainable JavaScript applications. It helps you catch problems early, debug issues faster, and provide better user experiences.
Key Takeaways
Basic Error Handling:
- Use
try-catch-finallyfor synchronous error handling trycontains code that might throw errorscatchhandles errors that occurfinallyalways executes for cleanup- Use
console.error()for error logging (notconsole.log())
Error Object:
- Has
message,name, andstackproperties - Built-in types:
Error,TypeError,ReferenceError,RangeError,SyntaxError,URIError - Always use Error objects (not strings or primitives)
Throwing Errors:
- Use
throw new Error("message")to create errors - Always throw Error instances (not strings or other types)
- Create custom error classes for specific error types
- Provide descriptive, contextual error messages
Error Propagation:
- Errors bubble up the call stack until caught
- Stack traces show the full call path
- Use specific catch blocks for different error types
- Re-throw errors when you can’t handle them completely
Async Error Handling:
- Use
.catch()with Promises - Use
try-catchwithasync/await - Handle errors in
Promise.all()andPromise.allSettled() - Async errors won’t be caught by synchronous try-catch
Best Practices:
- Never silently swallow errors
- Use specific error types and messages
- Clean up resources in
finallyblocks - Don’t log sensitive information
- Use custom error classes for structure
- Re-throw errors you can’t handle
- Test your error handling code
Logging:
- Use appropriate console methods (
error,warn,info,debug) - Use professional logging services for production (Sentry, LogRocket)
- Never log passwords, tokens, or sensitive data
- Include context in log messages
Testing:
- Test both success and error paths
- Use modern frameworks (Jest, Vitest, Playwright)
- Mock external dependencies to test error scenarios
- Verify error messages and types in tests
- Use CI/CD for automated testing
Proper error handling transforms your applications from fragile scripts into robust, production-ready software. Always handle errors thoughtfully, log them appropriately, and test your error handling code as thoroughly as your success paths.