Skip to main content

Command Palette

Search for a command to run...

Error Handling in JavaScript: try, catch, finally, and Throwing Custom Errors

Published
12 min read
Error Handling in JavaScript: try, catch, finally, and Throwing Custom Errors

Every JavaScript program will encounter errors. The question is not whether errors happen — it is whether your code handles them deliberately or lets them crash everything.

This article covers the full error handling toolkit: what errors are, how try, catch, and finally work, how to throw your own errors, and why treating error handling as a first-class concern makes your code more reliable and easier to debug.


What Errors Are in JavaScript

An error in JavaScript is an object. When something goes wrong — a variable that does not exist, a function called on the wrong type, division by zero — JavaScript creates an Error object and throws it. If nothing catches it, the program crashes and the error appears in the console.

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

null.toString();
// TypeError: Cannot read properties of null (reading 'toString')

decodeURIComponent("%");
// URIError: URI malformed

Each error has two important properties:

  • name — the type of error ("ReferenceError", "TypeError", etc.)

  • message — a human-readable description of what went wrong

try {
  null.toString();
} catch (err) {
  console.log(err.name);    // TypeError
  console.log(err.message); // Cannot read properties of null (reading 'toString')
}

Built-in error types

JavaScript has several built-in error constructors, each representing a different category of problem:

Error type When it occurs
Error General-purpose base error
ReferenceError Accessing an undeclared variable
TypeError Operating on a value of the wrong type
SyntaxError Malformed JavaScript (usually at parse time)
RangeError Value outside an allowable range (e.g., new Array(-1))
URIError Malformed URI passed to decodeURIComponent
EvalError Problems related to eval()

In practice, TypeError and ReferenceError are the ones you will encounter most. Knowing the type of an error is the first clue about where and why it happened.


The try and catch Blocks

The try...catch statement is how you intercept errors and handle them gracefully instead of letting them crash your program.

try {
  // code that might throw
} catch (error) {
  // code that runs if something goes wrong
}

JavaScript executes the code inside try. If any statement throws an error, execution immediately jumps to the catch block. The error object is passed to the catch block as the parameter you name (conventionally error or err).

A simple example

function parseJSON(text) {
  try {
    const data = JSON.parse(text);
    console.log("Parsed successfully:", data);
    return data;
  } catch (error) {
    console.log("Invalid JSON:", error.message);
    return null;
  }
}

parseJSON('{"name": "Priya"}'); // Parsed successfully: { name: "Priya" }
parseJSON("not valid json");    // Invalid JSON: Unexpected token 'o', "not valid json" is not valid JSON

Without try...catch, the second call would crash. With it, the error is caught, logged, and the function returns a sensible fallback value. The rest of your program continues running.

Execution flow inside try...catch

try {
  console.log("1. Before error");
  null.toString();              // throws TypeError
  console.log("2. After error"); // never runs
} catch (error) {
  console.log("3. In catch:", error.name); // runs
}

console.log("4. After try...catch"); // runs

Output:

1. Before error
3. In catch: TypeError
4. After try...catch

Once an error is thrown, execution inside try stops immediately. Lines after the throw are skipped. Control transfers to catch. After catch completes, execution continues normally after the entire try...catch block.

Catching specific error types

You can inspect the caught error to respond differently based on what went wrong:

function divide(a, b) {
  try {
    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;
  } catch (error) {
    if (error instanceof TypeError) {
      console.log("Type problem:", error.message);
    } else if (error instanceof RangeError) {
      console.log("Range problem:", error.message);
    } else {
      console.log("Unexpected error:", error.message);
    }
    return null;
  }
}

divide(10, 2);       // 5
divide(10, 0);       // Range problem: Cannot divide by zero
divide("10", 2);     // Type problem: Both arguments must be numbers

instanceof checks whether the error is an instance of a specific error constructor. This pattern lets a single catch block handle multiple error types differently — without needing separate try...catch for each.


The finally Block

The finally block runs after try and catch, regardless of what happened. It runs whether an error was thrown, whether it was caught, and whether the try block completed successfully. It even runs when a return statement appears inside try or catch.

try {
  // might throw
} catch (error) {
  // handles the error
} finally {
  // always runs
}

A file-reading analogy

finally maps directly to the "clean up after yourself" principle. If you open a database connection or start a loading spinner, you need to close the connection or stop the spinner when the operation is done — whether it succeeded or failed.

function fetchUserData(userId) {
  showLoadingSpinner();

  try {
    const data = getFromDatabase(userId);
    displayUser(data);
    return data;
  } catch (error) {
    showErrorMessage("Could not load user data.");
    console.error(error);
  } finally {
    hideLoadingSpinner(); // always runs, no matter what
  }
}

If getFromDatabase throws, the error is caught and displayed. Either way, hideLoadingSpinner runs. Without finally, you would need to call hideLoadingSpinner in both try and catch — duplicating logic and risking a missed call if the code changes later.

finally with return statements

finally runs even when there is a return inside try or catch. If finally itself contains a return, it overrides the earlier return:

function example() {
  try {
    return "from try";
  } finally {
    console.log("finally ran");
    // no return here — "from try" is preserved
  }
}

console.log(example());
// finally ran
// from try
function example() {
  try {
    return "from try";
  } finally {
    return "from finally"; // overrides the return in try
  }
}

console.log(example()); // "from finally"

The second case is a behaviour to be aware of, but in practice, putting a return in finally is unusual and usually a mistake. Use finally for cleanup, not for controlling return values.

Execution order summary

function demo(shouldThrow) {
  try {
    console.log("try: start");
    if (shouldThrow) throw new Error("oops");
    console.log("try: end");
    return "success";
  } catch (err) {
    console.log("catch:", err.message);
    return "recovered";
  } finally {
    console.log("finally: always");
  }
}

console.log(demo(false));
// try: start
// try: end
// finally: always
// success

console.log(demo(true));
// try: start
// catch: oops
// finally: always
// recovered

finally always gets the last word on cleanup, but the return value from try or catch is what the function returns — unless finally returns something itself.


Throwing Custom Errors

You are not limited to errors that JavaScript throws automatically. You can throw your own errors using the throw statement.

throw new Error("Something went wrong");

throw can technically accept any value — a string, a number, an object — but throwing an Error object is the right approach. It gives you a message, a stack trace, and a name, all of which are essential for debugging.

Throwing with built-in error types

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

setAge(25);     // 25
setAge("old");  // TypeError: Expected a number, got string
setAge(-5);     // RangeError: Age must be between 0 and 150, got -5

Using the specific error constructor rather than the generic Error gives callers useful information about the nature of the problem.

Creating custom error classes

For application-specific errors, you can extend the built-in Error class to create your own error types. This lets callers use instanceof to distinguish your errors from generic ones.

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

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

Using them:

function validateUser(user) {
  if (!user.name) {
    throw new ValidationError("Name is required", "name");
  }
  if (!user.email.includes("@")) {
    throw new ValidationError("Invalid email format", "email");
  }
}

function loadUser(id) {
  try {
    validateUser({ name: "", email: "priya@example.com" });
  } catch (error) {
    if (error instanceof ValidationError) {
      console.log(`Validation failed on field '\({error.field}': \){error.message}`);
    } else {
      throw error; // re-throw errors we don't know how to handle
    }
  }
}

loadUser(1);
// Validation failed on field 'name': Name is required

Notice the re-throw pattern: throw error inside catch. When you catch an error and realise you cannot handle it at this level, re-throwing it passes the problem up the call stack to a handler that can deal with it. This avoids silently swallowing errors that should surface.

The re-throw pattern

function processPayment(amount) {
  try {
    chargeCard(amount);
  } catch (error) {
    if (error instanceof NetworkError) {
      console.log("Network issue, will retry later");
      scheduleRetry();
    } else {
      throw error; // not a network error — not our responsibility
    }
  }
}

Only catch what you can handle. Let the rest propagate.


Why Error Handling Matters

Graceful failure over silent failure

Unhandled errors crash programs. But there is something worse than a crash: silent failure — code that encounters an error, does nothing about it, and continues with corrupted state.

// No error handling — silent failure
function getUserCity(user) {
  return user.address.city; // crashes if address is null
}

// With error handling — graceful failure
function getUserCity(user) {
  try {
    return user.address.city;
  } catch {
    return "Unknown";
  }
}

"Unknown" is a better outcome than a crash. The user sees a default; the application keeps running.

Better debugging

Error objects carry a stack trace — a list of function calls that led to the error. When you catch and log errors properly, debugging becomes straightforward:

try {
  processOrder(orderId);
} catch (error) {
  console.error("Order processing failed:", {
    message: error.message,
    stack: error.stack,
    orderId
  });
}

Logging error.stack shows exactly which line threw, which function called that, and how far up the chain the problem originated. Without it, you are guessing.

User experience

From a user's perspective, a crash is always worse than an error message. When your code handles errors and provides feedback, users understand what happened and what to do next:

async function submitForm(data) {
  try {
    await fetch("/api/submit", {
      method: "POST",
      body: JSON.stringify(data)
    });
    showSuccess("Form submitted successfully!");
  } catch (error) {
    if (error instanceof NetworkError) {
      showError("No internet connection. Please try again.");
    } else {
      showError("Something went wrong. Our team has been notified.");
      reportToMonitoring(error);
    }
  }
}

The user sees a meaningful message either way. The engineering team sees the error in monitoring. Nothing crashes silently.

Defensive programming

Good error handling is part of defensive programming — writing code that anticipates what can go wrong rather than assuming the happy path:

function parseConfig(raw) {
  if (!raw) {
    throw new ValidationError("Config cannot be empty", "config");
  }

  let parsed;
  try {
    parsed = JSON.parse(raw);
  } catch {
    throw new ValidationError("Config must be valid JSON", "config");
  }

  if (!parsed.apiKey) {
    throw new ValidationError("apiKey is required in config", "apiKey");
  }

  return parsed;
}

This function is explicit about what it requires and what it rejects. Every failure mode produces an informative error. Code that calls this function knows exactly what went wrong and can respond appropriately.


Error Handling with Async Code

try...catch works with async/await just as it does with synchronous code:

async function fetchUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);

    if (!response.ok) {
      throw new NetworkError(`Request failed`, response.status);
    }

    const user = await response.json();
    return user;
  } catch (error) {
    if (error instanceof NetworkError) {
      console.log(`HTTP \({error.statusCode}: could not fetch user \){id}`);
    } else {
      console.error("Unexpected error:", error);
    }
    return null;
  }
}

Without async/await, Promises use .catch():

fetch("/api/users/1")
  .then(response => response.json())
  .then(user => console.log(user))
  .catch(error => console.error("Fetch failed:", error));

Both patterns are valid. async/await with try...catch tends to be more readable for complex sequences of async operations.


Common Mistakes in Error Handling

Catching and doing nothing:

// Bad — swallows the error silently
try {
  riskyOperation();
} catch (error) {
  // nothing
}

Empty catch blocks are the // TODO: handle this of error handling. If you are not ready to handle an error, at minimum log it.

Catching errors you cannot handle:

// Catching too broadly
try {
  // 50 lines of code
} catch (error) {
  console.log("Something went wrong");
}

A try block that wraps too much code makes it hard to know which operation failed. Narrow try...catch to the specific operations that can fail.

Using throw without Error objects:

// Bad — string throws lose the stack trace
throw "something went wrong";

// Good — proper Error object
throw new Error("something went wrong");

String throws do not have .stack, .name, or .message. They are harder to log, harder to inspect, and harder to catch specifically.


Wrapping Up

Error handling in JavaScript is not a defensive afterthought — it is part of the design of reliable software. The tools are straightforward:

  • try wraps code that might fail

  • catch handles the failure

  • finally runs cleanup that must happen either way

  • throw raises errors with enough information to diagnose them

  • Custom error classes make errors specific and catchable by type

The mindset matters as much as the syntax. Ask what can go wrong at each step. Provide a sensible fallback or a clear error message. Log enough context to debug a problem you cannot reproduce. Re-throw errors you cannot handle.

Code that handles errors deliberately is code that tells you the truth when something breaks — which is always better than code that silently does the wrong thing or crashes without explanation.

More from this blog