JavaScript Error Handling: try-catch and Custom Errors

Unhandled errors don't just crash your application—they corrupt state, lose user data, and create debugging nightmares in production. A single uncaught exception in a Node.js server can terminate the...

Key Insights

  • JavaScript’s try-catch mechanism prevents application crashes, but wrapping everything in try-catch blocks creates maintenance nightmares—scope your error handling to specific failure points where you can actually recover or provide meaningful feedback.
  • Custom error classes transform generic exceptions into structured, actionable information by encoding domain knowledge directly into your error types, making debugging faster and error handling logic cleaner.
  • Async functions require await inside try-catch blocks to catch rejected promises; forgetting this is the most common error handling mistake in modern JavaScript and leads to unhandled promise rejections.

Understanding Why Error Handling Matters

Unhandled errors don’t just crash your application—they corrupt state, lose user data, and create debugging nightmares in production. A single uncaught exception in a Node.js server can terminate the entire process. In browser JavaScript, unhandled errors break user flows and leave your application in an undefined state.

The difference between a robust application and a fragile one often comes down to error handling strategy. Here’s what happens without proper error handling:

// Unhandled error - application crashes
function processUserData(data) {
  const result = JSON.parse(data); // Throws if data is invalid
  return result.user.email.toLowerCase(); // Throws if structure is wrong
}

processUserData('invalid json'); // Uncaught SyntaxError - crash

With proper error handling, you maintain control:

// Graceful error handling
function processUserData(data) {
  try {
    const result = JSON.parse(data);
    
    if (!result.user || !result.user.email) {
      throw new Error('Invalid user data structure');
    }
    
    return result.user.email.toLowerCase();
  } catch (error) {
    console.error('Failed to process user data:', error.message);
    return null; // Or throw a custom error, depending on context
  }
}

const email = processUserData('invalid json');
console.log(email); // null - application continues running

The try-catch-finally Fundamentals

The try-catch-finally statement provides three distinct blocks for error handling. The try block contains code that might throw an error. The catch block executes only when an error occurs. The finally block always executes, regardless of whether an error was thrown.

function readConfig(filename) {
  let file = null;
  
  try {
    file = openFile(filename); // Might throw
    const config = parseConfig(file.read()); // Might throw
    return config;
  } catch (error) {
    console.error(`Config error: ${error.message}`);
    return getDefaultConfig();
  } finally {
    // Always executes - perfect for cleanup
    if (file) {
      file.close();
    }
  }
}

The error object caught in the catch block contains valuable debugging information:

try {
  const obj = null;
  obj.property.value = 'test'; // TypeError
} catch (error) {
  console.log(error.name);      // "TypeError"
  console.log(error.message);   // "Cannot read properties of null"
  console.log(error.stack);     // Full stack trace
}

The finally block executes in all scenarios—after successful try completion, after catch block execution, and even when return statements exist in try or catch:

function demonstrateFinally() {
  try {
    console.log('Try block');
    return 'from try';
  } catch (error) {
    console.log('Catch block');
    return 'from catch';
  } finally {
    console.log('Finally block'); // Always executes
    // Note: return here would override try/catch returns
  }
}

demonstrateFinally();
// Output:
// Try block
// Finally block
// Returns: "from try"

Working with Built-in Error Types

JavaScript provides several built-in error types that signal specific failure conditions. Understanding these helps you write more precise error handling logic.

function handleDifferentErrors() {
  try {
    // Different operations that might fail
    const result = riskyOperation();
  } catch (error) {
    if (error instanceof TypeError) {
      console.error('Type error - check your data types');
    } else if (error instanceof ReferenceError) {
      console.error('Reference error - undefined variable');
    } else if (error instanceof RangeError) {
      console.error('Range error - value out of bounds');
    } else {
      // Unknown error type - rethrow
      throw error;
    }
  }
}

Sometimes you need to inspect an error and decide whether to handle it or propagate it up the call stack:

async function fetchUserData(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`);
    return await response.json();
  } catch (error) {
    if (error instanceof TypeError) {
      // Network error or JSON parse error - we can handle this
      console.error('Failed to fetch user data:', error);
      return null;
    }
    
    // Unknown error - let it propagate
    throw error;
  }
}

Creating Custom Error Classes

Custom errors transform error handling from generic exception catching into a structured, type-safe system. They encode domain knowledge and make your error handling logic self-documenting.

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

class DatabaseError extends Error {
  constructor(message, query, originalError) {
    super(message);
    this.name = 'DatabaseError';
    this.query = query;
    this.originalError = originalError;
  }
}

class NotFoundError extends Error {
  constructor(resource, id) {
    super(`${resource} with id ${id} not found`);
    this.name = 'NotFoundError';
    this.statusCode = 404;
    this.resource = resource;
    this.id = id;
  }
}

Custom errors shine when building APIs or complex applications:

class ApiError extends Error {
  constructor(message, statusCode, errorCode, context = {}) {
    super(message);
    this.name = 'ApiError';
    this.statusCode = statusCode;
    this.errorCode = errorCode;
    this.context = context;
    this.timestamp = new Date().toISOString();
  }
  
  toJSON() {
    return {
      error: {
        message: this.message,
        code: this.errorCode,
        status: this.statusCode,
        context: this.context,
        timestamp: this.timestamp
      }
    };
  }
}

// Usage in an Express route
app.post('/api/users', async (req, res, next) => {
  try {
    const { email, password } = req.body;
    
    if (!email || !email.includes('@')) {
      throw new ApiError(
        'Invalid email address',
        400,
        'INVALID_EMAIL',
        { field: 'email', value: email }
      );
    }
    
    const user = await createUser(email, password);
    res.json(user);
  } catch (error) {
    if (error instanceof ApiError) {
      res.status(error.statusCode).json(error.toJSON());
    } else {
      next(error); // Let error middleware handle it
    }
  }
});

Best Practices and Patterns

The most critical error handling pattern in modern JavaScript involves async/await. Promise rejections require await to be inside a try-catch block:

// WRONG - catch block never executes for promise rejections
try {
  fetchData(); // Returns a promise
} catch (error) {
  console.error(error); // Won't catch promise rejections
}

// CORRECT - await makes promise rejections catchable
async function loadData() {
  try {
    const data = await fetchData(); // await is crucial
    return processData(data);
  } catch (error) {
    console.error('Data loading failed:', error);
    return getDefaultData();
  }
}

Create error wrapper functions for consistent handling across your application:

function withErrorHandling(fn, context = '') {
  return async function(...args) {
    try {
      return await fn(...args);
    } catch (error) {
      console.error(`Error in ${context}:`, error);
      
      // Log to error tracking service
      errorTracker.capture(error, {
        context,
        args: JSON.stringify(args)
      });
      
      throw error; // Re-throw after logging
    }
  };
}

// Usage
const safeUserFetch = withErrorHandling(
  fetchUser,
  'fetchUser operation'
);

Implement centralized error logging for production applications:

class ErrorHandler {
  static async handle(error, context = {}) {
    // Log to console in development
    if (process.env.NODE_ENV === 'development') {
      console.error('Error:', error);
      console.error('Context:', context);
    }
    
    // Send to error tracking service in production
    if (process.env.NODE_ENV === 'production') {
      await this.sendToErrorService(error, context);
    }
    
    // Return user-friendly message
    return this.getUserMessage(error);
  }
  
  static getUserMessage(error) {
    if (error instanceof ValidationError) {
      return error.message;
    }
    
    // Don't expose internal errors to users
    return 'An unexpected error occurred. Please try again.';
  }
}

Common Pitfalls to Avoid

Empty catch blocks are a code smell. If you can’t handle an error meaningfully, don’t catch it:

// BAD - swallows errors silently
try {
  criticalOperation();
} catch (error) {
  // Empty - error disappears
}

// BETTER - at minimum, log it
try {
  criticalOperation();
} catch (error) {
  console.error('Critical operation failed:', error);
  throw error; // Propagate if you can't handle it
}

Avoid overly broad try-catch blocks that make debugging difficult:

// BAD - too broad, hard to debug
try {
  const user = fetchUser();
  const posts = fetchPosts();
  const comments = fetchComments();
  const analytics = processAnalytics();
  renderDashboard(user, posts, comments, analytics);
} catch (error) {
  // Which operation failed? No idea.
  showError('Something went wrong');
}

// BETTER - targeted error handling
async function loadDashboard() {
  try {
    const user = await fetchUser();
    const posts = await fetchPosts();
    return { user, posts };
  } catch (error) {
    if (error instanceof NotFoundError) {
      redirectToLogin();
    } else {
      showError('Failed to load dashboard data');
    }
  }
}

Proper error propagation in function chains prevents silent failures:

// BAD - errors get lost
function processData(data) {
  try {
    return transform(data);
  } catch (error) {
    return null; // Caller has no idea something failed
  }
}

// GOOD - let errors propagate or wrap them
function processData(data) {
  try {
    return transform(data);
  } catch (error) {
    throw new ValidationError(
      `Failed to process data: ${error.message}`,
      'data'
    );
  }
}

Error handling isn’t about catching every possible exception—it’s about catching errors you can meaningfully handle and letting others propagate to appropriate handlers. Design your error handling strategy around your application’s architecture, use custom errors to encode domain knowledge, and always consider the async nature of modern JavaScript.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.