Skip to main content

Command Palette

Search for a command to run...

Error Handling in JavaScript: Try, Catch, Finally Explained

Published
18 min read

Introduction

Imagine you're using a mobile app and something goes wrong. Instead of the app crashing and closing unexpectedly, it shows a helpful error message and lets you continue using it. That's good error handling. In JavaScript, errors are inevitable. Whether it's a user entering invalid data, a network request failing, or a bug in your code, knowing how to handle errors gracefully is essential for building reliable applications. In this blog, we'll explore what errors are, how to catch and handle them properly, and why error handling is so important for professional development.


1. What Are Errors in JavaScript?

Errors are exceptional conditions that occur during program execution. They can be caused by mistakes in code, unexpected user input, or external factors like network failures.

Types of JavaScript Errors:

// 1. Syntax Error - Invalid code structure (caught at parse time)
// var x = ; // SyntaxError: Unexpected token

// 2. ReferenceError - Using undefined variable
console.log(undefinedVariable); // ReferenceError: undefinedVariable is not defined

// 3. TypeError - Using a value incorrectly
const str = "hello";
str.toUpperCase.toUpperCase(); // TypeError: str.toUpperCase.toUpperCase is not a function

// 4. RangeError - Invalid numeric value
const arr = new Array(-1); // RangeError: Invalid array length

// 5. Error - Generic error
throw new Error("Something went wrong!");

How Errors Occur in Real Situations:

// Example 1: Accessing property of undefined
const user = null;
console.log(user.name); // TypeError: Cannot read property 'name' of null

// Example 2: Calling undefined function
const getData = undefined;
getData(); // TypeError: getData is not a function

// Example 3: Invalid operation
const num = "5";
const result = num.map(x => x); // TypeError: num.map is not a function

// Example 4: Division by zero (not an error, returns Infinity)
console.log(10 / 0); // Infinity (No error!)

// Example 5: JSON parsing invalid JSON
const json = "{invalid json}";
JSON.parse(json); // SyntaxError: Unexpected token

Error Object Structure:

try {
  undefinedFunc();
} catch (error) {
  console.log(error.name); // "ReferenceError"
  console.log(error.message); // "undefinedFunc is not defined"
  console.log(error.stack); // Full stack trace for debugging
}

2. Using Try and Catch Blocks

The try and catch blocks are the primary way to handle errors in JavaScript. try contains code that might throw an error, and catch handles it if it does.

Basic Structure:

try {
  // Code that might cause an error
  riskyOperation();
} catch (error) {
  // Code to handle the error
  console.log("An error occurred:", error.message);
}

Simple Example:

try {
  const result = JSON.parse("invalid json");
} catch (error) {
  console.log("Failed to parse JSON:", error.message);
  // Program continues instead of crashing
}

console.log("Program continues..."); // This still executes!

Catching Specific Error Types:

// Without try-catch - program crashes here
const user = null;
console.log(user.name); // TypeError: Cannot read property 'name' of null
console.log("This never executes"); // Never reached

// ----

// With try-catch - handle gracefully
try {
  const user = null;
  console.log(user.name);
} catch (error) {
  console.log("Caught an error:", error.message);
}
console.log("Program continues"); // This executes!

Checking Error Type:

try {
  const data = JSON.parse("{invalid}");
} catch (error) {
  if (error instanceof SyntaxError) {
    console.log("Invalid JSON format");
  } else if (error instanceof TypeError) {
    console.log("Type mismatch");
  } else {
    console.log("Unknown error:", error.message);
  }
}

Real-World Example: API Call

async function fetchUserData(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`);
    
    if (!response.ok) {
      throw new Error(`HTTP Error: ${response.status}`);
    }
    
    const data = await response.json();
    return data;
  } catch (error) {
    console.log("Failed to fetch user:", error.message);
    return null; // Return default value
  }
}

// Usage
const user = await fetchUserData(1);
if (user) {
  console.log(user);
} else {
  console.log("Could not load user data");
}

Try-Catch with Different Operations:

try {
  // Operation 1: Accessing property
  const user = { name: "Raj" };
  console.log(user.name); // "Raj" - OK

  // Operation 2: Array access
  const arr = [1, 2, 3];
  console.log(arr[0]); // 1 - OK

  // Operation 3: Function call
  const result = parseInt("123");
  console.log(result); // 123 - OK

  // Operation 4: File operations (would fail if file doesn't exist)
  // const content = fs.readFileSync("/path/to/file.txt");
  
} catch (error) {
  console.log("Error occurred:", error.message);
}

Multiple Catches (Not Standard, But Useful Pattern):

// JavaScript doesn't support multiple catch blocks natively
// But you can use if-else inside catch

try {
  // Some risky operation
  throw new TypeError("Invalid type");
} catch (error) {
  if (error instanceof TypeError) {
    console.log("Type Error:", error.message);
  } else if (error instanceof ReferenceError) {
    console.log("Reference Error:", error.message);
  } else {
    console.log("Other Error:", error.message);
  }
}

Rethrowing Errors:

// Sometimes you want to handle an error partially and then rethrow it

try {
  riskyOperation();
} catch (error) {
  console.log("Logging error for debugging:", error.message);
  
  // Log to external service
  logErrorToServer(error);
  
  // Rethrow for caller to handle
  throw error;
}

3. The Finally Block

The finally block executes regardless of whether an error occurs. It's perfect for cleanup operations.

Basic Structure:

try {
  // Code that might throw an error
  riskyOperation();
} catch (error) {
  // Handle the error
  console.log("Error:", error.message);
} finally {
  // This ALWAYS runs
  console.log("Cleanup code");
}

Execution Order:

console.log("1. Start");

try {
  console.log("2. In try block");
  throw new Error("Something went wrong");
  console.log("3. This won't execute");
} catch (error) {
  console.log("4. In catch block:", error.message);
} finally {
  console.log("5. In finally block");
}

console.log("6. After try-catch-finally");

// Output:
// 1. Start
// 2. In try block
// 4. In catch block: Something went wrong
// 5. In finally block
// 6. After try-catch-finally

Finally Always Executes (Even with Return):

function testFinally() {
  try {
    return "from try";
  } finally {
    console.log("finally executes!"); // This executes even with return!
  }
}

const result = testFinally();
console.log(result); // "from try"

// Output:
// finally executes!
// from try

Practical Use Cases for Finally:

Use Case 1: Closing Resources

function readFile(filename) {
  let file = null;
  
  try {
    file = openFile(filename); // Open file
    const content = file.read(); // Read content
    return content;
  } catch (error) {
    console.log("Error reading file:", error.message);
    return null;
  } finally {
    if (file) {
      file.close(); // Always close the file
    }
  }
}

Use Case 2: Cleaning Up State

let isLoading = false;

async function fetchData(url) {
  isLoading = true;
  
  try {
    const response = await fetch(url);
    return await response.json();
  } catch (error) {
    console.log("Fetch failed:", error.message);
    return null;
  } finally {
    isLoading = false; // Always update loading state
  }
}

Use Case 3: Hiding Loading Indicator

function showLoadingSpinner() {
  document.getElementById("spinner").style.display = "block";
}

function hideLoadingSpinner() {
  document.getElementById("spinner").style.display = "none";
}

async function loadUserData(userId) {
  showLoadingSpinner();
  
  try {
    const response = await fetch(`/api/users/${userId}`);
    return await response.json();
  } catch (error) {
    console.log("Failed to load user:", error.message);
    return null;
  } finally {
    hideLoadingSpinner(); // Always hide, even on error
  }
}

Use Case 4: Database Connection

async function queryDatabase(query) {
  let connection = null;
  
  try {
    connection = await db.connect();
    const results = await connection.query(query);
    return results;
  } catch (error) {
    console.log("Query failed:", error.message);
    return [];
  } finally {
    if (connection) {
      connection.close(); // Always close connection
    }
  }
}

Finally with No Catch:

// You can have try-finally without catch
try {
  const data = JSON.parse(jsonString);
  processData(data);
} finally {
  console.log("Cleanup regardless of errors");
  // If error occurs, it will propagate after finally
}

4. Throwing Custom Errors

Sometimes you need to throw your own errors to signal that something went wrong in your application logic.

Throwing Simple Errors:

// Throw a string (not recommended)
throw "Something went wrong";

// Throw an Error object (recommended)
throw new Error("Something went wrong");

Creating Custom Error Classes:

// Custom error class
class ValidationError extends Error {
  constructor(message) {
    super(message);
    this.name = "ValidationError";
  }
}

// Using custom error
function validateEmail(email) {
  if (!email.includes("@")) {
    throw new ValidationError("Invalid email format");
  }
  return true;
}

try {
  validateEmail("invalid-email");
} catch (error) {
  if (error instanceof ValidationError) {
    console.log("Validation failed:", error.message);
  }
}

Practical Example: User Validation

class ValidationError extends Error {
  constructor(field, message) {
    super(`\({field}: \){message}`);
    this.name = "ValidationError";
    this.field = field;
  }
}

function validateUser(user) {
  if (!user.name || user.name.trim() === "") {
    throw new ValidationError("name", "Name is required");
  }
  
  if (!user.email || !user.email.includes("@")) {
    throw new ValidationError("email", "Valid email is required");
  }
  
  if (!user.age || user.age < 18) {
    throw new ValidationError("age", "Must be 18 or older");
  }
  
  return true;
}

try {
  validateUser({ name: "Ashish", email: "invalid", age: 21 });
} catch (error) {
  if (error instanceof ValidationError) {
    console.log(`\({error.field}: \){error.message}`);
  }
}

Throwing Errors Based on Conditions:

function divide(a, b) {
  if (typeof a !== "number" || typeof b !== "number") {
    throw new TypeError("Both arguments must be numbers");
  }
  
  if (b === 0) {
    throw new Error("Cannot divide by zero");
  }
  
  return a / b;
}

try {
  console.log(divide(10, 2)); // 5
  console.log(divide(10, 0)); // Error!
} catch (error) {
  console.log("Calculation error:", error.message);
}

Multiple Custom Errors:

class AuthenticationError extends Error {
  constructor(message) {
    super(message);
    this.name = "AuthenticationError";
  }
}

class AuthorizationError extends Error {
  constructor(message) {
    super(message);
    this.name = "AuthorizationError";
  }
}

function login(username, password) {
  if (!username || !password) {
    throw new AuthenticationError("Username and password required");
  }
  
  // Simulate user lookup
  if (username !== "admin") {
    throw new AuthenticationError("Invalid credentials");
  }
  
  return { username, token: "abc123" };
}

function accessAdminPanel(user) {
  if (!user.isAdmin) {
    throw new AuthorizationError("Admin access required");
  }
}

try {
  const user = login("admin", "password");
  accessAdminPanel(user);
} catch (error) {
  if (error instanceof AuthenticationError) {
    console.log("Auth Error:", error.message);
  } else if (error instanceof AuthorizationError) {
    console.log("Access Denied:", error.message);
  } else {
    console.log("Unknown error:", error.message);
  }
}

Throwing with Stack Trace Preservation:

function processData(data) {
  try {
    const parsed = JSON.parse(data);
    if (!parsed.id) {
      throw new Error("ID field is required");
    }
    return parsed;
  } catch (error) {
    // Add context while preserving stack trace
    console.error("Failed to process data:", error);
    
    // Rethrow with additional context
    throw error; // Stack trace is preserved
  }
}

5. Why Error Handling Matters

Proper error handling is critical for professional, reliable applications. Let's understand why.

Reason 1: Prevents Application Crashes

// Without error handling - app crashes
const userInput = "not a number";
const number = parseInt(userInput);
const result = number * 2; // If something goes wrong, app stops here
console.log("Result:", result); // Never executes on error

// ----

// With error handling - app continues
try {
  const userInput = "not a number";
  const number = parseInt(userInput);
  if (isNaN(number)) {
    throw new Error("Invalid number");
  }
  const result = number * 2;
  console.log("Result:", result);
} catch (error) {
  console.log("Error:", error.message);
  console.log("Using default value"); // Still continues
}

Reason 2: Better User Experience

// Without error handling - user sees blank page
async function loadUserProfile(userId) {
  const response = await fetch(`/api/users/${userId}`);
  const user = await response.json(); // If fails, crashes
  displayUserProfile(user);
}

// ----

// With error handling - user sees message
async function loadUserProfile(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`);
    
    if (!response.ok) {
      throw new Error("Failed to load profile");
    }
    
    const user = await response.json();
    displayUserProfile(user);
  } catch (error) {
    showErrorMessage("Could not load profile. Please try again later.");
  }
}

Reason 3: Easier Debugging

// Without error handling - hard to debug
function complexOperation() {
  const result1 = operation1(); // Which failed?
  const result2 = operation2(result1); // This?
  const result3 = operation3(result2); // Or this?
  return result3;
}

// ----

// With error handling - clear where it failed
function complexOperation() {
  try {
    const result1 = operation1();
    console.log("Operation 1 succeeded");
    
    const result2 = operation2(result1);
    console.log("Operation 2 succeeded");
    
    const result3 = operation3(result2);
    console.log("Operation 3 succeeded");
    
    return result3;
  } catch (error) {
    console.error("Operation failed:", error.message);
    console.error("Stack trace:", error.stack);
  }
}

Reason 4: Resource Cleanup

// Without finally - resources might leak
async function downloadFile(url) {
  const file = await openFile();
  const content = await fetch(url); // What if this fails?
  await file.write(content);
  file.close(); // Never called if fetch fails!
}

// ----

// With finally - always cleanup
async function downloadFile(url) {
  let file = null;
  
  try {
    file = await openFile();
    const content = await fetch(url);
    await file.write(content);
  } catch (error) {
    console.log("Download failed:", error.message);
  } finally {
    if (file) {
      file.close(); // Always closes
    }
  }
}

Reason 5: Production Monitoring

// Log errors for monitoring in production
async function criticalOperation() {
  try {
    // Important business logic
    return await processSensitiveData();
  } catch (error) {
    // Log for monitoring
    console.error("Critical operation failed:", error);
    
    // Send to error tracking service
    sendErrorToSentry({
      message: error.message,
      stack: error.stack,
      timestamp: new Date()
    });
    
    // Notify admin
    notifyAdminOfError(error);
    
    // Return safe default
    return { success: false };
  }
}

Reason 6: Validation and Data Integrity

// Prevent invalid data from being processed
function saveUserData(userData) {
  try {
    validateUser(userData);
    
    // Only save if validation passes
    database.save(userData);
    
    return { success: true };
  } catch (error) {
    return {
      success: false,
      error: error.message
    };
  }
}

function validateUser(user) {
  if (!user.email || !user.email.includes("@")) {
    throw new Error("Invalid email");
  }
  
  if (!user.age || user.age < 0 || user.age > 150) {
    throw new Error("Invalid age");
  }
}

6. Error Handling Patterns

Pattern 1: Try-Catch-Finally

function fetchData(url) {
  try {
    // Attempt operation
    const response = fetch(url);
    return response;
  } catch (error) {
    // Handle error
    console.log("Fetch failed:", error.message);
    return null;
  } finally {
    // Cleanup
    console.log("Fetch attempt completed");
  }
}

Pattern 2: Error Wrapping

// Wrap low-level errors in meaningful ones
function getUserFromDatabase(userId) {
  try {
    return db.query(`SELECT * FROM users WHERE id = ${userId}`);
  } catch (error) {
    throw new Error(`Failed to load user \({userId}: \){error.message}`);
  }
}

Pattern 3: Graceful Degradation

// Continue with default values on error
function getUser(userId) {
  try {
    return fetch(`/api/users/${userId}`).then(r => r.json());
  } catch (error) {
    console.log("Using cached user data");
    return getCachedUser(userId); // Fallback
  }
}

Pattern 4: Silent Failure (Use Carefully)

// Sometimes you want to fail silently
function tryParse(json) {
  try {
    return JSON.parse(json);
  } catch (error) {
    // Don't throw, just return null
    return null;
  }
}

Pattern 5: Error Logging

// Log errors with context
function logError(error, context) {
  const errorInfo = {
    message: error.message,
    stack: error.stack,
    context: context,
    timestamp: new Date(),
    userAgent: navigator.userAgent
  };
  
  // Send to server
  fetch("/api/errors", {
    method: "POST",
    body: JSON.stringify(errorInfo)
  });
}

try {
  riskyOperation();
} catch (error) {
  logError(error, { operation: "fetchUserData", userId: 123 });
}

7. Common Error Handling Mistakes

Mistake 1: Silent Failures

// Bad: Silently failing
try {
  processData();
} catch (error) {
  // Empty - error is swallowed
}

// Good: Handle or log
try {
  processData();
} catch (error) {
  console.error("Processing failed:", error);
  throw error; // Rethrow if critical
}

Mistake 2: Catching Everything Without Discrimination

// Bad: Catches all errors
try {
  const data = JSON.parse(json);
  const result = riskyOperation(data);
  return result;
} catch (error) {
  // Can't tell what failed
  return null;
}

// Good: Be specific
try {
  const data = JSON.parse(json);
  return riskyOperation(data);
} catch (error) {
  if (error instanceof SyntaxError) {
    console.log("Invalid JSON");
  } else if (error instanceof TypeError) {
    console.log("Type error in operation");
  } else {
    throw error; // Unknown error, rethrow
  }
}

Mistake 3: Not Cleaning Up Resources

// Bad: Resource leak
async function download(url) {
  const file = openFile();
  const data = await fetch(url); // If this fails...
  file.write(data);
  file.close(); // Never called!
}

// Good: Always cleanup
async function download(url) {
  const file = openFile();
  try {
    const data = await fetch(url);
    file.write(data);
  } finally {
    file.close(); // Always called
  }
}

Mistake 4: Losing Original Error Information

// Bad: Losing stack trace
try {
  riskyOperation();
} catch (error) {
  throw new Error("Failed"); // Stack trace lost
}

// Good: Preserve original error
try {
  riskyOperation();
} catch (error) {
  throw new Error(`Failed: ${error.message}`);
  // Or just rethrow
  throw error;
}

Mistake 5: Catching Operational Errors as Bugs

// Bad: Treating user input error as code bug
try {
  const age = parseInt(userInput);
  if (age < 0) throw new Error("Invalid age");
  processAge(age);
} catch (error) {
  // This treats validation error same as code error
  reportBug(error);
}

// Good: Handle validation separately
function validateAge(age) {
  if (age < 0) return false;
  return true;
}

try {
  const age = parseInt(userInput);
  if (!validateAge(age)) {
    showError("Please enter a valid age");
    return;
  }
  processAge(age);
} catch (error) {
  // Only actual code errors reach here
  reportBug(error);
}

8. Error Handling Flow Diagram

                     Code Execution
                           |
                           v
                    ┌──────────────┐
                    │  try block   │
                    │              │
                    │  Normal code │
                    └──────────────┘
                           |
                ┌──────────┴──────────┐
                |                     |
        No Error Thrown        Error Thrown
                |                     |
                |                     v
                |         ┌──────────────────┐
                |         │ catch block      │
                |         │                  │
                |         │ Error handling   │
                |         └──────────────────┘
                |                     |
                ├─────────────────────┤
                |                     |
                v                     v
         ┌──────────────┐      ┌──────────────┐
         │ finally block│      │ finally block│
         │  (runs)      │      │  (runs)      │
         └──────────────┘      └──────────────┘
                |                     |
                v                     v
         Continue execution  Continue or Rethrow

9. Real-World Error Handling Example

// Complete example combining all concepts

class APIError extends Error {
  constructor(status, message) {
    super(message);
    this.name = "APIError";
    this.status = status;
  }
}

class ValidationError extends Error {
  constructor(field, message) {
    super(message);
    this.name = "ValidationError";
    this.field = field;
  }
}

async function registerUser(userData) {
  let response = null;
  
  try {
    // Validate input
    validateUserData(userData);
    console.log("✓ Validation passed");
    
    // Make API call
    response = await fetch("/api/register", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(userData)
    });
    
    console.log("✓ API call completed");
    
    // Check response
    if (!response.ok) {
      throw new APIError(response.status, "Registration failed");
    }
    
    const result = await response.json();
    console.log("✓ User registered successfully");
    
    return result;
    
  } catch (error) {
    // Handle different error types
    if (error instanceof ValidationError) {
      console.error(`Validation Error - \({error.field}: \){error.message}`);
      return { success: false, error: error.message, field: error.field };
    } else if (error instanceof APIError) {
      console.error(`API Error \({error.status}: \){error.message}`);
      return { success: false, error: error.message };
    } else {
      console.error("Unexpected error:", error.message);
      return { success: false, error: "An unexpected error occurred" };
    }
    
  } finally {
    console.log("✓ Registration attempt completed");
  }
}

function validateUserData(data) {
  if (!data.email || !data.email.includes("@")) {
    throw new ValidationError("email", "Valid email required");
  }
  
  if (!data.password || data.password.length < 8) {
    throw new ValidationError("password", "Password must be at least 8 characters");
  }
  
  if (!data.name || data.name.trim() === "") {
    throw new ValidationError("name", "Name is required");
  }
}

// Usage
await registerUser({
  name: "Ashish",
  email: "ashish@example.com",
  password: "securePass123"
});

10. Best Practices for Error Handling

Best Practice 1: Be Specific

// Bad: Too generic
try {
  doSomething();
} catch (e) {
  console.log("Error");
}

// Good: Specific and informative
try {
  doSomething();
} catch (error) {
  console.error(`Failed to do something: ${error.message}`);
  console.error(`Stack: ${error.stack}`);
}

Best Practice 2: Use Custom Errors

// Bad: Generic errors
throw new Error("Invalid");

// Good: Specific error classes
throw new ValidationError("email", "Invalid email format");

Best Practice 3: Always Cleanup

// Always use finally for cleanup
try {
  operation();
} finally {
  cleanup();
}

Best Practice 4: Log Errors in Production

try {
  operation();
} catch (error) {
  // Log for debugging
  console.error(error);
  
  // Send to monitoring service
  logToMonitoringService(error);
}

Best Practice 5: Provide Meaningful Messages

// Bad
throw new Error("Failed");

// Good
throw new Error(
  `Failed to save user \({userId} to database: \){error.message}`
);

Conclusion

Error handling is essential for professional JavaScript development. Here's what we've learned:

Key Concepts:

  1. Errors are exceptional conditions during execution

  2. Try-Catch blocks catch and handle errors

  3. Finally always runs, perfect for cleanup

  4. Custom Errors make error handling clear

  5. Error Handling prevents crashes and improves UX

Why It Matters:

  • Prevents application crashes

  • Improves user experience

  • Makes debugging easier

  • Protects resources

  • Enables monitoring

Best Practices:

  • Be specific about what can fail

  • Use custom error classes

  • Always cleanup with finally

  • Log errors for debugging

  • Provide meaningful error messages

Remember:

  • Strings don't throw errors (wrap in Error object)

  • Finally always executes

  • Errors can be caught and handled gracefully

  • Good error handling is a sign of professional code

Master error handling, and your applications will be more robust, reliable, and user-friendly. Every error is an opportunity to provide better feedback and create better experiences.