Effect-TS / effect

An ecosystem of tools to build robust applications in TypeScript
https://effect.website
MIT License
7.64k stars 244 forks source link

Async documentation is a little tricky to understand #3563

Closed tmcw closed 1 month ago

tmcw commented 2 months ago

What is the type of issue?

Documentation is confusing

What is the issue?

I'm reading through the documentation for modeling asynchronous effects, and I find the ordering and structure a little tricky at first:

The intro states:

In traditional programming, we often use Promises to handle asynchronous computations. However, dealing with errors in promises can be problematic. By default, Promise only provides the type Value for the resolved value, which means errors are not reflected in the type system. This limits the expressiveness and makes it challenging to handle and track errors effectively. To overcome these limitations, Effect introduces dedicated constructors for creating effects that represent both success and failure in an asynchronous context: Effect.promise and Effect.tryPromise. These constructors allow you to explicitly handle success and failure cases while leveraging the type system to track errors.

The first documented API is Effect.promise, which looks promising (sorry), but only applies if you have a promise "where you're confident that the asynchronous operation will always succeed". Promise rejections are "defects" if you use Effect.promise. So it seems very limited in usefulness, and doesn't fulfill the enticing intro about how we're going to use the type system to track errors, because we can't track errors with Effect.promise, at least as documented.

Next we've got Effect.tryPromise. This lets us handle rejections, but then says:

By default if an error occurs, it will be caught and propagated to the error channel as as an UnknownException.

So, again, not really using the type system to represent types of rejections. There's a bit about "Customizing Error Handling", which would let me re-map errors, but what if I have a known error that I'm throwing - I guess I need to do an instanceof check in the catch method?

Below this, I see the docs for handling callbacks, which look a lot like the API that I'd want, but they're described as a way to bridge the gap for callback-based code, rather than a way to do things with async.


It seems like using Effect.tryPromise, with the custom catch handler, is the way to do asynchronous computation with typed errors. But the ergonomics seem a bit weird, and it's confusing that the docs kind of hide it, despite the pitch being centered around typed errors for asynchronous work.

Where did you find it?

https://effect.website/docs/guides/essentials/creating-effects#modeling-asynchronous-effects

tim-smart commented 2 months ago

There's a bit about "Customizing Error Handling", which would let me re-map errors, but what if I have a known error that I'm throwing - I guess I need to do an instanceof check in the catch method?

The problem is you can't have known errors with Promise's, so you have to either cast the unknown if you are certain that the Promise only rejects with a certain type of error, or perform runtime checks. Or you can just leave it as unknown and put it in a cause of another error type.

mikearnaldi commented 2 months ago

Part of the reason Effect exists is precisely to put guards around unexpected JS behaviour. As Tim mentioned you can't infer what a Promise throws I'd say a few things:

1) Effect.promise use case is not limited in use, you should track in the type system only Recoverable errors so for the majority of cases unexpected errors will fall under defects.

2) Effect.tryPromise by default can't infer what was thrown, so it's rightfully an UnknownError that you can re-map, also you can use the catch clause to do your error mapping. See https://github.com/microsoft/TypeScript/issues/13219#issuecomment-1515037604 for a proper way to handle throws from generic thunks of code, the linked code applies to Effect as well:

Effect.tryPromise({
  try: () => ...,
  catch: (e) => {
    if (e instanceof TypeError) {
      return e
    } else {
      throw e;
    }
  }
})

3) Effect.async is a way to bridge the gap to callback-style code, if you have a Promise using Effect.async won't yield any advantage over Effect.tryPromise only added verbosity, if you do have a callback-style API it is usually better to use it, for example Node's old callback based APIs had the error explicitly typed so in that case you can leverage it directly

4) Mapping external errors to domain errors is a good practice regardless, typed errors are supposed to be recoverable so it is assumed you will have control-flow over them, to simplify dealing with those the standard is to use tagged errors, so even when you don't know what happened in details you get a higher level idea, for example:

import { Data, Effect } from "effect"

class FetchError extends Data.TaggedError("FetchError")<{
  cause: unknown
}> {
  message = "Something wrong with fetch call"
}

// (id: number) => Effect<any, FetchError, never>
const fetchTodo = (id: number) => Effect.tryPromise({
  try: () => fetch(...),
  catch: (cause) => new FetchError({ cause })
})
tmcw commented 1 month ago

Thanks! I think that makes more sense - having read a bunch more code that uses Effect, it seems like in cases like mine I'll want to wrap library functions that might throw exceptions into tryPromise blocks. When I 'know' what the exceptions can be thrown it's a little annoying to test their types and re-identify them in tryPromise, but I guess it's worthwhile as part of the tradeoffs.