Hello readers 👋, welcome to the 21st blog in this JavaScript series!
In the last post, we unlocked the elegance of destructuring and how it simplifies working with arrays and objects. Today, we are going to talk about a topic that separates a fragile program from a robust one: error handling. No matter how carefully we write code, things go wrong. A network fails, a file is missing, an API returns an unexpected shape, or a user provides invalid input. Writing code that survives these problems gracefully is what good error handling is all about.
We will explore the tools JavaScript gives us: try, catch, and finally. We will also see how to throw our own custom errors and why this entire practice matters so much.
Let's get into it.
What errors are in JavaScript
Before we handle errors, let's understand what they are. In JavaScript, errors are objects created by the engine when something goes wrong during execution. You might have seen some of them:
- SyntaxError: The code cannot be parsed. For example, missing a closing bracket. These errors prevent the whole script from running.
-
ReferenceError: You try to use a variable that hasn't been declared. Like
console.log(x)whenxis not defined. -
TypeError: You do something with a value that isn't possible, like calling
undefinedas a function or reading a property onnull. -
RangeError: You use a number outside an allowed range, like calling
new Array(-1)or exceeding the call stack size. - URIError: Occurs when encodeURI or decodeURI gets invalid parameters (rare).
Runtime errors (everything except SyntaxError) happen while the code is running. If we don't catch them, they bubble up, stop the entire script, and leave the user with a broken experience. Our goal is to anticipate and handle these runtime errors so that the application continues to work or fails in a controlled, understandable way.
The try...catch statement
The primary tool for handling runtime errors is the try...catch statement. You wrap the suspicious code inside a try block, and if an error occurs, control jumps straight to the catch block. The program doesn't crash, and you get a chance to react.
A basic example:
try {
const user = JSON.parse('{ "name": "Satya" }'); // valid JSON
console.log(user.name); // Satya
} catch (error) {
console.log("Something went wrong:", error.message);
}
In this case, the JSON is valid, so the try block runs completely, and the catch block is ignored. Now let's see what happens with an error:
try {
const user = JSON.parse('invalid json string');
console.log(user.name); // this line never runs
} catch (error) {
console.log("Failed to parse JSON:", error.message);
}
console.log("Program continues...");
The output will be:
Failed to parse JSON: Unexpected token i in JSON at position 0
Program continues...
Notice that after the error, the remaining code inside the try block is skipped, but the code outside the try...catch continues normally. That is the beauty of error handling. Without try...catch, the script would have stopped dead at the parsing failure.
The catch block receives an error object that typically has two useful properties: message (a human-readable description) and name (the type of error, like "SyntaxError"). In modern environments, you also get stack which shows the call stack at the moment of the error, a lifesaver for debugging.
The finally block
There are times when you need to run some code regardless of whether an error occurred or not. Maybe you opened a database connection, started a loading spinner, or acquired some resource that must be released. The finally block is designed precisely for this: it executes after the try and catch blocks, no matter what.
Here is the flow:
try {
// attempted code
} catch (error) {
// runs only if an error occurred
} finally {
// runs ALWAYS, error or not
}
Let's see an example:
function processData(input) {
let connection = null;
try {
connection = openDatabaseConnection();
const result = connection.query(input); // dangerous
console.log(result);
} catch (error) {
console.log("Query failed:", error.message);
} finally {
if (connection) {
connection.close();
console.log("Connection closed.");
}
}
}
Even if query() throws an error, the finally block will still close the connection. Without finally, we would need to duplicate the cleanup code in both the try and catch branches, which is messy.
You can also omit the catch block entirely and use try...finally when you don't need to handle the error but still want to clean up. The error will still propagate after finally runs.
Throwing custom errors
So far we've been catching errors that JavaScript throws automatically. But you can also deliberately throw your own errors using the throw statement. This is incredibly useful when your application logic encounters a state that shouldn't be possible.
function divide(a, b) {
if (b === 0) {
throw new Error("Division by zero is not allowed.");
}
return a / b;
}
try {
const result = divide(10, 0);
console.log(result);
} catch (error) {
console.log("Error:", error.message);
}
Here we guard against division by zero. Throwing an error immediately stops the function and lets the caller handle it. The Error constructor is the standard way to create an error object with a custom message. It captures the stack trace automatically.
You can also create custom error types by extending the Error class. This is helpful when you want to differentiate your application errors from standard ones.
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = "ValidationError";
}
}
function validateAge(age) {
if (age < 0) {
throw new ValidationError("Age cannot be negative.");
}
return true;
}
try {
validateAge(-1);
} catch (error) {
if (error instanceof ValidationError) {
console.log("Validation failed:", error.message);
} else {
console.log("Unknown error:", error);
}
}
Now you can check the error type with instanceof and react differently. Custom errors give you more expressive power and help to separate expected problems from unexpected bugs.
Why error handling matters
It might be tempting to skip error handling and hope everything just works, but that approach breaks quickly. Here are the key reasons why proper error handling is essential:
Graceful degradation: When a part of the UI fails to load or an API call times out, you can show a friendly message instead of a blank screen or a frozen page. The user experience remains intact.
Debugging superpower: Well-placed try...catch blocks combined with logging or error reporting services let you know exactly where and why failures happen in production. The error object's stack property is a map to the root cause.
Preventing data corruption: If an operation involves multiple steps (like transferring money), an unhandled error mid-process could leave the data in an inconsistent state. Catching errors lets you roll back or recover.
Separation of concerns: Your business logic shouldn't be cluttered with defensive checks everywhere; instead, you can centralize error handling at strategic boundaries. This keeps the code cleaner and easier to test.
Security: Throwing sensitive details to the console or UI can leak information. A catch block can log securely while showing a generic error to the user.
Visualizing the error handling flow
When you write a try...catch...finally, the engine follows this order:
- Execute the
tryblock. - If no error occurs, skip
catch. - If an error occurs, skip the rest of
tryand runcatchwith the error object. - In either case, run
finallyblock afterwards.
It's like a safety net under a trapeze act. The trapeze artist (your code) attempts the routine; if he slips, the net catches him, and someone always cleans up the area (finally). The show goes on.
try { ... }
|
[error?]---- yes ----> catch (error) { ... }
| |
no |
| |
finally { ... } <-------------
|
continue with rest of script
Conclusion
Error handling is not just about avoiding crashes; it's about building resilient, user-friendly applications that behave predictably even when the unexpected happens. The try, catch, and finally trio, combined with custom errors, gives you a powerful and expressive safety harness.
Let's recap what we covered:
- JavaScript errors are objects created at runtime, and unhandled errors stop the program.
-
try...catchallows you to intercept runtime errors and respond without breaking execution. - The
finallyblock always runs, making it perfect for cleanup tasks like closing connections. -
throw new Error()lets you create custom exceptions and force callers to handle them. - Custom error classes extending
Errorhelp differentiate error types for better handling. - Good error handling improves debugging, user experience, and data integrity.
With these concepts in your mental toolkit, you can write code that not only works under ideal conditions but also holds up gracefully when things go sideways. That's the mark of a thoughtful developer.
Hope you found this helpful! If you spot any mistakes or have suggestions, let me know. You can find me on LinkedIn and X, where I post more about web development.
Top comments (0)