Skip to content

Error Handling with Try-Catch

beginner14 min read

Errors Are Not Failures — They're Information

Every program encounters errors. Files go missing. Networks drop. Users type unexpected things. The difference between a fragile script and production-ready code isn't the absence of errors — it's how you handle them.

JavaScript gives you a structured way to deal with errors: try/catch/finally. Master this, and you'll stop writing code that silently breaks in production.

Mental Model

Think of try/catch as a safety net under a tightrope walker. The tightrope walker (your code) attempts the walk inside try. If they slip (an error is thrown), the safety net (catch) catches them instead of letting them crash to the ground. And finally is the cleanup crew that comes out no matter what — whether the walker made it across or fell into the net.

Three Kinds of Errors

Before we catch errors, we need to understand the three fundamentally different types:

Runtime Errors

These happen while code is executing. The syntax is valid, but something goes wrong at runtime:

const user = null;
console.log(user.name); // TypeError: Cannot read properties of null

Runtime errors are the ones you catch with try/catch. They're the most common in production.

Syntax Errors

These happen at parse time, before any code runs:

// This never executes — the engine rejects it during parsing
function broken( {
  console.log("hello");
}
// SyntaxError: Unexpected token '{'
Common Trap

You cannot catch syntax errors in the same script with try/catch. By the time try would run, the parser has already rejected the entire script. The only way to catch a syntax error is if it's in code evaluated at runtime — like eval() or new Function().

Logic Errors

These are the sneakiest. The code runs without throwing, but produces the wrong result:

function calculateDiscount(price, percent) {
  return price * percent; // Bug: should be price * (percent / 100)
}

calculateDiscount(100, 20); // Returns 2000, not 20

No error is thrown. No crash. Just wrong output. try/catch can't help here — you need tests, code reviews, and careful thinking.

Quiz
Which type of error can you catch with try/catch in the same script?

The try/catch/finally Flow

Here's the basic structure:

try {
  // Code that might throw
  const data = JSON.parse(userInput);
  console.log(data.name);
} catch (error) {
  // Runs ONLY if try threw an error
  console.log("Invalid JSON:", error.message);
} finally {
  // Runs ALWAYS — error or not
  console.log("Parsing attempt complete");
}

The execution flow follows strict rules:

  1. Code inside try runs line by line
  2. If an error is thrown, execution immediately jumps to catch — remaining try lines are skipped
  3. If no error is thrown, catch is skipped entirely
  4. finally runs no matter what — after try completes normally, or after catch handles an error
try {
  console.log("Step 1"); // Runs
  console.log("Step 2"); // Runs
  JSON.parse("not valid json"); // Throws — jumps to catch
  console.log("Step 3"); // SKIPPED — never runs
} catch (error) {
  console.log("Caught:", error.message);
} finally {
  console.log("Done");
}
// Output:
// "Step 1"
// "Step 2"
// "Caught: Unexpected token 'o', "not valid json" is not valid JSON"
// "Done"
Execution Trace
Enter try block
Execution begins inside try
No error yet
console.log('Step 1')
Prints 'Step 1'
Normal execution
console.log('Step 2')
Prints 'Step 2'
Still no error
JSON.parse('not valid json')
Throws SyntaxError
Execution jumps to catch immediately
console.log('Step 3')
SKIPPED
Never executes — error already thrown
Enter catch block
error = SyntaxError with message about invalid JSON
Catch receives the thrown error object
console.log('Caught:', ...)
Prints the error message
Error handled
Enter finally block
Prints 'Done'
Always runs — regardless of error

finally Always Runs — Even With return

This surprises a lot of people. finally runs even if try or catch contains a return:

function riskyOperation() {
  try {
    return "success";
  } finally {
    console.log("Cleanup runs before the return!");
  }
}

riskyOperation();
// Logs: "Cleanup runs before the return!"
// Returns: "success"

The return value is held, finally executes, then the function actually returns. This makes finally perfect for cleanup — closing connections, clearing timers, resetting state.

Quiz
What happens when try has a return statement and finally also has a return statement?

You Can Use try/finally Without catch

Sometimes you need guaranteed cleanup but don't want to handle the error yourself — you want it to propagate up:

function readFile() {
  const handle = openFile("data.txt");
  try {
    return processFile(handle);
  } finally {
    handle.close(); // Always close, even if processFile throws
  }
}

If processFile throws, the error propagates to the caller, but handle.close() still runs first.

The Error Object

When an error is thrown, you get an object with useful properties:

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

The Three Core Properties

PropertyWhat It Tells You
nameThe type of error (TypeError, ReferenceError, etc.)
messageA human-readable description of what went wrong
stackThe call stack at the time of the error — invaluable for debugging

The cause Property (ES2022)

Modern JavaScript lets you chain errors with cause. This is huge for debugging because you preserve the original error while adding context:

async function fetchUserProfile(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`);
    return await response.json();
  } catch (error) {
    throw new Error(`Failed to load profile for user ${userId}`, {
      cause: error,
    });
  }
}

Now the caller sees "Failed to load profile for user 42" and can inspect error.cause to find the original network error. Without cause, you lose the original error context.

The cause property was added in ES2022 (all major browsers support it since 2022). Before cause, developers would manually attach the original error: newError.originalError = error. The standardized cause is better because debugging tools and error-reporting services like Sentry now recognize it automatically and display the full error chain.

Built-in Error Types

JavaScript has several built-in error constructors, each for a specific category of problem:

TypeError

The most common. Thrown when a value is not the type you expected:

null.toString();          // TypeError: Cannot read properties of null
undefined();              // TypeError: undefined is not a function
const x = 42; x();       // TypeError: x is not a function

ReferenceError

Thrown when you reference a variable that doesn't exist:

console.log(nonExistent); // ReferenceError: nonExistent is not defined

SyntaxError (at runtime)

Thrown by JSON.parse, eval, or new Function when they encounter invalid syntax:

JSON.parse("{ bad json }"); // SyntaxError: Expected property name

RangeError

Thrown when a numeric value is outside its allowed range:

new Array(-1);                   // RangeError: Invalid array length
(42).toFixed(200);               // RangeError: toFixed() digits argument must be between 0 and 100
function f() { f(); } f();       // RangeError: Maximum call stack size exceeded

URIError

Rare. Thrown when URI-handling functions get malformed input:

decodeURIComponent("%");  // URIError: URI malformed
Quiz
What error type is thrown when you call a null value as a function?

Throwing Your Own Errors

You're not limited to catching errors — you can throw them too. This is how you enforce rules in your code:

function divide(a, b) {
  if (b === 0) {
    throw new Error("Division by zero");
  }
  return a / b;
}

You can throw any of the built-in error types:

function setAge(age) {
  if (typeof age !== "number") {
    throw new TypeError("Age must be a number");
  }
  if (age < 0 || age > 150) {
    throw new RangeError("Age must be between 0 and 150");
  }
  return age;
}

When to Throw

Throw when your function receives input it cannot work with. If you can handle bad input gracefully (return a default, coerce the type), do that instead. Throw when continuing would produce incorrect or dangerous results.

Custom Error Classes

For larger applications, custom error classes let you distinguish between different failure modes:

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

class NotFoundError extends Error {
  constructor(resource) {
    super(`${resource} not found`);
    this.name = "NotFoundError";
  }
}

function updateUser(id, data) {
  const user = findUser(id);
  if (!user) throw new NotFoundError(`User ${id}`);
  if (!data.email) throw new ValidationError("email", "Email is required");
  // ...
}

Now you can handle different errors differently:

try {
  updateUser(42, { name: "Alice" });
} catch (error) {
  if (error instanceof ValidationError) {
    showFieldError(error.field, error.message);
  } else if (error instanceof NotFoundError) {
    show404Page();
  } else {
    throw error; // Unknown error — re-throw
  }
}
Quiz
What happens if you throw a plain string instead of an Error object?

Error Handling Patterns

Pattern 1: Guard Clauses (Fail Fast)

Check for problems at the top of your function and bail out early. This keeps your main logic clean and un-indented:

function processOrder(order) {
  if (!order) throw new Error("Order is required");
  if (!order.items?.length) throw new Error("Order must have items");
  if (!order.customer) throw new Error("Order must have a customer");

  // Main logic — only runs if all guards pass
  const total = order.items.reduce((sum, item) => sum + item.price, 0);
  return { ...order, total };
}

Pattern 2: Graceful Degradation

Instead of crashing, provide a fallback experience:

function getUserPreferences(userId) {
  try {
    const saved = localStorage.getItem(`prefs_${userId}`);
    return JSON.parse(saved);
  } catch {
    return { theme: "light", fontSize: 16 }; // Safe defaults
  }
}

Pattern 3: Error Boundaries (Selective Catching)

Don't catch everything at the top level. Catch at the level where you can actually do something useful about the error:

function loadDashboard() {
  const user = getUser(); // Let this throw — if no user, dashboard is pointless

  let notifications;
  try {
    notifications = fetchNotifications(user.id);
  } catch {
    notifications = []; // Non-critical — show empty list
  }

  let analytics;
  try {
    analytics = fetchAnalytics(user.id);
  } catch {
    analytics = null; // Non-critical — hide analytics section
  }

  return { user, notifications, analytics };
}

Critical operations throw. Non-critical operations degrade gracefully. The dashboard still loads even if notifications or analytics fail.

The Cardinal Sin: Swallowing Errors

This is the single most common mistake in error handling, and it will ruin your debugging experience:

// NEVER do this
try {
  doSomethingImportant();
} catch (error) {
  // empty — error silently disappears
}

When you swallow an error, the code continues as if nothing happened. Data silently corrupts. Features silently break. And when you finally notice something is wrong, the original error — the one that would have told you exactly what happened — is gone forever.

At minimum, log the error:

try {
  doSomethingImportant();
} catch (error) {
  console.error("Operation failed:", error);
}
What developers doWhat they should do
catch (error) { } — empty catch block, error is silently swallowed
Swallowed errors make bugs invisible. When something breaks later, you have zero clues about the root cause because the original error was discarded.
catch (error) { console.error('Context:', error); } — always log or re-throw
catch (error) { return null; } — returning null without logging hides the failure
Returning a fallback is fine for graceful degradation, but you still need visibility into when and why fallbacks are being used. Without logging, you can't distinguish 'working correctly' from 'constantly failing silently.'
catch (error) { console.error(error); return fallbackValue; } — log first, then degrade
Wrapping your entire function body in a single try/catch
A giant try block hides which operation actually failed. It also catches errors you didn't expect, potentially masking bugs in your own logic that should crash loudly during development.
Wrap only the specific operation that might fail, keep the rest outside try

try/catch With async/await — A Brief Preview

try/catch works naturally with async/await, which is one of the biggest reasons async/await became popular over raw .then() chains:

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

The await keyword "unwraps" rejected promises and throws the rejection reason as a regular error, making it catchable by try/catch. Without async/await, you'd need .catch() on every promise chain.

Common Trap

A common mistake is forgetting that fetch does not throw on HTTP error responses (404, 500, etc.). It only throws on network failures (DNS errors, no internet). You need to check response.ok yourself and throw manually for HTTP errors. This trips up even experienced developers.

Quiz
What does try/catch NOT catch in this async function?

Putting It All Together

Key Rules
  1. 1try/catch only catches runtime errors — not syntax errors in the same script, not logic errors
  2. 2finally always runs: after normal completion, after catch, even after return statements
  3. 3Always throw Error objects (not strings or numbers) to preserve the stack trace
  4. 4Never swallow errors with empty catch blocks — at minimum, log them
  5. 5Catch at the level where you can do something useful — don't catch everything at the top
  6. 6Use guard clauses (fail-fast) to validate inputs before the main logic runs
  7. 7Use the cause property (ES2022) to chain errors and preserve the original context
Challenge:

Try to solve it before peeking at the answer.

Write a safeDivide function that takes two numbers and returns their division result. It should: throw a TypeError if either argument is not a number, throw a RangeError if the divisor is zero, and work correctly for all valid inputs.