How to handle errors in Javascript in the modern world?

If you came here only for the sake of an answer and you are not interested in reasoning – scroll down 🙂

How it all began

To begin with, let’s remember how errors are generally caught in js, whether it’s a browser or a server. js has a construct try...catch.

try {
    let data = JSON.parse('...');
} catch(err: any) {
		// если произойдет ошибка, то мы окажемся здесь
}

This is a common construct and most languages ​​have it. However, there is a problem here (and, as it turns out, not the only one), this construction “will not work” for asynchronous code, for code that was 5 years ago. In those days, the browser used to make an Ajax request XMLHttpRequest.

const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com', true);
xhr.addEventListener('error', (e: ProgressEvent<XMLHttpRequestEventTarget>) => {
    // если произойдет ошибка, то мы окажемся здесь
});

It uses the mechanism of subscribing to the error event. In this case, the variable e is an event, in fact, we got away from the real error and closed ourselves with some abstraction, behind which the real error is hidden, to which we do not have access.

NodeJS has been promoting the concept of Error-First Callback from the very beginning, this idea has been applied to asynchronous functions, such as reading a file. Its meaning is to pass the error to the callback function as the first argument, and the data already received as the next arguments.

import fs from 'fs';

fs.readFile('file.txt', (err, data) => {
    if (err) {
        // обработка ошибки
    }
    // если все хорошо, работаем с данными
});

If we look at what type the variable has errwe will see the following:

interface ErrnoException extends Error {
    errno?: number | undefined;
    code?: string | undefined;
    path?: string | undefined;
    syscall?: string | undefined;
}

There is indeed a mistake here. In essence, this is the same way as above, only in this case we get an object Error.

After some time, Javascript appeared Promise. They have certainly changed js development for the better. After all, no one * no one likes to fence huge constructions from callback functions.

fetch('https://api.example.com')
  .then(res => {
    // если все хорошо, работаем с данными
  })
  .catch(err => {
		// обработка ошибки
  });

Despite the fact that outwardly this example is very different from the first, nevertheless, we see a clear logical connection. Obviously, the developers wanted to make something similar to try...catch construction. Over time, another way to handle an error in asynchronous code appeared. This method, in fact, is just syntactic sugar for the previous example.

try {
  const res = await fetch('https://api.example.com');
  // если все хорошо, работаем с данными
} catch(err) {
	// обработка ошибки
}

Also, the design try...catch allows you to catch errors from several promises at the same time.

try {
  let usersRes = await fetch('https://api.example.com/users');
	let users = await usersRes.json();

  let chatsRes = await fetch('https://api.example.com/chats');
	let chats = await chatsRes.json();

  // если все хорошо, работаем с данными
} catch(err) {
	// обработка ошибки
}

Here’s a great way to catch bugs. Any error that occurs inside the block trywill fall into the block catch and we’ll work it out for sure.

Are we really going to process it?

Indeed, is it true that we will handle the error, or just pretend? In practice, most likely, the resulting error will simply be printed to the console or the like. Moreover, when an error* occurs, the interpreter will jump to the block catch where not we, not TypeScript will not be able to infer the type of the variable that got there (example – return with Promise.reject), after which the function exits. That is, we will not be able to execute the code that is in the same block, but which is located below the function within which the error occurred. Of course, we can foresee such situations, but code complexity and readability will increase many times over.

How to be?

Let’s try to use the approach offered by the developers of one notorious language.

let [users, err] = await httpGET('https://api.example.com/users');
if (err !== null) {
	// обработка ошибки
}
// продолжаем выполнение кода

We always keep a possible error next to the data returned from the function, which hints to us that the variable err desirable to check.

Example for calling multiple return functions Promise.

let err: Error,
		users: User[],
		chats: Chat[];

[users, err] = await httpGET('https://api.example.com/users');
if (err !== nil) {
  // обработка ошибки
}

[chats, err] = await httpGET('https://api.example.com/chats');
if (err !== nil) {
  // обработка ошибки
}

Of course, we can, as before, simply exit functions when an error occurs, but if, nevertheless, there is a need to treat the code more responsibly, we can easily start doing this.

Let’s look at how we can implement such a function and what we generally need to do. First, let’s define the type PairPromise. In this case, I decided to use null if there is no result or error, as it is simply shorter.

type PairPromise<T> = Promise<[T, null] | [null, Error]>;

Let’s define possible returned errors.

const notFoundError = new Error('NOT_FOUND');
const serviceUnavailable = new Error('SERVICE_UNAVAILABLE');

Now let’s describe our function.

const getUsers = async (): PairPromise<User[]> => {
    try {
        let res = await fetch('https://api.example.com/users');
        if (res.status === 504) {
            return Promise.resolve([null, serviceUnavailable]);
        }

        let users = await res.json() as User[];

        if (users.length === 0) {
            return Promise.resolve([null, notFoundError]);
        }

        return Promise.resolve([users, null]);
    } catch(err) {
        return Promise.resolve([null, err]);
    }
} 

An example of using such a function.

let [users, err] = await getUsers();
if (err !== null) {
	switch (err) {
  	case serviceUnavailable:
    	// сервис недоступен
    case notFoundError:
    	// пользователи не найдены
    default:
    	// действие при неизвестной ошибке
	}
}

There are many ways to apply this error handling approach. We combine design convenience try...catch and Error-First Callback, we are guaranteed to catch all errors and be able to handle them conveniently, if necessary. As a nice bonus – we do not lose typing. Also, we are not bound only by the object Errorwe can return our wrappers and use them successfully, depending on our beliefs.

The community’s opinion on this topic is very interesting.

Similar Posts

Leave a Reply