Difficulty: Intermediate
Error handling is one of the most critical aspects of writing robust JavaScript applications. The try/catch/finally statement provides a structured way to handle runtime errors gracefully instead of letting them crash your program. When code inside a try block throws an error, execution immediately jumps to the catch block, where you can inspect the error and decide how to respond.
The basic syntax wraps potentially dangerous code in a try block and provides a catch block that receives the error object. The catch block's parameter gives you access to the error that was thrown, allowing you to log it, display a user-friendly message, or attempt recovery. Without try/catch, an unhandled error would propagate up the call stack and potentially terminate your application or leave it in an inconsistent state.
The finally block is a powerful addition that executes regardless of whether an error occurred. This makes it ideal for cleanup operations like closing database connections, releasing file handles, or hiding loading spinners. The finally block runs after both the try and catch blocks complete, and it runs even if either block contains a return statement. This guarantee makes finally indispensable for resource management.
Nested try/catch blocks allow you to handle errors at different granularity levels. An inner try/catch can handle specific errors while letting unexpected ones propagate to an outer handler. This pattern is common in complex operations where different steps might fail in different ways and require different recovery strategies.
The error object in JavaScript contains three standard properties: message (a human-readable description), name (the error type like 'TypeError' or 'RangeError'), and stack (a string showing the call stack at the point the error was created). The stack trace is invaluable for debugging because it shows exactly where the error originated and the chain of function calls that led to it.
Rethrowing errors is a technique where you catch an error, perform some action (like logging), and then throw it again so that a higher-level handler can deal with it. This is especially useful when you want to add context or perform side effects without fully handling the error. With async/await, try/catch becomes the primary way to handle rejected promises, replacing the .catch() chain pattern and making asynchronous error handling look synchronous.
function parseJSON(jsonString) {
try {
const data = JSON.parse(jsonString);
console.log('Parsed successfully:', data);
return data;
} catch (error) {
console.log('Error name:', error.name);
console.log('Error message:', error.message);
return null;
} finally {
console.log('Parse attempt completed');
}
}
parseJSON('{"name": "Alice"}');
parseJSON('invalid json');
The first call parses valid JSON and returns the object. The second call fails, triggering the catch block which logs the error details. In both cases, the finally block runs, confirming the attempt completed.
function processUser(data) {
try {
try {
const user = JSON.parse(data);
if (!user.name) {
throw new Error('Name is required');
}
console.log('User:', user.name);
} catch (innerError) {
console.log('Inner catch:', innerError.message);
throw innerError; // rethrow for outer handler
}
} catch (outerError) {
console.log('Outer catch:', outerError.message);
console.log('Stack preview:', outerError.stack.split('\n')[0]);
}
}
processUser('{"age": 25}');
console.log('---');
processUser('bad data');
The inner try/catch handles the immediate parsing/validation, logs the error, then rethrows so the outer handler can also process it. This layered approach allows different levels of error handling.
function riskyOperation() {
try {
console.log('Trying operation...');
throw new Error('Something went wrong');
return 'success'; // never reached
} catch (error) {
console.log('Caught:', error.message);
return 'recovered';
} finally {
console.log('Cleanup: closing resources');
// Note: a return here would override the catch return!
}
}
const result = riskyOperation();
console.log('Result:', result);
Even though the catch block returns 'recovered', the finally block still executes before the function actually returns. If finally also had a return, it would override the catch block's return value - a common pitfall.
async function fetchUserData(userId) {
try {
console.log('Fetching user', userId);
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
if (error.name === 'TypeError') {
console.log('Network error - check your connection');
} else {
console.log('Fetch failed:', error.message);
}
return null;
} finally {
console.log('Request completed for user', userId);
}
}
// Usage:
// const user = await fetchUserData(42);
console.log('try/catch works seamlessly with async/await');
With async/await, try/catch handles rejected promises the same way it handles synchronous errors. The catch block can differentiate error types - network failures produce TypeErrors, while HTTP errors can be thrown manually with custom messages.
try/catch, finally, error object, nested try/catch, rethrow, async error handling