gcanti / fp-ts

Functional programming in TypeScript
https://gcanti.github.io/fp-ts/
MIT License
10.76k stars 503 forks source link

[Feature Request] Error Handlers for Map Left #1360

Closed ryanleecode closed 3 years ago

ryanleecode commented 3 years ago

🚀 Feature request

Current Behavior

If I have a TaskEither and I want to handle an error in mapLeft with another TaskEither, I can't without adding my own utility function.

Desired Behavior

Add a utility function to TaskEither and Either that lets you handle left with another TaskEither/Either.

Suggested Solution

Add a function like this to TaskEither

const handleError = <E, U>(fe: (e: E) => TE.TaskEither<E, U>) => <A>(
  te: TE.TaskEither<E, A>,
): TE.TaskEither<E, A> => {
  return pipe(
    TE.taskEither.map(te, TE.right),
    TE.swap,
    TE.chainFirstW(flow(fe, TE.mapLeft(TE.left))),
    TE.swap,
    TE.flatten,
  )
}

Usage

pipe(
  TE.right(123),
  TE.chain(() => TE.left(new Error('oh no!'))),
  handleError(flow(Console.error, (io) => TE.fromIO(io))),
)

Who does this impact? Who is this for?

TypeScript users

Your environment

Software Version(s)
fp-ts 2.9.0
TypeScript 4.1.2
gcanti commented 3 years ago

@ryanleecode looks like orElse

EDIT: link

ryanleecode commented 3 years ago

Yeah but orElse takes the return value of my error handler which I don't want. I need something like a orElseFirst if that makes sense.

ryanleecode commented 3 years ago

This doesn't compile

pipe(
  TE.right(123),
  TE.chain(() => TE.left(new Error('oh no!'))),
  TE.orElse(flow(Console.error, TE.fromIO)),
  TE.map((x: number) => `${x}`),
)

but this would

pipe(
  TE.right(123),
  TE.chain(() => TE.left(new Error('oh no!'))),
  handleError(flow(Console.error, TE.fromIO)),
  TE.map((x: number) => `${x}`),
)
mohaalak commented 3 years ago

When you write TE.chain(() => TE.left(new Error("oh no!')) you are changing your data, you no longer have 123 in right position you have a left Error, so when you want to handleError you should return a new number from it.

pipe(
  TE.right(123),
  TE.chain(() => TE.left(new Error("oh no!"))),
  handleError(flow(Console.error, (io) => TE.fromIO(io))),
  TE.map((x: number) => `${x}`)
)().then(console.log);

this will not print Right 123 it just print Left Error.

if you want to write your error inside console you should write it in the last place

pipe(
  TE.right(123),
  TE.chain(() => TE.left(new Error("oh no!"))),
  TE.map((x: number) => `${x}`)
)().then(E.fold(console.error, console.log));

or if you want to pure if you are handling Error you could just fold TaskEither to Task with TE.fold

pipe(
  TE.right(123),
  TE.chain(() => TE.left(new Error("oh no!"))),
  TE.map((x: number) => `${x}`),
  TE.fold(flow(Console.error, T.fromIO), flow(Console.log,T.fromIO))
);

Now this TaskEither will return a Task<void>. The whole point of TaskEither is that you can know from the return type that if your Task have error or not and frankly you did not handle error here.

ryanleecode commented 3 years ago

Fold doesn't work if I want to use a TaskEither instead of a Task. For example, instead of logging I can send the error over a network to a service like Sentry. Sending over a network could return an error as well.

kylegoetz commented 3 years ago

Let's get away from the code. You want to:

  1. perform an async action that could fail with type E or succeed with type A
  2. if #1 fails, perform an async action that could fail with type F or succeed with type B
  3. if #1 succeeds, map A to C. But if #1 fails, then it should still map A to C. But if #1 fails, then #2 will feed a type B to #3, which is a type error and why you're getting the compile error.

So step 1, you have a TaskEither<E,A>. Step two transforms that into TaskEither<F,B>. But step 3 expects a TaskEither<unknown,A> to yield TaskEither<unknown,C>.

That is why what you're asking for cannot be done, and why @mohaalek said you should have your orElse return a number. That way step 2 becomes "perform TaskEither that could fail with type F or succeed with type A. Then your step 3, which maps TaskEither<unknown,A> to TaskEither<unknown,C> will be copacetic.

It's unclear what you want to happen. Step 3 is the code for what to happen if step 1 succeeds. If step 1 fails, why do you want to continue with step 3 after logging the error? The only way this makes sense is if your error handler can log the error and then provide a sensible default for step 3 to handle as if step 1 had succeeded. Hence, again, @mohaalek suggestion you have your orElse return a number.

Here's how I would implement this:

interface HttpError {}
interface HttpResponse {}
declare const logErrorToRemote: (e:unknown) => TE.TaskEither<HttpError, HttpResponse>
const SENSIBLE_DEFAULT = 5
const logAndReturnSensibleDefault = flow(
  logErrorToRemote, 
  TE.map(_httpResponse => SENSIBLE_DEFAULT))

pipe(
  TE.right(123),
  TE.chain(() => TE.left(new Error('oh no!'))),
  TE.orElse(logAndReturnSensibleDefault),
  TE.map((x:number) => `${x}`)
) // TaskEither<HttpError, string>
ryanleecode commented 3 years ago

Lets forget about the map function. I think we are overemphasizing it here.

The point is you can't handle an error in mapLeft of a TaskEither using another TaskEither without changing the type signature of the right side. The only way you can do it is at the very root of your function hierarchy and mapping everything to void.

You can argue that all errors should be handled at the root rather than at the local of the function -- but it shouldn't be mandatory.


Re: Sensible defaults

Adding a default is the usecae of the orElse function but thats not my usecase. I don't want to set a default. And you can't always set a default. What if the right side is a 3rd party class/object that you have no control over?

kylegoetz commented 3 years ago

Lets forget about the map function

Without the map function, that code that you said does not compile does indeed compile.

The point is you can't handle an error in mapLeft of a TaskEither using another TaskEither without changing the type signature of the right side.

Yes you can: you make sure your new TaskEither returns the same type.

The only way you can do it is at the very root of your function hierarchy and mapping everything to void.

Untrue.

F.pipe(
  TE.of(5),
  TE.chain(() => TE.left('Error!')),
  TE.orElse(e => TE.of('something'))
)() // Promise resolving to "something"

F.pipe(
  TE.of(5),
  TE.chain(() => TE.left('Error!')),
  TE.orElse(e => TE.left(false))
)() // Promise resolving to false

No mapping to voids, but I am changing types on both right and left side.

What if the right side is a 3rd party class/object that you have no control over?

Then after receiving that, you do whatever you need to with it, and then map it to something you do have control over.

I think you're missing something fundamental regarding how pipe works:

pipe(
  stepOne,
  TE.chain(stepTwo),
  TE.orElse(stepThree),
  TE.map(stepFour),
  TE.chain(stepFive),
)

The structure of this code, with no explicit types, implies the following facts:

  1. assume stepOne returns TE<E,A>
  2. stepTwo must take an A as its parameter. Assume it returns TE<F,B>
  3. stepThree must take E|F as its parameter. Assume it returns TE<G, C>
  4. stepFour must take B|C as its parameter. Assume it returns D
  5. stepFive must take D as its parameter.

If stepOne fails, stepTwo never executes. stepThree will execute. If stepThree succeeds, stepFour will execute and then stepFive. So stepFour must take the return type of stepThree.

If stepOne succeeds, stepTwo executes. If stepTwo succeeds, stepThree will never execute. Step four will execute. Therefore stepFour must take the return type of stepTwo.

Therefore, stepThree and stepTwo must have the same return type or alternatively stepFour must take a sum of both types and handle both.

What you're asking for appears to be something that cannot be done with TaskEither not because of how it's written currently, but because it is prohibited by monad laws.

ryanleecode commented 3 years ago

No mapping to voids, but I am changing types on both right and left side.

You're just using orElse in this example. Of course you can substitute any value. I am referring to @mohaalak example where he is folding and because the left side of the fold resolves to void, the right side must as well.

pipe(
  TE.right(123),
  TE.chain(() => TE.left(new Error("oh no!"))),
  TE.map((x: number) => `${x}`),
  TE.fold(flow(Console.error, T.fromIO), flow(Console.log,T.fromIO))
);

What you're asking for appears to be something that cannot be done with TaskEither not because of how it's written currently, but because it is prohibited by monad laws.

What monadic law is being broken here?

What I'm asking for here is the same as onError from the scala cats module.

https://github.com/typelevel/cats/blob/master/core/src/main/scala/cats/ApplicativeError.scala#L195

Example:

import cats.data._
import cats.effect._
import cats.implicits._

object Main {
  def main(args: Array[String]): Unit = {
    case class Err(msg: String)
    type F[A] = EitherT[IO, Err, A]

    val f = for {
      _ <- EitherT
        .liftF[IO, Err, Int](IO { 123 })
        .onError({
          case Err("oh no!") =>
            EitherT.liftF(IO {
              println("if only I could fail...")
            })
        })
      _ <- Err("oh no!")
        .raiseError[F, Int]
        .onError({
          case Err("oh no!") =>
            EitherT.liftF(IO {
              println("it failed!!!!")
            })
        })
      c <- EitherT.liftF[IO, Err, String](IO { "123" })
    } yield c

    f.value.unsafeRunSync()
  }
}
gcanti commented 3 years ago

@ryanleecode if I understand correctly you want the following behavior

import { flow, pipe } from 'fp-ts/lib/function'
import * as TE from 'fp-ts/TaskEither'
import * as E from 'fp-ts/Either'

const handleError = <E, U>(fe: (e: E) => TE.TaskEither<E, U>) => <A>(te: TE.TaskEither<E, A>): TE.TaskEither<E, A> => {
  return pipe(
    TE.taskEither.map(te, TE.right),
    TE.swap,
    TE.chainFirstW(flow(fe, TE.mapLeft(TE.left))),
    TE.swap,
    TE.flatten
  )
}

// ---------------
// tests
// ---------------

import * as assert from 'assert'

async function test() {
  const a1: TE.TaskEither<string, number> = TE.right(1)
  const a2: TE.TaskEither<string, boolean> = TE.right(true)
  const e1: TE.TaskEither<string, number> = TE.left('a')
  const e2: TE.TaskEither<string, number> = TE.left('b')
  assert.deepStrictEqual(
    await pipe(
      a1,
      handleError(() => e2) // ignore the handler because a1 is a Right
    )(),
    E.right(1)
  )
  assert.deepStrictEqual(
    await pipe(
      e1,
      handleError(() => a2) // ignore the handler result because is a Right, so just rethrow e1
    )(),
    E.left('a')
  )
  assert.deepStrictEqual(
    await pipe(
      e1,
      handleError(() => e2) // ignore e1 because the handler result is a Left, so rethrow e2 instead
    )(),
    E.left('b')
  )
}

test()
ryanleecode commented 3 years ago

Essentially, yes

gcanti commented 3 years ago

Ok, then handleError is derivable from orElse

import { pipe } from 'fp-ts/lib/function'
import * as TE from 'fp-ts/TaskEither'

const handleError = <E, B>(f: (e: E) => TE.TaskEither<E, B>): (<A>(te: TE.TaskEither<E, A>) => TE.TaskEither<E, A>) =>
  TE.orElse((e) =>
    pipe(
      f(e),
      TE.chain(() => TE.left(e))
    )
  )
thewilkybarkid commented 3 years ago

orElseFirst and orElseFirstW are now in 2.11 (https://github.com/gcanti/fp-ts/pull/1463); but for logging orElseFirstIOK/ orElseFirstIOKW would be ideal.