DEV Community

Discussion on: Why I Don't Use Async Await

Collapse
 
jesterxl profile image
Jesse Warden

Sorry for the delay. It basically comes down to "What comes out of the then?"

There are basically 5 things you can do there, each with pro's and con's.

Helpful Error Messages

If we "embrace JavaScript", and also "embrace Promise chains" in the spirit of functional programming where your compose functions together, then the easiest, low-hanging fruit thing you can do is just more helpful error messages.

Let's take this block of code as an example:

 someHttpCall()
 .then( response => response.json() )
Enter fullscreen mode Exit fullscreen mode

As a back-end in engineer, there are a lot of things that could go wrong there, but as a front-end engineer, you really only care about 2 types of errors: If it's your fault, or the server's fault. So let's outline all the things that can go wrong:

  • "network problems": you can't make someHttpCall() work because you have no internet, internet but it's failing, internet but the server is having connection issues. This isn't your fault, BUT if you know this, then you can at least inform both yourself in the console, as well as the user (whether front-end browser or someone using your API) that your're having connection issues; maybe they could help by checking internet or VPN status.
  • "bad url": your http request, specifically the URL, is messed up. Either http vs. https, or you mispelled something, or are missing the dot com/net, etc. You can lump this under network problems, but this is technically your fault even if you're pulling this config from somewhere else, so your error message should be clear there IS something you can proactively do to fix the code, but not retry the request.
  • "timeout": If you're using retries or cancellable requests, while technically a "network problem", you CAN pay the cost of forcing the user to wait longer IF you're willing to retry. Sometimes there are blips and retries work which results in more likely to work code. This can be hard, I get it, but at least you know "My network is good, my URL is good, but I never heard back...."
  • "server mad": This could be anything from 100's, to 300's to 400's to 500's; basically "I didn't get my data back, the server is mad for some reason". You could say 400's are a client's fault, but server's aren't perfect; sometimes it's an invalid 400. Either way, if you think you're in the right, you need to log that you have a valid session so you can more easily debug with the server dev later.
  • "parsing issues" This is primarely what we'll be talking about below. Basically the only real thing that's our fault often; we attempted to parse something from the back-end, and it failed. Yes, they may have changed their API and not told us, or perhaps their JSON is f00barred, who knows, but bottom line, they claim they sent us data, but we couldn't understand it. This goes beyond just JSON.parse failing. There's a lot of work involved here.

So let's give an example of this. In that above code, you'll get all of those types of errors in your .catch. If the url is broken, if the server gives you a 500, if things timeout, etc., all will go to your catch. However, they're not always helpful. For example, simply throwing your own errors after listening someHttpCall() will help you debug it: "These errors came from the someHttpCall function, they did NOT even get to the response.json() call." That alone can save you minutes to errors figuring things out. So something like:

const getResponse = () =>
  someHttpCall()
  .then(
    response => {
     if(response.ok === false) {
        return Promise.reject(
          new Error(`We failed to make the http call, got a status code ${response.status} back, reason: ${response.statusText}`)
        )
     } else {
       return response
     }
   }
  )
Enter fullscreen mode Exit fullscreen mode

So that's a start and MUCH more helpful then a stack trace you have to wade through. Logging response is isn't really helpful because it's huge, has a lot of properties, and none really help you debug. This can go much deeper:

  • status code of 201 is fine, but 200 is wrong because it's a POST, what is going on
  • status code of 400 could actually be ok, maybe you just need to re-authenticate then try again? Is that possible? No?
  • status code of 429 could mean you just need to wait a few seconds and retry, negating the whole error

And many more:

  • if the response has headers, and those headers have a content type: response.headers.get('content-type') AND that type is json, then you're hitting an API that could send back errors as JSON, which some can do. However, there's no guarantee that will work. Do you care? Well, yeah. The server said it failed, but when we tried to get something beyond response.status and response.statusText, it sent back an unparseable error message? That clearly is something the server dev needs to fix.

You can see, all of this information is just generically thrown in a catch right now. You can invest the effort to make safe pure functions to process these responses and validate if they're good or not (e.g. if response.ok === true) and explain why they're not. Most of this is for you, but eventually it'll be the user (UI and/or API).

Take that example code I showed above inspecting response.ok, response.status, and response.headers.get('content-type') and put into tested pure functions. You can delete the tests after you're done since those are typically private functions, but it can help you learn.

Got it? Ok, let's talk custom.

Custom Error Messages

So the above is great, but super hard to take action on in your code. Meaning you'll post 12 different types of things that can go wrong, and you just have to look in 1 place: the .catch.

That's great, except your code still fails. Meaning, you'll get a failure, the code will log the error, you'll read the error in the logs, possibly fix some code, and redeploy. That really doesn't help the user much.

Additionally, if you're an orchestration API or a Back-end For Front-end calling other API's, you need to respond with some type of response http status code.

For example, if you're calling some microservice from your API, and it never responds or breaks somehow, you'll probably want to respond with a 504 or 500; "It's not my fault, it's some dude upstream I'm calling, but... I failed you, sorry; since they broke, we all broke."

How do you know that for sure? In "Better Error Messages", all you get is easier to quickly understand error messages. That means, in your catch, you can read error.message, and it's more helpful than normal. But that's too hard to do things with. Strings are powerful, but types are more powerful. Better to create custom error types so you can make decisions on some of them. For example, the simplest way is to simply create them:

class NetworkError extends Error {}
class TokenExpiredError extends Error{}
class ParsingError extends Error {}
class ThrottlingError extends Error {}
Enter fullscreen mode Exit fullscreen mode

Suddenly, instead of using a generic, but yes helpful, error:

return Promise.resolve(new Error("We got a 500 status code from the data microservice we're calling, they said 'network problem', but we couldn't parse their error message, not sure what's going on."))
Enter fullscreen mode Exit fullscreen mode

We do the same thing, but with a slightly different constructor:

return Promise.resolve(new NetworkError("..."))
Enter fullscreen mode Exit fullscreen mode

Same text message, but now it has a type. This allows us to intelligently do things based on what type of error message we get in the catch by pattern matching against it. If you use the standard JavaScript, it's:

 .catch(
  error => {
    if(error instanceof NetworkError) {
      return Promise.reject(error)
  } else if(error instanceof TokenExpiredError) {
    return attemptToReLogin.then(() => someHttpCall()) // <-- recursive retry
  } else if (error instanceof ThrottlingError) {
    return delay(1 * 1000).then(() => someHttpCall()) // <-- wait 1 second, then try again
  } else {
    // can't really recover from parsing error
    return Promise.reject(error)
  }
)
Enter fullscreen mode Exit fullscreen mode

Someday we'll be able to more powerfully pattern match on both the the request responses AND the errors, but until then, this'll at least make writing things in Express.js/Restify/Serverless a lot easier.

Go Lang Style Error Handling

The 3rd option is Go lang style of error handling where NO function ever throws, rather it returns if it worked or not. In JavaScript, that's:

let { ok, error, data } = await someHttpCall()
Enter fullscreen mode Exit fullscreen mode

Using await there is fine because someHttpCall will never throw; it has a try catch inside or a Promise you know won't reject; instead you return some value:

const someHttpCall = () =>
  fetch("url")
  .then( handleResponseOrReturnError )
  .catch(
    error =>
      ({ ok: false, error })
  )
Enter fullscreen mode Exit fullscreen mode

Using the let keyword, you can then write super imperative code like Go lang does to have async/await style imperative error handling, but completely bypass the need for try/catch. You'll still use instanceof to check for errors.

Result

The 4th way is more functional; it's like Go Lang, but you return a Result type out. JavaScript is pretty flexible, so there are a TON of ways to do this, each having pro's and con's. The easiest I've found is Folktale's Result. It even has a utility Result.try method for unsafe code. You basically do the same thing you did in the Go Lang style, but instead of returning an Object in the shape of { ok: true | false, data: "whatever", error: SomeErrorType }, you instead return a Result. A Result that worked will be a Result.Ok with some data in it. A Result that failed will be a Result.Error with a message in it. However, it may help to have a Result.Error actually be wrapped in a parent type so you can pattern match on the result like you did in the class type example. This is really hard to do in JavaScript alone; you need a library like Folktale that allows creating custom Union types. Check out the Folktale docs for Union, Quinn talks about custom errors for File failures in Node.js as an example: folktale.origamitower.com/api/v2.3...

Types

Using Result is cool, but SUPER hard in JavaScript because you start getting into strong types without a compiler to help you. Using the class NetworkError extends Error is easy because "they're all errors, they all arrive at the parent .catch, and we know which 4 we're getting so we can write instanceof". That doesn't really scale, though, in larger code that has MANY more functions with many things that can go wrong.

In larger code bases, where you have MANY types of Results that can blow up, it's better to type them, and used a typed langauge like TypeScript, ReScript, Elm, etc. That way, you know what type you're getting out. In TypeScript, you can pattern match using a switch statement. In ReScript & Elm, you can... pattern match on the errors. It allows error handling on a per function basis, and you can then wire them all together using Promises or pipes |> and the compiler will tell you what types of failures come out. MUCH more helpful.

That way, when the 4 errors of someHttpCall are wired up with the 4 additional errors that can come out of fetchUserDetails, your compiler will let you know which one of the 8 came out and give you an opportunity to do things for the ones you can know you can re-login or retry on.

Collapse
 
he_zhenghao profile image
Zhenghao He

also I am curious how you can distinguish the errors between bad url and bad internet? It seems really hard to tell them apart...
Also I am not super clear on the last section where you mentioned that "you know what type you're getting out" by type you meant the type for difference error e.g. NetworkError or TokenExpiredError so you can do exhaustive pattern matching?

Thread Thread
 
jesterxl profile image
Jesse Warden

Assuming all custom Error classes you make only extend the Error base class, then you can be confident in instanceof. If you're just doing regular JavaScript, the whole big if statements of:

if (thing instanceof OneClass) {
...
} else if (thing instanceof OtherClass) {
...
}
Enter fullscreen mode Exit fullscreen mode

That's fine; your name check works too. However, it's unsafe and you have to do by hand. Using something like TypeScript or ReScript will give you a compiler that can help you ensure you're:

  • spelling them right
  • ensuring your're checking for the correct properties
  • you've captured ALL possible errors
Collapse
 
he_zhenghao profile image
Zhenghao He

wow thanks for the explanation! But I still don't quite understand when you said "you'll post 12 different types of things that can go wrong, and you just have to look in 1 place: the .catch." - wouldn't we be able to achieve the same thing with async/await as well? Like

async function fetchUser() {
  try {
    const user = await someHttpCall()
    const details = await fetchUserDetails(user)
    return displayUserDetails(details)
  } catch (error) {
    switch(error.name) {
      case 'HttpCallError':
        console.log('Networking error:', error)
        break

      case 'UserDetailsError':
        console.log('Fetching user error', error)
        break
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
jesterxl profile image
Jesse Warden

Absolutely, but that code is imperative. In functional programming, we don't create a bunch of variables which leads to accidental side effects, even if you are just in 1 function. We instead have a pure function, and it either returns a Result.Ok or a Result.Error. You then wire those together in 1 pure function, and that 1 pure function returns a Result.Ok or Result.Error. If you have a type system, you can break those various errors down into multiple types. It doesn't have to be 12, no doubt, I just mean, there possible 12 things that could go wrong, and maybe 4 of them you could react too and possibly retry/etc.

Again, nothing wrong the imperative example you showed; many like that and it works, and if you take the effort to throw more helpful, informative errors, that style can help you debug quickly too.

Thread Thread
 
he_zhenghao profile image
Zhenghao He

hey thanks for the reply. Two followup questions if you don't mind

  1. if we have TS or any other type system avaiable, is using the Result type still relevent or we can just use typed Error instances.
  2. "if you take the effort to throw more helpful, informative errors that style can help debug quickly too" please correct me if I am wrong but it sounds to me that you were implying that it would take more effort to throw helpful informative errros using imperative approach than the functional one. Can you maybe elaborate a little bit more on that?
Thread Thread
 
jesterxl profile image
Jesse Warden
  1. lol, that's a rabbit hole. If you're cool with imperative or OOP, it can help a lot, but most imperative/OOP developers don't return Errors from functions, they throw them. You can use never which is helpful in that it will throw something, but TypeScript doesn't have throwable like Java. So the compiler can help you a little bit and say "Oh man, this function doesn't return a value, you should probably wrap this with a try catch". And yeah, if you extend Error, then yeah, you'll get way more help from the compiler returning different types of Exceptions.

... but that doesn't really make sense. If you're willing to do the whole Exception dance, then you are cool with try/catch, and should just either only use it in exceptional circumstances like the OOP and imperative kids do.

If, however, you want to join the church of pure functions, then it's better to return a Result. The types in TypeScript are a bit tough, but I've seen a few type and interface implementations that do help. For example, an Array in TypeScript will use like Array<string> or [string] style syntax. That's helpful for Result because some of the libraries will do Result<boolean> which is kind of cool.

Either way, we're kind of at the limits of JavaScript here; deviating from throw and try/catch is not normal JavaScript. Doing Functional Programming in a non-functional language is hard, and you start getting into these weird situations where you "think of returning an Error from a function instead of throwing it" or "wrapping a failure in an Object and returning it". If you're a functional programmer, it all makes sense, AND when you're in a different land, you try to make it work. Not everything makes sense because the langauge doesn't make sense; it allows things that are "wrong"; wrong being against FP rules. Exceptions are in this weird middle ground; some people think exceptions in Haskell are ok; others don't. Either way, Result is kind of normalized in that world, so that's what we do.

Using types helps a TON once you start composing functions together, though. Even just regular promises using variadic tuples.

  1. Well... sort of. I think it's hugely worth spending the time to help. Like fetch is great, but you get this stack trace, and it's not always obvious why it failed. So spending the effort to be a bit more obvious will help you a ton when things start blowing up.

You still have these problems in functional languages, though. Like if you try to JSON.parse safely, with types, a response from the server and it fails, by default you might get "Failed to parse JSON." That is completely worthless and frustrating. Better to be "Attempted to parse a string firstName, and found a field called firstName, but it was null". NOW we're getting somewhere. So you can create crap errors or good errors; best to spend the effort. It's just in functional programming, it's wayyyyy more obvious where it came from: the function vs. "somewhere in the function". Although, when you start composing them together, that's not really helpful if you have a 30 function Promise chain, "Where in this chain did this error come from?".

However, I wouldn't say more effort, just that its' SUPER easy to screw up in imperative land. For example, this is ok in JavaScript (I'm not sure about TypeScript):

if(thing) {
  return true
}
return
Enter fullscreen mode Exit fullscreen mode

In a typed language, they'd prefer something like:

if(thing) {
  return true
}
return false
Enter fullscreen mode Exit fullscreen mode

However, you could later go:

if(thing) {
  return true
}
if(otherThing){
  return "something"
}
return false
Enter fullscreen mode Exit fullscreen mode

That kind of problem would never surface in something like Elm or ReScript; you're not allowed to omit the else, nor miss some of the elses, and the types must be the same. Imperative style code like that can get you into trouble. If you don't have those types of scenarios, then it makes it a lot easier to write more specific errors. If you go play in Elm or Haskell, you'll see what I mean. ReScript and F# are a bit more flexible, and you can run into the same types of problems where you have large functions that can do too many things.