JavaScript error handling try catch throw custom errors guide

JavaScript Error Handling: try, catch, finally, throw, and Custom Errors

JavaScript error handling is what separates production-ready code from code that breaks silently (or loudly) at 3 AM. The try...catch statement gives you control over runtime errors — you decide what happens when things go wrong instead of letting your entire application crash. This guide covers everything from basic try/catch to custom error classes, async error patterns, and the debugging strategies professionals use daily.

Before diving in, make sure you’re comfortable with JavaScript conditionals and loops since error handling builds on both.

Why Error Handling Matters

Errors are inevitable. APIs go down, users enter unexpected input, network connections drop, and files go missing. Without proper error handling, a single uncaught exception can crash your Node.js server or leave your web app in a broken state with a white screen. Error handling lets you catch these failures gracefully, show meaningful messages to users, log details for debugging, and keep your application running.

JavaScript errors come in two flavors: compile-time errors (syntax errors caught before execution) and runtime errors (exceptions thrown during execution). The try...catch statement handles runtime errors.

try…catch Basics

The try block contains code that might throw an error. The catch block runs only if an error occurs:

try {
  const data = JSON.parse('{"valid": true}');
  console.log(data.valid); // true
} catch (error) {
  console.error('Parse failed:', error.message);
}
try {
  const data = JSON.parse('not valid json');
  console.log(data); // never reaches here
} catch (error) {
  console.error('Parse failed:', error.message);
  // "Parse failed: Unexpected token 'o' at position 1"
}

Key behavior: if no error occurs, the catch block is skipped entirely. If an error occurs, execution jumps immediately to catch — any code after the throwing line in try doesn’t run.

Optional Catch Binding (ES2019)

If you don’t need the error object, you can omit it:

try {
  someRiskyOperation();
} catch {
  console.log('Something went wrong');
}

The Error Object

When an error is caught, the catch block receives an Error object with these properties:

try {
  undefinedFunction();
} catch (error) {
  console.log(error.name);    // "ReferenceError"
  console.log(error.message); // "undefinedFunction is not defined"
  console.log(error.stack);   // Full stack trace with file/line info
}

The stack property is the most useful for debugging — it shows exactly where the error originated and the call chain that led to it.

The finally Block

The finally block runs regardless of whether an error occurred. It’s guaranteed to execute even if you return from inside try or catch:

function readFile(path) {
  let handle;
  try {
    handle = openFile(path);
    return handle.read();
  } catch (error) {
    console.error('Failed to read:', error.message);
    return null;
  } finally {
    // Always runs — perfect for cleanup
    if (handle) handle.close();
    console.log('Cleanup complete');
  }
}

Common uses for finally: closing database connections, releasing file handles, hiding loading spinners, resetting state, and stopping timers.

try…finally (Without catch)

function riskyOperation() {
  try {
    // Do something
    return computeResult();
  } finally {
    cleanup(); // runs even before return
  }
}

Throwing Your Own Errors

Use throw to create your own errors when input validation fails or business logic is violated:

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 {
  divide(10, 0);
} catch (error) {
  console.log(error.name);    // "RangeError"
  console.log(error.message); // "Cannot divide by zero"
}

You can throw any value, but always throw Error objects (or subclasses) to get a proper stack trace:

// Bad — no stack trace
throw 'Something broke';
throw 42;

// Good — has stack trace
throw new Error('Something broke');
throw new TypeError('Expected a string');

Custom Error Classes

For larger applications, create custom error classes to categorize errors and add metadata:

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;
  }
}

// Usage
function getUser(id) {
  const user = database.find(u => u.id === id);
  if (!user) {
    throw new NotFoundError('User', id);
  }
  return user;
}

try {
  getUser(999);
} catch (error) {
  if (error instanceof NotFoundError) {
    res.status(error.statusCode).json({ error: error.message });
  } else {
    res.status(500).json({ error: 'Internal server error' });
  }
}

Built-in Error Types

JavaScript has seven built-in error types, each signaling a specific category of problem:

Error — the base class. Use it for general errors. TypeError — thrown when a value isn’t the expected type, like calling null.toString(). ReferenceError — accessing a variable that doesn’t exist. SyntaxError — invalid code structure (usually caught at parse time, but also from JSON.parse and eval). RangeError — a number is outside its valid range, like new Array(-1). URIError — malformed URI functions like decodeURIComponent('%'). EvalError — historically from eval() misuse; rarely encountered today.

try {
  null.toString();
} catch (e) {
  console.log(e instanceof TypeError); // true
}

try {
  decodeURIComponent('%');
} catch (e) {
  console.log(e instanceof URIError); // true
}

Nested try…catch and Re-throwing

Sometimes you catch an error, handle what you can, and re-throw it for higher-level handling:

function processData(raw) {
  try {
    const data = JSON.parse(raw);
    return transform(data);
  } catch (error) {
    if (error instanceof SyntaxError) {
      // Handle JSON parse errors locally
      console.error('Invalid JSON input');
      return null;
    }
    // Re-throw unexpected errors
    throw error;
  }
}

The re-throwing pattern keeps error handling at the right level — handle what you understand, pass up what you don’t.

Async Error Handling

Promises

Promises use .catch() instead of try...catch:

fetch('https://api.example.com/data')
  .then(response => {
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();
  })
  .then(data => console.log(data))
  .catch(error => console.error('Fetch failed:', error.message));

Async/Await

With async/await, you use regular try...catch — the most readable approach:

async function fetchUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    console.error('Failed to fetch user:', error.message);
    return null;
  }
}

Handling Multiple Async Operations

async function fetchAll(urls) {
  const results = await Promise.allSettled(
    urls.map(url => fetch(url).then(r => r.json()))
  );
  
  for (const result of results) {
    if (result.status === 'fulfilled') {
      console.log('Success:', result.value);
    } else {
      console.error('Failed:', result.reason.message);
    }
  }
}

Promise.allSettled is ideal when you want all results regardless of individual failures, unlike Promise.all which rejects on the first failure.

Global Error Handlers

Browser

// Catch uncaught errors
window.onerror = (message, source, line, col, error) => {
  logToServer({ message, source, line, col, stack: error?.stack });
};

// Catch unhandled promise rejections
window.onunhandledrejection = (event) => {
  console.error('Unhandled rejection:', event.reason);
  event.preventDefault();
};

Node.js

process.on('uncaughtException', (error) => {
  console.error('Uncaught:', error);
  process.exit(1); // Exit after logging
});

process.on('unhandledRejection', (reason) => {
  console.error('Unhandled rejection:', reason);
});

Global handlers are your last line of defense — they should log the error and, in Node.js, typically exit the process since the application state may be corrupted.

Error Handling Patterns

Result Pattern (Go-style)

async function safeAsync(fn) {
  try {
    const data = await fn();
    return [data, null];
  } catch (error) {
    return [null, error];
  }
}

// Usage
const [user, error] = await safeAsync(() => fetchUser(123));
if (error) {
  console.error(error.message);
} else {
  console.log(user);
}

Retry Pattern

async function retry(fn, maxAttempts = 3, delay = 1000) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxAttempts) throw error;
      console.log(`Attempt ${attempt} failed, retrying in ${delay}ms...`);
      await new Promise(r => setTimeout(r, delay));
      delay *= 2; // Exponential backoff
    }
  }
}

const data = await retry(() => fetch('/api/flaky-endpoint'));

Error Boundary (React concept)

function withErrorBoundary(fn, fallback) {
  try {
    return fn();
  } catch (error) {
    console.error(error);
    return fallback;
  }
}

const result = withErrorBoundary(
  () => riskyComputation(),
  'default value'
);

Debugging Tips

Always log the full error object, not just the message — the stack trace is critical. Use console.error instead of console.log for errors so they appear in the error panel of DevTools. Add context to errors by wrapping and re-throwing with additional info. Use structured logging in production (JSON format with timestamps, request IDs, and user context).

// Add context when re-throwing
try {
  await processOrder(orderId);
} catch (error) {
  throw new Error(
    `Failed to process order ${orderId}: ${error.message}`,
    { cause: error } // ES2022 — preserves original error
  );
}

Best Practices

Catch specific errors using instanceof — don’t catch everything blindly. Never swallow errors silently (empty catch blocks are a code smell). Use finally for cleanup, not for business logic. Throw errors early, catch them late. Prefer async/await with try...catch over promise chains for readability. Create custom error classes for domain-specific errors in larger applications. Always include enough context in error messages for debugging.

Now that you can handle errors gracefully, it’s time to learn about functions — the building blocks that make your code reusable and organized. Continue to Functions & Arrow Functions.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *