DEV Community

Cover image for Callback hell OR try catch hell (tower of terror)
Muhammad Ovi
Muhammad Ovi

Posted on • Originally published at muhammadovi.com

Callback hell OR try catch hell (tower of terror)

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
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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...
              });
            });
          });
        });
      });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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...
              });
            });
          });
        });
      });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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!!!
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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];
  }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

That's all folks! Hope you found this helpful, see you in the next one 😉

Top comments (15)

Collapse
 
sargalias profile image
Spyros Argalias

I agree. Also, if your next function happens to depend on results from the previous function, you can chain them without nesting:

function getAreas() {}
function getTowns(areas) {}
function getCities(towns) {}
function getCountries(cities) {}

getAreas()
  .then(getTowns)
  .then(getCities)
  .catch(handleError);

// or inline
getAreas()
  .then(function getTowns(areas) {})
  .then(function getCities(towns) {})
  .then(function getCountries(cities) {})
  .catch(function handleError() {});
Enter fullscreen mode Exit fullscreen mode

Nice article overall though, keep it up :).

Collapse
 
ovi profile image
Muhammad Ovi

Yes, this would be a good approach too :)

Collapse
 
alekseiberezkin profile image
Aleksei Berezkin

You can have one catch clause below all awaits

Collapse
 
ovi profile image
Muhammad Ovi

But what if we have all the promise dependend on promise above them, and we want to handle error for each of them?

Collapse
 
peerreynders profile image
peerreynders • Edited
async function tryAll(promises, name) {
  const resolved = [];
  const rejected = [];
  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled
  const all = await Promise.allSettled(promises);
  for (const outcome of all)
    if (outcome.hasOwnProperty('value')) resolved.push(outcome.value);
    else rejected.push(outcome.reason);

  if (rejected.length < 1) return resolved;

  const error = new Error('tryAll encountered errors');
  error.name = name;
  error.rejected = rejected;
  throw error;
}

const ERROR_NAME_MULTI_REJECT = 'ErrorMultiReject';

function handleMultiReject(multiError) {
  for (const err of multiError.rejected)
    console.log('Multi Error', err.toString());
}

function routeError(err) {
  if (err.name === ERROR_NAME_MULTI_REJECT) handleMultiReject(err);
  else console.log('Other Error -', err.toString());
}

function makeError(source, name) {
  const error = new Error(`Error from ${source}`);
  error.name = name;
  return error;
}

const testFn = (result, source, name) =>
  result ? Promise.resolve(result) : Promise.reject(makeError(source, name));

async function main() {
  try {
    const one = await testFn('one', `testFnOne`, 'ErrorOne');
    const results = await tryAll(
      [
        testFn(null, `testFnTwo`, 'ErrorTwo'),
        testFn(null, `testFnThree`, 'ErrorThree'),
        testFn('four', `testFnFour`, 'ErrorFour'),
      ],
      ERROR_NAME_MULTI_REJECT
    );
    console.log('done');
  } catch (err) {
    routeError(err);
  }
}

main();
// "Multi Error", "ErrorTwo: Error from testFnTwo"
// "Multi Error", "ErrorThree: Error from testFnThree"
Enter fullscreen mode Exit fullscreen mode

Perhaps you meant something else - in which case chaining the error handlers may be worth considering:

// wrap the error with one that is uniquely named ...
async function wrapError(promise, name) {
  try {
    // https://jakearchibald.com/2017/await-vs-return-vs-return-await/#return-awaiting
    return await promise;
  } catch (err) {
    const error = new Error('Wrapped Error');
    error.name = name;
    error.wrapped = err;
    throw error;
  }
}

function makeChainedHandler(next, handler) {
  return next
    ? (err) => {
        handler(err);
        next(null);
      }
    : handler;
}

// ... so that the appropriate error handler
// to process the wrapped error can be retrieved
// which calls any other chained "aborted" handlers.  
//
function handleError(err) {
  if (err.hasOwnProperty('wrapped')) {
    const handler = handlers.get(err.name);
    if (handler) {
      handler(err.wrapped);
      return;
    }

    err = err.wrapped;
  }

  console.log('Other Error -', err.toString());
}

async function main() {
  try {
    const one = await wrapError(demoFnOne('one'), ERROR_NAME_ONE);
    const two = await wrapError(demoFnTwo(null), ERROR_NAME_TWO);
    const three = await wrapError(demoFnThree('three'), ERROR_NAME_THREE);
    const four = await wrapError(demoFnFour('four'), ERROR_NAME_FOUR);
    console.log('done');
  } catch (err) {
    handleError(err);
  }
}

// --- Begin Demo Support
const DEMO_ONE = 'DemoFnOne';
const DEMO_TWO = 'DemoFnTwo';
const DEMO_THREE = 'DemoFnThree';
const DEMO_FOUR = 'DemoFnFour';

const ERROR_NAME_ONE = 'ErrorOne';
const ERROR_NAME_TWO = 'ErrorTwo';
const ERROR_NAME_THREE = 'ErrorThree';
const ERROR_NAME_FOUR = 'ErrorFour';

const config = [
  [DEMO_ONE, ERROR_NAME_ONE],
  [DEMO_TWO, ERROR_NAME_TWO],
  [DEMO_THREE, ERROR_NAME_THREE],
  [DEMO_FOUR, ERROR_NAME_FOUR],
];

function makeError(fnName) {
  const error = new Error(`Error from ${fnName}`);
  error.name = 'Error' + fnName[0].toUpperCase() + fnName.slice(1);
  return error;
}

const makeDemoFn = (fnName) => (result) =>
  result ? Promise.resolve(result) : Promise.reject(makeError(fnName));

function makeErrorHandler(fnName) {
  return (err) => {
    if (err) {
      console.log(err.toString());
      return;
    }

    console.log(`Error: ${fnName} was aborted.`);
  };
}

function makeBoth([fnName, errName]) {
  const fn = makeDemoFn(fnName);
  const handler = makeErrorHandler(fnName);
  return [
    [fnName, fn],
    [errName, handler],
  ];
}

const [fns, handlers] = (() => {
  const fnEntries = [];
  const handlerEntries = [];
  let chained = null;

  // in reverse to set up the necessary abort chaining
  for (let i = config.length - 1; i >= 0; i -= 1) {
    const [fnEntry, [name, handler]] = makeBoth(config[i]);
    chained = makeChainedHandler(chained, handler);
    fnEntries.push(fnEntry);
    handlerEntries.push([name, chained]);
  }

  return [new Map(fnEntries), new Map(handlerEntries)];
})();

const demoFnOne = fns.get(DEMO_ONE);
const demoFnTwo = fns.get(DEMO_TWO);
const demoFnThree = fns.get(DEMO_THREE);
const demoFnFour = fns.get(DEMO_FOUR);

// --- End Demo Support

main();

// "ErrorDemoFnTwo: Error from DemoFnTwo"
// "Error: DemoFnThree was aborted."
// "Error: DemoFnFour was aborted."
Enter fullscreen mode Exit fullscreen mode
Collapse
 
peerreynders profile image
peerreynders

Your main function would look something like this

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):

Many languages say you should use defensive programming and check the arguments to all functions. In Erlang, defensive programming is built-in. You should describe the behavior of functions only for valid input arguments; all other arguments will cause internal errors that are automatically detected. You should never return values when a function is called with invalid arguments. You should always raise an exception. This rule is called “Let it crash.”

However most runtimes don't have the luxury of letting the process die and having the supervisor deal with the crash (p.199):

If this process dies, we might be in deep trouble since no other process can help. For this reason, sequential languages have concentrated on the prevention of failure and an emphasis on defensive programming.

However there is another point to be made - not all errors are equal. Roughly speaking:

  • Expected errors. Errors will occur routinely during the operation of the software and therefore should be handled appropriately.
  • Exceptional errors. Errors that indicate that fundamental assumptions about the software and the environment that it is operating in have been violated. These type of errors cannot be handled at the local scope, so local processing is terminated and the error is passed upwards repeatedly until some kind of sensible compensating action can be taken.

Not all languages have exceptions but they have idioms for exceptional errors. Golang:

    // some processing
    result, err := doSomething()
    if err != nil {
        return err
    }

    // more processing ...
Enter fullscreen mode Exit fullscreen mode

Rust has the error propagation ? operator.

  let mut f = File::open("username.txt")?;
Enter fullscreen mode Exit fullscreen mode

i.e. for Ok(value) the value is is bound to the variable while an Error(error) is returned right then and there.

When a language like JavaScript supports exceptions the rule of thumb tends to be:

  • Use error values for expected errors.
  • Use exceptions for unexpected, exceptional errors.

So when we see

const [areas, areasErr] = await getAreas();
Enter fullscreen mode Exit fullscreen mode

areasErr is an expected error and should be handled, not ignored. And just because an error code is returned doesn't necessarily imply that getAreas() can't be a source of unexpected errors. When we see

const areas  = await getAreas();
Enter fullscreen mode Exit fullscreen mode

the 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:

Bad uses of rejections include: When a value is asked for asynchronously and is not found.

i.e. a promise should resolve to an error value for expected errors rather than rejecting with the expected error. So when we see

const [areas, areasErr] = await promiseResolver(getAreas);
Enter fullscreen mode Exit fullscreen mode

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 how getAreas() categorizes certain errors and how the code using it treats them. If you have no control over getAreas() 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. :

function myGetAreas() {
  try {
    return await getAreas();
  } catch (err) {
    if (ERROR_NO_ACTION_NAMES.includes(err.name)) return [];
    else throw err;
  }
}  
Enter fullscreen mode Exit fullscreen mode

so that the consuming code can be plainly

const areas = await myGetAreas();
Enter fullscreen mode Exit fullscreen mode

Compared to the above

const [areas, _areasErr] = await promiseResolver(getAreas);
Enter fullscreen mode Exit fullscreen mode

comes across as expedient (though noisy) and perhaps receiving less thought than it deserves.

Collapse
 
jonsilver profile image
Jon Silver

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

Collapse
 
peerreynders profile image
peerreynders

All approaches have their trade-offs.

The Zen of Go:

Plan for failure, not success

Go programmers believe that robust programs are composed from pieces that handle the failure cases before they handle the happy path.

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.

to implement functional try/catch

Granted Go doesn't use a containing tuple like [error, result] but destructuring still directly exposes null or undefined 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 …

a variable ripe for mutation

… 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:

If a variable has unique access to a value, then it is safe to mutate it.

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 various try … catch scopes isn't too unreasonable:

const hasValue = (result) => result.hasOwnProperty('value');

class Result {
  static ok(value) {
    return new Result(value);
  }

  static err(error) {
    return new Result(undefined, error);
  }

  constructor(value, error) {
    if (typeof value !== 'undefined') this.value = value;
    else this.error = error;
  }

  get isOk() {
    return hasValue(this);
  }

  get isErr() {
    return !hasValue(this);
  }

  map(fn) {
    return hasValue(this) ? Result.ok(fn(this.value)) : Result.err(this.error);
  }

  mapOr(defaultValue, mapOk) {
    return hasValue(this) ? mapOk(this.value) : defaultValue;
  }

  mapOrElse(mapErr, mapOk) {
    return hasValue(this) ? mapOk(this.value) : mapErr(this.error);
  }

  andThen(fn) {
    return hasValue(this) ? fn(this.value) : Result.err(this.error);
  }

  // etc
}

const RESULT_ERROR_UNINIT = Result.err(new Error('Uninitialized Result'));

// To get around statement oriented
// nature of try … catch
// wrap it in a function
//
function doSomething(fail) {
  // While not strictly necessary
  // use `context` to "namespace"
  // all cross scope references
  // and initialize them to
  // sensible defaults.
  //
  const context = {
    success: false,
    message: '',
    result: RESULT_ERROR_UNINIT,
  };

  try {
    if (fail) throw new Error('Boom');
    context.success = true;
    context.result = Result.ok(context.success);
  } catch (err) {
    context.success = false;
    context.message = err.message;
    context.result = Result.err(err);
  } finally {
    console.log(context.success ? 'Yay!' : `Error: '${context.message}'`);
  }
  return context.result;
}

const isErrBoom = (error) => error instanceof Error && error.message === 'Boom';
const isErrNotTrue = (error) =>
  error instanceof Error && error.message === "Not 'true'";
const returnFalse = (_param) => false;
const isTrue = (value) => typeof value === 'boolean' && value;
const isFalse = (value) => typeof value === 'boolean' && !value;
const negate = (value) => !value;
const negateOnlyTrue = (value) =>
  isTrue(value) ? Result.ok(false) : Result.err(new Error("Not 'true'"));

// hide try … catch inside `doSomething()` to produce a `Result`
const result1 = doSomething(false); // 'Yay'
console.assert(result1.isOk, 'Should have been OK');
console.assert(result1.mapOr(false, isTrue), "Ok value isn't 'true'");
console.assert(
  result1.map(negate).mapOr(false, isFalse),
  "'map(negate)' didn't produce 'Ok(false)'"
);
console.assert(
  result1.andThen(negateOnlyTrue).mapOr(false, isFalse),
  "'andThen(negateOnlyTrue)' didn't produce 'Ok(false)'"
);
console.assert(
  result1
    .map(negate)
    .andThen(negateOnlyTrue)
    .mapOrElse(isErrNotTrue, returnFalse),
  "'andThen(negateOnlyTrue)' didn't produce 'Error(\"Not 'true'\")"
);

const result2 = doSomething(true); // "Error: 'Boom'"
console.assert(result2.isErr, 'Should have been Error');
console.assert(
  result2.mapOrElse(isErrBoom, returnFalse),
  "Message isn't 'Boom'"
);
console.assert(
  result2.map(negate).mapOrElse(isErrBoom, returnFalse),
  "'map(negate)' didn't preserve Error"
);
console.assert(
  result2.andThen(negateOnlyTrue).mapOrElse(isErrBoom, returnFalse),
  "'andThen(negateOnlyTrue)' didn't preserve Error"
);
Enter fullscreen mode Exit fullscreen mode
Collapse
 
ovi profile image
Muhammad Ovi

Thank you for sharing <3

Collapse
 
ovi profile image
Muhammad Ovi

But what if we have all the promise dependend on promise above them, and we want to handle error for each of them?

Collapse
 
khorne07 profile image
Khorne07

Simple and powerful solution 👌

Collapse
 
tylim88 profile image
Acid Coder

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

 
basharath profile image
Basharath
const promise = new Promise((res, rej) => {});

// promise is a promise.
Enter fullscreen mode Exit fullscreen mode
 
basharath profile image
Basharath

Yes, but they are promises not functions returning promises.

Collapse
 
basharath profile image
Basharath

You just need to pass the function(Promise) references in Promise.all