VS Vikesh Singh

Courses JavaScript Lesson 14

JAVASCRIPT · LESSON 14 OF 18

Error Handling: try/catch and Why It Matters

· 4 min read

The fetch examples in the last lesson used try/catch without much explanation — this lesson covers error handling properly.

The fetch examples in the last lesson used try/catch without much explanation — this lesson covers error handling properly, because real applications constantly deal with things that can go wrong: bad user input, failed network requests, unexpected data shapes, and plain bugs.

Catching Errors With Try/Catch

A try/catch block lets you run code that might fail, and handle the failure gracefully instead of letting it crash your whole script.

try {
  const result = 10 / "abc";
  console.log(result);
  const parsed = JSON.parse("{ not valid json");
  console.log(parsed);
} catch (error) {
  console.log("Something went wrong:", error.message);
}

10 / "abc" doesn't actually throw — it just produces NaN (Not a Number), which prints fine. But JSON.parse("{ not valid json") does throw, immediately stopping that try block and jumping to catch. Note this important behavior: as soon as any line throws inside a try block, every line after it in that same block is skipped — execution jumps straight to catch, which is why console.log(parsed) never runs here.

This highlights a real beginner trap: not every "wrong" result throws an error. NaN, undefined, and null are all valid, non-throwing results that often represent something going wrong logically, even though JavaScript doesn't treat them as errors. try/catch only catches things that actually throw — you still need your own checks (like the response.ok pattern from the fetch lesson) for problems that don't throw on their own.

Throwing Your Own Errors

You can throw your own errors deliberately, which is how you signal "this specific thing is wrong" from inside your own functions.

function getDiscount(code) {
  if (typeof code !== "string") {
    throw new TypeError("Discount code must be a string");
  }
  if (code === "") {
    throw new Error("Discount code cannot be empty");
  }
  return code === "SAVE10" ? 10 : 0;
}

try {
  console.log(getDiscount(""));
} catch (error) {
  console.log("Caught:", error.name, "-", error.message);
}

throw new Error("...") creates a proper Error object, with a message property and a name property (here, "Error" — JavaScript also has specific subclasses like TypeError and RangeError for particular kinds of problems). Throwing a real Error object, rather than throwing a plain string like throw "oops", is the right habit — Error objects carry a stack trace, which is enormously useful when debugging exactly where something went wrong, especially in a larger codebase.

Cleanup With Finally

The finally block runs no matter what — whether the try block succeeded or the catch block caught an error — and is meant for cleanup code that absolutely must run either way.

function processOrder(order) {
  console.log("Starting order processing");
  try {
    if (!order.total) {
      throw new Error("Order has no total");
    }
    console.log("Order processed:", order.total);
  } catch (error) {
    console.log("Order failed:", error.message);
  } finally {
    console.log("Cleaning up, logging completed");
  }
}

processOrder({ customer: "Tony Stark" });

Even though this order has no total and throws, "Cleaning up, logging completed" still prints, because finally runs regardless of outcome. This pattern matters in real code for things like closing a database connection, hiding a loading spinner, or re-enabling a disabled submit button — work that needs to happen whether the operation succeeded or failed.

Try/Catch With Async/Await

Async code needs the same care, and this is exactly why async/await paired naturally with try/catch in the last two lessons — a rejected Promise inside an awaited call behaves just like a thrown error for try/catch purposes.

async function loadData() {
  try {
    const response = await fetch("https://api.example.test/data");
    if (!response.ok) {
      throw new Error("Server responded with status " + response.status);
    }
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.log("Failed to load data:", error.message);
  }
}

Notice we throw our own Error here when response.ok is false — converting a non-throwing problem (a bad status code) into a proper throw, so our single catch block can handle both network failures and bad server responses uniformly, rather than needing separate handling logic for each case.

A practical tip: don't wrap every single line of your program in try/catch out of habit — that just hides real bugs and clutters your code. Reserve it for operations that can genuinely fail in ways you expect and want to handle: parsing JSON from an external source, network requests, or user-provided input you're about to use.

A final practical habit: when you catch an error, resist the temptation to catch it and do nothing, or just log it and move on silently. A caught error should usually result in some visible outcome, like a message shown to the user or a clear fallback behavior, otherwise you've only hidden the problem rather than handled it. Silently swallowed errors are one of the hardest kinds of bugs to track down later, precisely because nothing ever appeared to go wrong.

Give it a try: write a function that takes a user-provided string, tries to JSON.parse it, and returns a friendly fallback value with a console message if parsing fails, instead of letting the error crash anything. Then test it with both valid and deliberately broken JSON strings to see both paths run.