DEV Community

loading...

Why I Don't Use Async Await

Jesse Warden
I write code, front end and back-end, and like deploying it on AWS. Software Developer for 20 years, and still love it. Amateur Powerlifter & Parkourist.
Originally published at jessewarden.com ・4 min read

A lot of JavaScript developers speak in exceptions. However, JavaScript does not have any defined practices on “good exception handling”. What does good mean? All using try/catch, .catch for Promises, and window.onerror in the browser or process.on for Node.js? Just http/file reading/writing calls? 3rd party/vendor systems? Code with known technical debt? None “because fast, dynamic language”?

In my view, good exception handling is no exceptions. This means both writing code to not throw Exceptions, nor cause them, and ensuring all exceptions are handled.

However, that’s nearly impossible in JavaScript as it is a dynamic language and without types, the language encourages the accidental creation of null pointers. You can adapt certain practices to prevent this.

One in the particular is not using async await.

A warning, this is a minority view, and only some functional languages hold this view. I also acknowledge my Functional Programming bias here. JavaScript accepts all types of coding styles, not just FP.

The Promise

Promises are great for a variety of reasons; here are 4:

  1. They have built-in exception handling. You can write dangerous code, and if an Exception occurs, it’ll catch it, and you can write a catch function on the promise to handle it.
  2. They are composable. In functional programming, you create pure functions, which are rad by themselves, and you wire them together into pipelines. This is how you do abstraction and create programs from functions.
  3. They accept both values and Promises. Whatever you return from the then, the Promise will put into the next then; this includes values or Promises, making them very flexible to compose together without worry about what types are coming out.
  4. You optionally define error handling in 1 place, a catch method at the end.
const fetchUser => firstName => 
  someHttpCall()
  .then( response => response.json() )
  .then( json => {
    const customers = json?.data?.customers ?? []
    return customers.filter( c => c.firstName === 'Jesse' )
  })
  .then( fetchUserDetails )
  .catch( error => console.log("http failed:", error) )
Enter fullscreen mode Exit fullscreen mode

However, they’re hard. Most programmers do not think in mathematical pipelines. Most (currently) think in imperative style.

Async Await

The async and await keywords were created to make Promises easier. You can imperative style code for asynchronous operations. Rewriting the above:

async function fetchUser(firstName) {
  const response = await someHttpCall()
  const json = await response.json()
  const customers = json?.data?.customers ?? []
  const user = customers.filter( c => c.firstName === 'Jesse' )
  const details = await fetchUserDetails(user)
  return details
}
Enter fullscreen mode Exit fullscreen mode

But there is a problem, there is no error handling. Let’s rewrite it with a try/catch:

async function fetchUser(firstName) {
  try {
    const response = await someHttpCall()
    const json = await response.json()
    const customers = json?.data?.customers ?? []
    const user = customers.filter( c => c.firstName === 'Jesse' )
    const details = await fetchUserDetails(user)
    return details
  } catch(error) {
    console.log("error:", error)
  }
}
Enter fullscreen mode Exit fullscreen mode

However, there are also some nuances. For example, we want to separate the error handling for someHttpCall and it’s data handling from fetchUserDetails.

async function fetchUser(firstName) {
  try {
    const response = await someHttpCall()
    const json = await response.json()
    const customers = json?.data?.customers ?? []
    const user = customers.filter( c => c.firstName === 'Jesse' )
    try {
      const details = await fetchUserDetails(user)
      return details
    } catch(fetchUserDetailsError) {
      console.log("fetching user details failed, user:", user, "error:", fetchUserDetailsError)
    }
  } catch(error) {
    console.log("error:", error)
  }
}
Enter fullscreen mode Exit fullscreen mode

This can get more nuanced. Now you have the same problem you have with nested if statements, it’s just quite hard to read. Some don’t view that as a problem.

Golang / Lua Style Error Handling

The Golang and Lua devs do view that as a problem. Instead of Exception handling like JavaScript/Python/Java/Ruby do, they changed it to returning multiple values from functions. Using this capability, they formed a convention of returning the error first and the data second. This means you can write imperative code, but no longer care about try/catch because your errors are values now. You do this by writing promises that never fail. We’ll return Array’s as it’s easier to give the variables whatever name you want. If you use Object, you’ll end up using const or let with the same name which can get confusing.

If you use traditional promises, it’d look like this:

const someHttpCall = () =>
  Promise.resolve(httpCall())
  .then( data => ([ undefined, data ]) )
  .catch( error => Promise.resolve([ error?.message, undefined ]) )
Enter fullscreen mode Exit fullscreen mode

If you are using async await, it’d look like this:

function someHttpCall() {
  try {
    const data = await httpCall()
    return [ undefined, data ]
  } catch(error) {
    return [ error?.message ] 
  }
} 
Enter fullscreen mode Exit fullscreen mode

If you do that to all your async functions, then when using your code, it now looks like this:

async function fetchUser(firstName) {
  let err, response, json, details
  [err, response] = await someHttpCall()
  if(err) {
    return [err]
  }

  [err, json] = await response.json()
  if(err) {
    return [err]
  }

  const customers = json?.data?.customers ?? []
  const user = customers.filter( c => c.firstName === 'Jesse' );
  [err, details] = await fetchUserDetails(user[0]);
  if(err) {
    return [err]
  }

  return [undefined, details]
}
Enter fullscreen mode Exit fullscreen mode

Then if all your functions look like this, there are no exceptions, and all functions agree to follow the same convention. This has some readability advantages and error handling advantages elaborated on elsewhere. Suffice to say, each line stops immediately without causing more errors, and secondly, the code reads extremely imperative from top to bottom which is preferable for some programmers.

The only issue here is not all errors are handled despite it looking like it. If you mispell something such as jsn instead of json or if you forget to wrap a function in this style like response.json, or just generally miss an exception, this style can only help you so much.

Additionally, you have to write a lot more code to put the error first, data last. The worse thing about this style is the constant checking if(err). You have to manually do that each time you call a function that could fail. This violates DRY pretty obnoxiously.

Conclusions

You know what doesn’t violate DRY, isn’t verbose, and handles all edge cases for exceptions, only requiring you to put exception handling in one place, but still remains composable?

Promises.

const fetchUser => firstName => 
  someHttpCall()
  .then( response => response.json() )
  .then( json => {
    const customers = json?.data?.customers ?? []
    return customers.filter( c => c.firstName === 'Jesse' )
  })
  .then( fetchUserDetails )
  .catch( error => console.log("http failed:", error) )
Enter fullscreen mode Exit fullscreen mode

Discussion (9)

Collapse
captainn profile image
Kevin Newman

It doesn't look like your promises example has all the same error handling. Is this really comparing apples to apples?

Also, you could structure your try/catch blocks without nesting them, if you don't like that style.

Collapse
jesterxl profile image
Jesse Warden Author • Edited

The point is you don't need to to have multiple try/catch blocks; just use 1 catch in a normal Promise. They all come out the same place. However, I acknowledge many prefer imperative style. This article is aimed at those who aren't sure why they're using that style, and if there are alternatives.

There is a path out. You start having each operation return something meaningful vs. "I don't know, nor care, just let all Exceptions bubble up and whoever is reading the catch will figure it out".

Once you've gotten accustomed to that, then you stop having errors, and instead start dealing with values like http worked or http failed. This is called Result or Either in other languages, but there are some neat ways to compose that.

Once you've gotten that concept down, you can then start returning multiple values such as FileData or FileDataParseFailure or FileNotThere or TimedOutTryingToReadFile with options on how you react to each one of those. Again, these compose in the same way, but aren't a Promise.reject, they're just a normal Promise.resolve.

Collapse
macsikora profile image
Pragmatic Maciej • Edited

Jesse,
you are defining that one is imperative where the other is not. Guess what, to go to the.catch in the promise chain we need to throw an exception in the function in the chain 😀, so we still are imperative as we use exceptions but wrapped by Promise interface. Also the whole thing is imperative as we do http call in it directly. Funny is thought that you are feeling so good with calling Promise.catch and have so big issue with just catch.

In my personal experience in many languages, I see a lot of issue in the way of thinking which is presented in the article. People see some solution which work in language A, and force them to language B even though it doesn't work good enough in new environment. There are so many examples:

  • using HOF in Python (where in most we have list comprehensions)
  • using Maybe in languages with null article about that
  • trying to pretend there are no exceptions by wrapping errors in values in languages with exceptions as primary error flow syntax
  • using point free style in languages without currying
  • trying to avoid any statements like switch in statement based languages

All these things happens because somebody have seen pure FP language like Haskell, Elm and now tries to force this ideas into any other language. I am not saying ideas from these languages are wrong, I am saying there is a tradeoff, and in many situations tradeoff is negative. When we switch to Python for example there are Pythonic ways for solving staff, and "reduce" function is not one of them. When we switch to JS/TS we have exceptions, we have null values, trying to fight with them ends by introducing alien concepts to the language. For example the code in your example json?.data?.customers ?? [] is based on null | undefined values, and if we introduce Maybe we should not be using it. Still I am not saying that FP is wrong, we can still use FP, by avoiding side-effects, making pure functions, but it doesn't mean instead of a + b I should use add(a)(b) to be super composable FP king.

Back to exceptions. Yes they are problematic, yes they are outside of standard call stack, but they are there and they can be used with sense. Async/await syntax is great for readability with no comparison, code is flat, steps are clear, wrapping it by try catch makes us understand that anything bad will happen in the process, here is a place to cover this negative path. We can have flat code by creating custom exceptions and handling them in one place. This example with returning tuple [error, data] looks like nothing more than just having worst code (yes very opinionated statement 😉) only because I don't want to use standard error handling mechanism which is exactly exceptions.

I also was in that train, by overusing ramda, after that fp-ts, I had occasion to work with Elm, Haskell, and after all of these I just clearly see, taking an idea from one language to another is not always a good idea, something which is great in one place, doesn't need to be great in another.

Thread Thread
jesterxl profile image
Jesse Warden Author • Edited

In FP style, using Promise.reject is not ok. It's just an example to show how to use. However, if you're ok with exceptions, then it is.

We're using Promise to get things for free because time and time again I see developers using async/await with NO try/catch. Not just in one function, but all the functions that use it, etc. Like, no exception handling at all. I don't believe that is a good practice to ignore exceptions. It's too hard to define which ones are important and which ones are not. JavaScript the language will help you if you use Promise and do it for you whereas async await will not. We're not pretending they're aren't any; we're acknowledging JavaScript is unpredictable so we use the best mechanism it gives us.

If you know how to use reduce, it's good. Python and JavaScript have native reduce. If you do not like it, or find it confusing, JavaScript has good support for for loops, and Python has amazing list and slicing syntax. Reduce was added after loops, so the ECMA committee found it would help a lot of people who wanted to use it, but recognized others wouldn't. That's fine, they're both there. If you like imperative style loops, they're there for you. You like list comprehensions? You can use those instead.

Maybe is a type. JavaScript doesn't really have compile time types, so Maybe is hard. Things like Folktale are great, and can sometimes help in readability, but I recognize many do not like Lodash get, or Folktale Maybe and would prefer the native optional chaining. If you understand what a Maybe is, and have a good library, then it's good. If, however, like you mentioned, you're working with people who don't, then optional chaining is good. Optional chaining was added quite recently (in ECMA years) so we had no choice but to use Maybe, else we had to do obnoxious null/undefined checks everywhere which was quite verbose. Lodash isNil/get/getOr helped a lot here.

Currying: you either like it or you don't. While the pipeline operator is years away from being usable, partial applications still work great inside of Promises. The downside is without types, it's a bit challenging to know "what comes out". As long as your build is fast, you can figure this out by re-running, but the exceptions aren't as good as Elm/Ocaml which will tell you the function type signature. If you're not familiar with currying, or are exposing your API to those that aren't, you should just expose a regular function.

Async/await syntax is great for readability

Yeah, if you like imperative code. I don't. Many do. The [error, data] example is what the Go devs do. They love it. I abhor it. Promises make it so you don't have to write Golang style.

If someone wants to use classes and a DI framework because they come from Spring Boot, and that makes them productive, they should use it. JavaScript has significantly improved class and has some good options now. Frameworks like Nest enable them to apply that Angular/Java OOP style on the server now. That is a good thing. I'll never use it myself as I don't like that style, but I will espouse to those who like OOP.

The same applies to imperative. If someone wants to use async/await, for loops, and throw exceptions, that's fine; they should continue doing so. That's not all JavaScript has to offer, though, and like Python, they support both OOP and FP styles as well. If someone doesn't like them, that's cool, but they are not Go; there are many ways to use those languages.

I just like FP style, and I like how Promise has built-in Exception handling that makes JavaScript safer for the exception cases I didn't cover.

Thread Thread
macsikora profile image
Pragmatic Maciej • Edited

I think when you say "imperative" you mean "statement based", and when you say "functional" you mean "expression based". Promises are imperative how you will use them do not change it, async/await makes promises composition more like steps, in the same way "do" syntax in Haskell. In general I think you are referring to the fact that you prefer composition of expressions instead of using assignment statement. Where for me it is no specially different, it is only lets say code style, I can say for example that let expression from Elm/Haskell in JS can be replicated by just assigning local variables, this solutions are equal until we start mutating staff.

Thread Thread
jesterxl profile image
Jesse Warden Author

Sort of. I make a ton of assumptions on the reader which I maybe shouldn't? If I don't, I end up writing 10 pages.

First, I assume we're using as pure of functions as possible. This means, ivory tower, there are no exceptions, but rather, a Result or Maybe is returned. So you're you're just changing this:

fs.readFileSync('arrayOfPeopleJSON.txt')
|> JSON.parse
|> filterHumans
|> mapNames
|> fixNames
Enter fullscreen mode Exit fullscreen mode

to this:

Promise.resolve('arrayOfPeopleJSON.txt')
.then( JSON.parse )
.then( filterHumans )
.then( mapNames )
.then( fixNames )
Enter fullscreen mode Exit fullscreen mode

... but then you have to ensure JSON.parse is actually:

const safeJSONParse = str => {
  try {
    const result = JSON.parse(str)
    return result
  } catch(error) {
    console.log("safeJSONParse str:", str, "error:", error)
    retun []
  }
}
Enter fullscreen mode Exit fullscreen mode

However, even though I screwed up return and called it retun in the function, the Promise just "handles" it fo me; no action on my part. That's the kind of stuff I know will happen eventually. It's the other stuff I don't that is quite complex like http/node-fetch responses that get super hard and complex. I'll start high level, like response => response.json(), but then break it down to handle statusCode, and various return values based on the API. Again, these functions are as pure as possible, and tend to return multiple values or a Result. The catch is always there to say "Yo dog, this is JavaScript, you missed one.... why aren't you using types?"

You can do that same style in async/await, no doubt, but I find people who do really don't care about pure functions, dependency injection, or any other FP style concepts. Again, though, you have to remember to put try/catches, whereas with Promise you don't.

The caveat with the above is in the browsers, I've seen some horrible things with window.onerror returning true. It's... it's really depressing. They'll basically create a global denylist, and say "we don't care about runtime exceptions, except for these 3". I know some love that power, but I'd prefer they endeavor to write more solid code. Yes, I get that's impossible in JavaScript, but you can make an effort and see improvements here; I believe it's worth doing.

The other caveat is Node.js, especially recent versions like 12 and 14. If you don't have a try/catch, or a catch on Promise changes, she'll crash your whole program, which I agree with. However, again, I've seen people do process.on for both sync and async exceptions and use that as a crutch rather than do the work to write more solid code. Again, that's nuanced, because some code bases are just... well, brutal. They might have been inherited or have 3rd party nastiness, so I get it. My empathy, however, doesn't condone that behavior.

Yeah, do in Haskell and let in Elm are "crutches" I use a lot. Despite practicing FP for years, my brain is still wired to think about hard problems imperatively (in sequential statements like you said). The let keyword helps immensely when I'm trying to reason about a problem in steps. I think the difference there, though, is:

  1. you have types so you can't screw it up, or the compiler won't compile your code and
  2. you're forced to handle Nothing or Failure scenarios where in JavaScript you can just "oh it's a happy path, if it fails, that's ok"

Ok, now you said the mutating word, it's too early in the morning... I can't go on. :: hugs immutability coffee cup ::

Collapse
mjoycemilburn profile image
MartinJ

Please forgive my ignorance but I'd really appreciate advice on the nature of the exceptions you're aiming to catch.

I see plenty of exceptions thrown by my own rubbish code during Javascript and PHP development, but these get sorted out during testing. I'd have thought that Production systems should surely see only network exceptions.

However, I mostly find myself using Fetch() and here network error are returned as an HTTP error status. Accordingly, I wouldn't expect them to trigger a catch.

As a matter of fact, I do currently use the pure promise style rather than await and do attach a catch block at the end of my .then stacks. But I've yet to see one triggered!

I think I must be missing somthing and I'd really like to know what it is!

Collapse
jesterxl profile image
Jesse Warden Author • Edited

Caveat: Most of this is with node-fetch in Node.js, not fetch in the browser. But similar things happen.

The first ones are like you said, during testing, like your parameters are messed up. You requested a person ID, but the person ID is null, so the entire function breaks.

The second ones are when you get back stuff that isn't JSON, so when response.json() runs, it breaks. At large companies this could be your firewall or WAF, or when the back-end freaks out and instead of sending JSON back, the nginx server sends an HTML error page.

The third ones are a mixup of the above. Sometimes you'll get a statusCode of 401 for example, but the response is in JSON giving you more details about it. However, your code gives back a bogus JSON response and that triggers other null pointers because they were expecting a good JSON response, not an error one.

The fourth is when all happy path is good, and the API changes something about the JSON; your fetch code works, but the code around it fails. You think it's the fetch' fault, but it's actually the API changing the schema. If you have schema validation (like ajv or something), this can help.

Occasionally I'll learn of a new one where my certs are wrong deployed to AWS and the fetch will throw "because https weirdness", or occasionally I'll get a 500 from the upstream and didn't handle the response correctly (text vs. JSON vs. html).

Those last ones are what kill me. The ones above I can convert all to pure functions, including data parsing where they never throw, just return a Result.Error vs. a Result.Ok. I like it better how in Elm has solidified it into 5 return values, and you can code to that interface, and you know exactly where the prolbem lies: with you or the back-end.

type Error
    = BadUrl String
    | Timeout
    | NetworkError
    | BadStatus Int
    | BadBody String
Enter fullscreen mode Exit fullscreen mode

It could just be you've worked with stable back-end systems, mine are either green field and changing, or have a lot of technical debt. Locally, things can be different because of strict proxy.

Collapse
mjoycemilburn profile image
MartinJ

Thanks Jesse, I really appreciate that. As you've detected, I'm working generally in stable (and low stress) areas but I've certainly sen the JSON problem often enough now and will concentrate on handling that one in future. Thanks again for your time and consideration, MJ