The Task Type Signature

Is there a particular reason that Task is typed Task err value instead of Task value? Just kind of seems like it makes it more complicated and introduces more concepts when it doesn’t need to.

Ways this appears to complicate things:

  • Requires Task Never value when an error is impossible rather than requiring Task (Result err value) when an error can occur. This requires at least some understanding of the Never type.
  • Requires re-implementing functions that already exist for the Result type like mapError and onError.
  • Requires both attempt and perform functions. Task (Result err value) would only require perform.

Maybe it’s a performance or implementation detail? Or maybe it really is simpler the current way for new users?

Edit: Seems to be a bit of confusion about what I meant. As a real-world example from another community, Rust recently incorporated Futures into its standard library. The implementation was based on Tokio futures, which are the de-facto community standard and have the type Future<E, T> mirroring the Elm Task err value type. They typically represent futures that have no failure state by making E the unit type. However, in the interest of making the standard library as small and simple as possible, they opted to use the signature Future<T> instead, and represent futures that can fail with Future<Result<E, T>>. This is the same basic idea that I’m taking about.

Just to be clear I’m not claiming Elm should implement something just because Rust did. I’m just curious if there are any specific reasons for using Task err value rather than Task value when (subjectively I guess) Task value is simpler and requires fewer ideas.

1 Like

How will you handle task failure otherwise? Elm is designed around explicit handling of problems.

There’s Maybe a which suits your needs. Perhaps your question is “why did some functions choose to return Task x a Instead of Maybe a

Task failure would be handled with Task (Result err value).

Instead of the task itself knowing about the failure case, it would be part of the Task’s eventual value if necessary. This is what Result is already very good at. If an error case was not necessary, the Task’s eventual value just wouldn’t be a Result value. (It would be Task value rather than Task (Result err value)) This would avoid the Never type, which granted isn’t that complicated but is still another thing to learn about.

1 Like

No, Task represents potentially asynchronous operations. Maybe does not.

it seems Elm prefer more direct apis than a smaller set of generic apis, e.g. map

As an example if you had Task (Result err value) and encoded the failure of the task in the Result err value then the functions Task.andThen and Task.map would receive a Result err value instead of just a value. They would need to handle the possibility of failure. Currently the functions passed to Task.andThen and Task.map aren’t called on task failure so they never have to handle that.

This would mean that when using Task.map or Task.andThen with a Task that could fail you’d need to always compose it with Result.map. Since this would be a common thing to do someone would probably publish a package called FallibleTask that provided a type called FallibleTask err value and a bunch of functions that compose Task.map with Result.map etc.

13 Likes

All true. It goes both ways though. Because Task.map and Task.andThen explicitly separate the error type it becomes more difficult to deal with both at once. Here is a (real) function I have defined in one of my Elm applications:

andThenBoth : (Result err data -> Task newErr newData) -> Task err data -> Task newErr newData

When I need to deal with both conditions to produce the next task to execute, I need another Task function that joins them back into something that can be handled at once. This could theoretically be defined in a NonFaillibleTask package.

Further, the current behavior of Task.map and Task.andThen could be kept even with the Task value signature I’m asking about. Just define them like:

  • map : (value -> newValue) -> Task (Result err value) -> Task (Result err newValue)

Even if Task did use the Task value type signature I think this would be the best way to define map since it appears to be the most common use case. Maybe with a different name though.

I still think Task value would be simpler overall, but that’s neither here nor there. Seems like your reasoning is the most likely answer to my question. Thank you!

3 Likes

I think the more relevant thing here is that the Elm runtime has to sanctify a particular representation of failure which Rust does not have to do. If you embed the failure in the value type (Task (Maybe a), Task (Result err a), etc.), Elm has to pick exactly one of those representations to be the golden standard. Not that this is impossible or that there aren’t alternatives, but the existing API seems reasonable and avoids doing that.

Btw, Result err a and Task err a are essentially the same thing, it’s just that Result is a very visible type while Task is an opaque type representing interaction with the outside world. Interaction with the outside world can inherently fail, so the err type variable should be needed more than not.

If the type is Task a, and errors are represented using Task (Result x a) then it’s no longer possible to conveniently chain Tasks with Task.andThen. Consider the following under the current API:

doFirstRequest
  |> Task.andThen (\value -> doSecondRequest value.id)
  |> Task.andThen (\value -> doThirdRequest value.name)

If the type we are dealing with is Task (Result Http.Error a) then the chain looks like this:

doFirstRequest
  |> Task.andThen
      (\result1 ->
        case result1 of
          Err err1 -> Task.succeed (Err err1)
          Ok value1 ->
            doSecondRequest value1.id
              |> Task.andThen
                  (\result2 ->
                    case result2 of
                      Ok value2 -> doThirdRequest value2.name
                      Err err2 -> Task.succeed (Err err2)
                  )

You can write a helper function to help with this unwrapping and rewrapping, but then you’re back to introducing more concepts because people will want to know how that function works and why you need it at all. With Promises in JS you can just do .then(), so why do I need this traverse function in Elm just to chain Tasks together? Etc. The issue with this is that Task.andThen and Task.map are the majority use case for working with Tasks, and it totally overtakes the complexity of sometimes having to deal with Never.

I suspect that in Rust this doesn’t become a problem because of syntax designed for dealing with Futures and unwrapping Results in a nice way.

2 Likes

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.