What are "Callbacks"?
A callback function is usually used as a parameter to another function.
The function that receives callback function is normally fetching data from a database, making an API request, downloading a file, which usually takes a while.
Assume getting some data from the API and the request takes around 2 seconds to complete.
Now, you can either wait for the API call to complete and then display your UI,
OR, you show everything else and show a loader where the API data needs to be shown.
In the API function, we pass some sort of "call back" function that replaces loader with actual data, so once the response is received from API
It calls the callback function with the data and, then our callback function replaces the loader.
Let's see this in action:
function getDataFromAPI(callbackFunction) {
fetchSomeData().then((data) => {
callbackFunction(data);
});
}
getDataFromAPI(function replaceLoaderWithData(data) {
// your awesome logic to replace loader with data
});
OR
// from w3schools
function myDisplayer(sum) {
document.getElementById('demo').innerHTML = sum;
}
function myCalculator(num1, num2, myCallback) {
let sum = num1 + num2;
myCallback(sum);
}
myCalculator(5, 5, myDisplayer);
Okay, you already know this. We're not learning what callbacks are.
What is "callback hell"?
If your application logic is not too complex, a few callbacks seem harmless.
But once your project requirements start to increase, you will quickly find yourself piling layers of nested callbacks.
Like this:
getAreas(function (areas) {
getTowns(function (towns) {
getCities(function (cities) {
getCountries(function (countries) {
getContinents(function (continents) {
getPlanets(function (planets) {
getSolarSystems(function (solarSystems) {
getGalaxies(function (galaxies) {
// Welcome to the callback hell...
});
});
});
});
});
});
});
});
Of course, we can use JavaScript's Promise
and move to .then
& .catch
.
getAreas().then(function (areas) {
getTowns().then(function (towns) {
getCities().then(function (cities) {
getCountries().then(function (countries) {
getContinents().then(function (continents) {
getPlanets().then(function (planets) {
getSolarSystems().then(function (solarSystems) {
getGalaxies().then(function (galaxies) {
// Welcome to the callback hell AGAIN...
});
});
});
});
});
});
});
});
Congrats! Welcome to Callback Hell.
Callback Hell, also known as Pyramid of Doom, is a slang term used to describe an unwieldy number of nested “if” statements or functions.
Async Await to the rescue!
Async await feels like heaven because it avoids the callback hell or pyramid of doom by writing asynchronous code in a clean line-by-line format.
The above code changes to this:
// assuming the environment supports direct async function
const areas = await getAreas();
const towns = await getTowns();
const cities = await getCities();
const countries = await getCountries();
const continents = await getContinents();
const planets = await getPlanets();
const solarSystems = await getSolarSystems();
const galaxies = await getGalaxies();
😳😲😳
// now this... looks awesome!!!
BUT...
This is awesome until error handling comes into play because you end up with the try-catch tower of terror!
All your beautiful one-liners magically expand to at least five lines of code...
// assuming the environment supports direct async function
try {
const areas = await getAreas();
} catch (err) {
// handleError(err)
}
try {
const towns = await getTowns();
} catch (err) {
// handleError(err)
}
try {
const cities = await getCities();
} catch (err) {
// handleError(err)
}
try {
const countries = await getCountries();
} catch (err) {
// handleError(err)
}
// ... and so on.
You can find yourself an easy way which is simply by appending the catch method to the end of each promise.
// assuming the environment supports direct async function
const areas = await getAreas().catch((err) => handleError(err));
const towns = await getTowns().catch((err) => handleError(err));
const cities = await getCities().catch((err) => handleError(err));
const countries = await getCountries().catch((err) => handleError(err));
const continents = await getContinents().catch((err) => handleError(err));
const planets = await getPlanets().catch((err) => handleError(err));
const solarSystems = await getSolarSystems().catch((err) => handleError(err));
const galaxies = await getGalaxies().catch((err) => handleError(err));
This looks better, but! This is still getting repetitive.
Another better option is to create a standardized error handling function.
The function would first resolve the promise then returns an array.
In that array, the first element is the data and the second element is an error.
If there's an error then the data is null and the error is defined, like this:
async function promiseResolver(promise) {
try {
const data = await promise();
return [data, null];
} catch (err) {
return [null, err];
}
}
Now when you call this function in your code you can destructure it to get a clean one-liner with error handling,
Or use a regular if statement if you want to do something else with the error.
Your main function would look something like this:
// assuming the environment supports direct async function
const [areas, areasErr] = await promiseResolver(getAreas);
const [towns, townsErr] = await promiseResolver(getTowns);
const [cities, citiesErr] = await promiseResolver(getCities);
if (citiesErr) {
// do something
}
const [countries, countriesErr] = await promiseResolver(getCountries);
const [continents, continentsErr] = await promiseResolver(getContinents);
const [planets, planetsErr] = await promiseResolver(getPlanets);
const [solarSystems, solarSystemsErr] = await promiseResolver(getSolarSystems);
const [galaxies, galaxiesErr] = await promiseResolver(getGalaxies);
if (galaxiesErr) {
// do something
}
// ... and so on.
That's all folks! Hope you found this helpful, see you in the next one 😉
Top comments (15)
I agree. Also, if your next function happens to depend on results from the previous function, you can chain them without nesting:
Nice article overall though, keep it up :).
Yes, this would be a good approach too :)
You can have one
catch
clause below allawait
sBut what if we have all the promise dependend on promise above them, and we want to handle error for each of them?
Perhaps you meant something else - in which case chaining the error handlers may be worth considering:
The thing is if you present code written in the manner described you have to expect to raise some eyebrows because you are ignoring error codes. Obviously you felt the need to do this - however work arounds like this are symptomatic of poor error design.
Defensive programming is messy
So messy in fact that the designers of Erlang invented "Let it Crash" (Programming Erlang 2e, p.88):
However most runtimes don't have the luxury of letting the process die and having the supervisor deal with the crash (p.199):
However there is another point to be made - not all errors are equal. Roughly speaking:
Not all languages have exceptions but they have idioms for exceptional errors. Golang:
Rust has the error propagation
?
operator.i.e. for
Ok(value)
thevalue
is is bound to the variable while anError(error)
is returned right then and there.When a language like JavaScript supports exceptions the rule of thumb tends to be:
So when we see
areasErr
is an expected error and should be handled, not ignored. And just because an error code is returned doesn't necessarily imply thatgetAreas()
can't be a source of unexpected errors. When we seethe code is implying that there aren't any expected errors to be handled locally but
getAreas()
can still be a source of unexpected errors.With this in mind - 4.1.3. Rejections must be used for exceptional situations:
i.e. a promise should resolve to an error value for expected errors rather than rejecting with the expected error. So when we see
there is a bit of a code smell because all errors deemed by
getAreas()
as exceptional are converted to expected errors at the call site and then are promptly ignored. There is an impedance mismatch between howgetAreas()
categorizes certain errors and how the code using it treats them. If you have no control overgetAreas()
then an explicit anti-corruption function (or an entire module for a "layer") may be called for to reorganize the error categorization (and the associated semantics), e.g. :so that the consuming code can be plainly
Compared to the above
comes across as expedient (though noisy) and perhaps receiving less thought than it deserves.
There's an npm library that does exactly this. (Full disclosure: I wrote it and have been using it for a couple of years)
npmjs.com/package/@jfdi/attempt
All approaches have their trade-offs.
The Zen of Go:
Go's errors are values philosophy is a recognized pain point.
Handling errors where they occur - midstream - obscures, in use case terminology, the happy path/basic flow/main flow/main success scenario. That doesn't mean that other flows/scenarios aren't important - on the contrary. That's why there are extensions/alternate flows/recovery flows/exception flows/option flows.
Clearly identifying the main flow in code is valuable.
Whether or not an IDE can collapse all the conditional error handling is beside the point - especially given that traditionally Java has been chastised for needing an IDE to compensate for all its warts.
Granted Go doesn't use a containing tuple like
[error, result]
but destructuring still directly exposesnull
orundefined
values which is rather "un-functional" - typically the container (the tuple) is left as an opaque type while helper functions are used to work with the contained type indirectly (e.g. Rust's Result).Now your criticism regarding
try … catch
is fair …… but JavaScript isn't a functional language (which typically is immutable by default and supports the persistent data structures to make that sustainable). However it is possible to take inspiration from Rust:
Rust: A unique perspective
e.g. for local variables with exclusive/unique access (i.e. aren't somehow shared via a closure) mutation can be acceptable.
So maintaining a
context
object to "namespace" all the values that need to stretch across the varioustry … catch
scopes isn't too unreasonable:Thank you for sharing <3
But what if we have all the promise dependend on promise above them, and we want to handle error for each of them?
Simple and powerful solution 👌
the "standardized error handling function" may seem nice, but it forces you to handle each error individually, you need to check the error for every statement with if else, resulting if else tower of terror and we are back to square one
meanwhile promise.all did a better job in handling all error with one catch
Yes, but they are promises not functions returning promises.
You just need to pass the function(Promise) references in Promise.all