JavaScript is a lightweight, interpreted, object-oriented language with first-class functions, and is best known as the scripting language for Web pages, but it’s used in many non-browser environments as well. It is a prototype-based, multi-paradigm scripting language that is dynamic, and supports object-oriented, imperative, and functional programming styles.JavaScript runs on the client side of the web, which can be used to design / program how the web pages behave on the occurrence of an event. JavaScript is an easy to learn and also powerful scripting language, widely used for controlling web page behavior.Contrary to popular misconception, JavaScript is not “Interpreted Java”. In a nutshell, JavaScript is a dynamic scripting language supporting prototype based object construction. The basic syntax is intentionally similar to both Java and C++ to reduce the number of new concepts required to learn the language. Language constructs, such as if statements, for and while loops, and switch and try … catch blocks function the same as in these languages .
JavaScript can function as both a procedural and an object oriented language. Objects are created programmatically in JavaScript, by attaching methods and properties to otherwise empty objects at run time, as opposed to the syntactic class definitions common in compiled languages like C++ and Java. Once an object has been constructed it can be used as a prototype for creating similar objects.JavaScript’s dynamic capabilities include runtime object construction, variable parameter lists, function variables, dynamic script creation , object introspection , and source code recovery.
It is very hard to write a clean & error free code. One small mistake can lead to a bigger issue. Error handling plays a vital role to reduce the number of bugs. If you handle error elegant way, it will save a lot of time in the future. So the bigger question is how you should handle the error.
Let’s take this sample code as an example:
const express = require("express"); const app = express(); app.get("/", (_, res) => res.end("Cool")); app.listen(8000);
The above code is a sample code written in nodejs on an express framework. This code trying to run a server at port 80. What if we know that port 8000 is already taken by some other app and we try to run the above code. How do we know what will happen? Will it run or break with some error? This is very unclear while seeing this code. Even you want to handle an error, you may have to read the documentation. But don’t worry, just like any natural language. The programming language has some grammar. Even there is no standard specification in ECMA standard for Error handling. But the Javascript community follows certain coding guidelines.
Types of Errors
Since JavaScript has a different flavor of the compiler and most of the have written and maintained by a different organization. Except for SyntaxError, There is no very definite or consistent distribution among the type of error. Even the message varies compiler to compiler. However, You can find a list fo the errors. Since Javascript is a dynamic language, most of the errors are runtime errors.
console.log(Number(10).toPrecision(200));
If we run the above code, It will throw RangeError. RangeError: toPrecision() argument must be between 1 and 100.
How to handle Error
Base on the nature of the API(method) call sync/async, Error can be handle differently.
Synchronous
try-catch: You can use try-catch block to handle Synchronous error.
try { console.log(Number(10).toPrecision(200)); } catch (error) { // RangeError: toPrecision() argument must be between 1 and 100console.log(error instanceof RangeError); // true }
If you don’t want to catch the error and perform any operation. In the newer version of JavaScript compiler you can do so.
try { console.log(Number(10).toPrecision(200)); } catch {}
If you want to perform some default operation on error, You can use finally block after the catch block.
let average; try { average = getAverage(); // Sum function does not exits } catch { } finally { average = 0; } console.log(`Average is ${average}`); // Average is 0
Finally block is mainly used to clean the resources like some open file, open connection.
Asynchronous:
An API is called asynchronous in nature when the outcome will come on some next event cycle of the EventLoop. Normally, All network call and IO operation are an async in nature. To get data from async call we either use callback or promise object.
Error handle in async-callback API(callback-error-data): Core Browser base javascript has very limited async APIs. You can create an async function either using timer APIs like setTimeout and setInterval Or you can create an AJAX call using fetch. setTimeout and setInterval do not throw any such error that can be handle. And fetch is a promised based async call(We will learn later to handle promise-based error). However, nodejs has a lot of standards and third-party APIs which throws an error. Just like our first express.js example.
const express = require("express"); const app = express(); app.get("/", (_, res) => res.end("OK")); const server = app.listen(80); server.on("error", function handleListen(error) { console.log(error); });
Here in the above example, Express does not try to handle error for you. Instead, it returns the core server instance of nodejs. You can catch error on the error handler callback function.
Nodejs follows certain rules. As a coding standards, all the async APIs accept a callback. In the callback, the first argument will be an error generated by API and the second argument will be data on success. This standard has been followed by the overall community too.
fs.readFile("a file that does not exist", (err, data) => { if (err) { console.error("There was an error reading the file!", err); return; } // Otherwise handle the data });
Note: You can not handle async callback error in try-catch block. However, there is an exception. A recent version of ECMA Script, using async-await now we can handle error in try-catch. We will learn that later.
// This will not work try { app.listen(80); } catch (error) { // never calledconsole.error(error); }
Error handle in promise-based API(try-catch-await): After ES5, Javascript introduce a new design pattern to handle callback for async API. That is a promise design pattern. This solves the previous issue of callback hell.
const promise = new Promise((response, reject) => { // some async code here }); promise .then(function onSuccess(data) { console.log("SUCCESS"); }) .catch(function onError(err) { console.error(err); });
To get error form a promise object, you have to use the catch method and pass a callback function.
The promise is much cleaner than that callback. However, It is very hard to understand the flow in a big codebase. The recent version of ECMA Script has introduced async-await. Using async-await we can write asynchronous code in a synchronous way.
async function main() { try { await promise1; const data = await promise2; } catch (error) { console.log(error); // SOME ERROR } } main();
Using try-catch-await, you can handle multiple errors in one block which was not possible/complicated in the promise-then-catch pattern.
Now we know how to handle the error. However, while writing code we don’t have to handle error only. We may want to create a custom error. This will help to write clean and maintainable code. It is good practice, you should create custom errors for business logics
Custom Error
Creating a custom error is very simple. You can use any custom class and throw it as an error.
class SomeNetworkError { constructor(status) { this.status = status; } } try { throw new SomeNetworkError(4000); } catch (error) { console.log(error instanceof SomeNetworkError); // trueconsole.log(`SomeNetworkError Status: ${error.status}`); // SomeNetworkError Status: 4000 }
Above we have SomeNetworkError class and we use an instance of this class to throw an error. This is a valid code. However, as a coding practice, we should extend default(standard) error-classes. The base of all error-class is Error and call with a super method with the message.
class SomeNetworkError extends Error { constructor(message, status) { super(message); this.status = status; } } try { throw new SomeNetworkError("Network Error", 4000); } catch (error) { console.error(error instanceof Error); // trueconsole.log(`> ${error}`); // > Error: Network Errorconsole.error(error); // SomeNetworkError: Network Errorconsole.error(error.stack); // stacktrace here }
If you notice, extending the Error class and calling super automatically get .toString method of the SomeNetworkError class and print a nice message. Similarly, you can extend other standard Error class too.
class ArithmeticRangeError extends RangeError { constructor(message) { super(message); } } try { const zero = 0; if (zero === 0) { throw new ArithmeticRangeError("zero cant be 0"); } } catch (error) { console.log(error instanceof RangeError); // trueconsole.error(error.toString()); // RangeError: zero cant be 0 }
Advanced Error Handling
Error handle with Loop: Keep try-catch out of the loop, if you want to break loop on error. Else put try-catch inside a loop to continue
Break on error:
try { const numbers = [10, 2, 0, 5]; numbers.forEach((num) => { if (num === 0) { throw new ArithmeticRangeError("zero cant be 0"); } console.log(num); }); } catch (error) {}
Continue on error or skip:
const numbers = [10, 2, 0, 5]; numbers.forEach((num) => { try { if (num === 0) { throw new ArithmeticRangeError("zero cant be 0"); } } catch (error) {} console.log(num); });
Without try-catch, Logical handle:
// filter zero, no need handle zero const numbers = [10, 2, 0, 5].filter((num) => num !== 0); numbers.forEach((num) => { console.log(num); }); // Use some to break loop const numbers = [10, 2, 0, 5]; numbers.some((num) => { const isZero = num === 0; if (isZero) return true; // logic here console.log(num); });
As you can see, based on your need you may not need to throw error always. You can handle it logically.
Multiple errors in try-catch:
try { let name; /// some operationif (name === "") throw new RangeError("Cant be blank"); if (name.match(/\W/)) throw new TypeError("name cant be non alph-numric"); throw new Error("Some other error"); } catch (error) { if (error instanceof RangeError) console.log("RangeError"); else if (error instanceof TypeError) console.log("TypeError"); else console.log("Other Error"); }
Multiple errors in promise-then-catch:
new Promise((resolve) => { let name; // some logic resolve(name); }) .then((name) => { if (name === "") throw new RangeError("Cant be blank"); else return name; }) .then((name) => { if (name.match(/\W/)) throw new TypeError("name cant be non alph-numric"); else return name; }) .catch((error) => { if (error instanceof RangeError) console.log("RangeError"); else if (error instanceof TypeError) console.log("TypeError"); else console.log("Other Error"); });
Error Handling Coding Practices
Above all code are very standard and simple use cases to handle the error. However, when you work on the project. The code maybe not this simple as it is given here. So we need to write some boilerplate codes. Below, I have listed some of the patterns that I follow in my projects.
- Create Enum Class or Error Constants
- Use localization from beginning
- Common util module or file to handle logic and generate an error
- Try to minimize try-catch uses, Instead write more unit test cases
- Catch and Throw a custom error on API calls
- Use typescript as much as possible
- Minimize the use of magic number/string
- Avoid higher level of nested object
- Avoid global object pollution
- Use Error-Boundaries as much as possible(React)
- Proper logging, use console.error for error logging.
- Log level to minimize log messages
- Don’t print credential in logs
- Use more visuals than console in the case of WebApps.
Happy Coding …