I have a question about the behavior of the effect DSL for Either vs Ior. It seems to me like there ...
j
I have a question about the behavior of the effect DSL for Either vs Ior. It seems to me like there might be an inconsistency, but I wanted to make sure I wasn't simply misunderstanding something. Lets start with Either. If I do the following test I get the result that makes sense to me:
Copy code
@Test
fun eitherAttempt() = runTest {
    val success: Either<String, Int> = 1.right()
    val failure: Either<String, Int> = "fail".left()

    val actual = either {
        val test = attempt { failure.bind() } catch  { "fail" }
        "${success.bind()} : $test"
    }

    assertEquals(
        expected = "1 : fail".right(),
        actual = actual
    )
}
I can "fold" the result of failure.bind() using the attempt function in either's effect. In this case the test passes. Now for Ior things go differently
Copy code
@Test
fun iorAttempt() = runTest {
    val greatSuccess: Ior<Nel<String>, Int> = 1.rightIor()
    val semiSuccess: Ior<Nel<String>, Int> = Ior.Both("little fail".nel(), 2)
    val greatFailure: Ior<Nel<String>, Int> = "epic fail".nel().leftIor()

    val actual = ior(Semigroup.nonEmptyList()) {
        val test = attempt<Nel<String>, Int> { greatFailure.bind() } catch  { "fail" }
        "${greatSuccess.bind()} : ${semiSuccess.bind()} : $test"
    }

    assertEquals(
        expected = Ior.Both(nonEmptyListOf("little fail", "big fail"), "1 : 2 : fail"),
        actual = actual
   )
}
This test fails. I somewhat expected the Semigroup.nonEmptyList() stuff to maybe not combine inside the attempt { } block but what actually happens is that I get
Copy code
Expected :Ior.Both(NonEmptyList(little fail, big fail), 1 : 2 : fail)
Actual   :Ior.Left(NonEmptyList(epic fail))
The bind() simply short circuits out of the ior { } effect entirely not respecting the attempt { } effect. This seems inconsistent, but I might just not be understanding something.
s
Hey James, That is indeed a bit inconsistent, good catch. I know what is going on, and it’s partially due to not having context receivers yet. Side-note: You’re not yet on 1.20-RC, correct? The
ior
DSL introduces a separate receiver (
IorEffectScope
)
IorRaise
to allow for error accumulation for
Ior.Both
but this is not available inside (
attempt
)
recover
. This means it uses the short-circuit behavior from (
EffectScope
)
Raise
instead. A potential solution: Define (
attempt
)
recover
inside (
EffectScope
)
IorRaise
with the desired behavior, having it define their will make it have precedence. That would fix this inconsistency, what do you think? Could you make a GitHub issue for this? I think it deserves this fix.
j
Yeah I saw that I was hitting the
bind()
from the
IorEffectScope
and figured it should be hitting the
EffectScope
bind but didn't want to speculate too much here. It sounds like this can potentially be fixed. I wouldn't mind trying to fix it. Might be a good way to learn more about Arrow. In any case I can still make the Issue on GitHub. I'm also not on 1.20-RC yet though I plan to migrate soon. Really want to test out 2.0 but still desperately waiting for multiple context receivers support on Kotlin multiplatform before using them in my current project.
s
but still desperately waiting for multiple context receivers support on Kotlin multiplatform before using them in my current project.
I think we all are 😭 but this shouldn't be blocking for 1.2.0-RC/2.0. If you have any issue migrating or adopting 1.2.0-RC please let us know 🙏 I haven't seen any issue on Github, if you're interested on contributing I'd be more than happy to help and guide you with any questions or doubts you have ☺️ Feel free to create an issue, and I can assign you to it and we can discuss further details in the issue itself, and/or any WIP PR on Arrow 😉
j
https://github.com/arrow-kt/arrow/issues/3051 Issue created. I started converting my own project to 1.20-RC today and rewrote the issue to use the new Raise DSL. Also looked at your root cause. Everything makes sense. Looking at 1.20-RC now it looks like
either { }
is actually somewhat unique in that it does not have a special Raise class. It's bind() method is sort of tacked into the
Raise<in Error>
class itself. I dont know if this is even valid but I can do something sort of similar with
option { }
Copy code
@Test
fun optionAttempt() = runTest {
    val some = 1.some()
    val none: Option<Int> = None

    val actual = option {
        val test = recover<None, Int>({ none.bind()}) { 2 }
        "${some.bind()} : $test"
    }

    assertEquals(expected = "1 : 2".some(), actual = actual)
}
This also fails with actual being
Option.None
In theory a similar fix to what you propose could work I think. I'll try to get a WIP PR out tomorrow though.
PR Opened. It's very much a WIP. I only learned about the new Raise DSL a couple days ago so I could be doing some silly things. It's nothing production ready but I figured it would be a good place to move this discussion to. Let me know if I can clarify anything or what changes would be required to move this forward!