Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implemented submarine error propagation for Handle #619

Open
wants to merge 15 commits into
base: main
Choose a base branch
from

Conversation

lenguyenthanh
Copy link
Member

This is a follow up of #489 on behalf of @djspiewak, the description below is copied from previous pr with some minor update of the examples.

This adds builder syntax to Handle which makes it possible to materialize an instance of Handle for any error type given an ApplicativeThrow[F]. Syntax is intentionally reminiscent of try/catch:

Scala 2 example

sealed trait DomainError
object DomainError {
  case object Failed extends DomainError
  case object Derped extends DomainError
}

def foo(implicit h: Raise[F, DomainError]): F[String] = ???
def bar(implicit h: Handle[F, DomainError]): F[String] = ???

Handle.allowF[F, DomainError] { implicit h =>
  foo *> bar
} rescue {
  case DomainError.Failed => ???
  case DomainError.Derped => ???
}

scala 3 example

enum DomainError:
  case Failed
  case Derped

def foo(implicit h: Raise[F, DomainError]): F[String] = ???
def bar(implicit h: Handle[F, DomainError]): F[String] = ???

Handle.allow:
  foo *> bar
.rescue:
  case DomainError.Failed => ???
  case DomainError.Derped => ???

You can see some other examples in the tests. You can suspend as many different error types as you want and it all composes the way you would expect.

This effectively eliminates the need for EitherT or IorT outside of tests (where both can still be useful for building ad-hoc harnesses that are less powerful than IO). Types infer quite nicely on both Scala 2 and Scala 3, though obviously Scala 3's using syntax makes this even nicer.

@lenguyenthanh lenguyenthanh force-pushed the submarine-with-fancy-pants branch from cb59bec to 4f19410 Compare March 7, 2025 16:39
@lenguyenthanh lenguyenthanh force-pushed the submarine-with-fancy-pants branch from 9140fc4 to cb545b9 Compare March 7, 2025 17:04
def allow[E]: AdHocSyntaxWired[E] =
new AdHocSyntaxWired[E]

final class AdHocSyntaxWired[E]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is Scala 3, I think with some clever use of inline we could make all the allocations / boilerplate disappear from the generated code.

Copy link
Member Author

@lenguyenthanh lenguyenthanh Mar 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried inline in 85e418b.

Combine with moving Inner class out of AdhocSyntaxWired, we completely
remove AdhocSyntaxWired footprint from generated bytecode (user's site)

before:

public final class mtl$minussubmarine$package$
implements Serializable {
    public static final mtl$minussubmarine$package$ MODULE$ = new mtl$minussubmarine$package$();

    private mtl$minussubmarine$package$() {
    }

    private Object writeReplace() {
        return new ModuleSerializationProxy(mtl$minussubmarine$package$.class);
    }

    public EitherT<Eval, Throwable, String> test() {
        return (EitherT)Handle$.MODULE$.allow() // `allow` return new AdHocSyntaxWired

    .apply((Function1 & Serializable)contextual$1 -> {
            Error$ error$ = (Error$)all$.MODULE$.toRaiseOps((Object)Error$.MODULE$);
            return (EitherT)package.all$.MODULE$.toFunctorOps(RaiseOps$.MODULE$.raise$extension((Object)error$, (Raise)contextual$1), (Functor)EitherT$.MODULE$.catsDataMonadErrorForEitherT((Monad)Eval$.MODULE$.catsBimonadForEval())).as((Object)"nope");
        }, (ApplicativeError)EitherT$.MODULE$.catsDataMonadErrorForEitherT((Monad)Eval$.MODULE$.catsBimonadForEval()))

    .rescue((Function1 & Serializable)x$1 -> {
            Error$ error$ = x$1;
            if (Error$.MODULE$.equals(error$)) {
                String string = (String)package.all$.MODULE$.catsSyntaxApplicativeId((Object)"error");
                return (EitherT)ApplicativeIdOps$.MODULE$.pure$extension((Object)string, (Applicative)EitherT$.MODULE$.catsDataMonadErrorForEitherT((Monad)Eval$.MODULE$.catsBimonadForEval()));
            }
            throw new MatchError((Object)error$);
        });
    }
}

after:

public final class mtl$minussubmarine$package$
implements Serializable {
    public static final mtl$minussubmarine$package$ MODULE$ = new mtl$minussubmarine$package$();

    private mtl$minussubmarine$package$() {
    }

    private Object writeReplace() {
        return new ModuleSerializationProxy(mtl$minussubmarine$package$.class);
    }

    public EitherT<Eval, Throwable, String> test() {
        Handle$ HandleCrossCompat_this = Handle$.MODULE$;
        Handle$ HandleCrossCompat_this2 = Handle$.MODULE$;
        MonadError x$2$proxy1 = EitherT$.MODULE$.catsDataMonadErrorForEitherT((Monad)Eval$.MODULE$.catsBimonadForEval());

        return (EitherT)new HandleCrossCompat.InnerWired((HandleCrossCompat)HandleCrossCompat_this2, (Function1 & Serializable)evidence$1 -> {

            Error$ error$ = (Error$)all$.MODULE$.toRaiseOps((Object)Error$.MODULE$);
            return (EitherT)package.all$.MODULE$.toFunctorOps(RaiseOps$.MODULE$.raise$extension((Object)error$, (Raise)evidence$1), (Functor)EitherT$.MODULE$.catsDataMonadErrorForEitherT((Monad)Eval$.MODULE$.catsBimonadForEval())).as((Object)"nope");
        }, (ApplicativeError)x$2$proxy1).rescue((Function1 & Serializable)x$1 -> {
            Error$ error$ = x$1;
            if (Error$.MODULE$.equals(error$)) {
                String string = (String)package.all$.MODULE$.catsSyntaxApplicativeId((Object)"error");
                return (EitherT)ApplicativeIdOps$.MODULE$.pure$extension((Object)string, (Applicative)EitherT$.MODULE$.catsDataMonadErrorForEitherT((Monad)Eval$.MODULE$.catsBimonadForEval()));
            }
            throw new MatchError((Object)error$);
        });
    }
}

scala code for both:

//> using scala 3.nightly
//> using dep org.typelevel::cats-mtl:1.5.0-89-85e418b-20250308T182332Z-SNAPSHOT
//> using dep org.typelevel::cats-core:2.13.0
//> using options -explain

import cats.Eval
import cats.data.EitherT
import cats.mtl.Handle.*
import cats.mtl.syntax.all.*
import cats.syntax.all.*

object Error

type F[A] = EitherT[Eval, Throwable, A]

def test1 =
  allow[Error.type]:
    Error.raise[F, String].as("nope")
  .rescue:
    case Error => "error".pure[F]

Copy link
Member Author

@lenguyenthanh lenguyenthanh Mar 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another fancy way to do it is using polymorphic function 40a7772:

-  inline def allow[E]: AdHocSyntaxWired[E] =
-    new AdHocSyntaxWired[E]()
-
-  private[mtl] final class AdHocSyntaxWired[E]:
-    inline def apply[F[_], A](inline body: Handle[F, E] ?=> F[A]): InnerWired[F, E, A] =
-      new InnerWired(body)
+  def allow[E]: [F[_], A] => (Handle[F, E] ?=> F[A]) => InnerWired[F, E, A] =
+    [F[_], A] => (body: Handle[F, E] ?=> F[A]) => InnerWired(body)

Generated code is a bit nicer compare to inline solution:

public final class mtl$minussubmarine$package$
implements Serializable {
    public static final mtl$minussubmarine$package$ MODULE$ = new mtl$minussubmarine$package$();

    private mtl$minussubmarine$package$() {
    }

    private Object writeReplace() {
        return new ModuleSerializationProxy(mtl$minussubmarine$package$.class);
    }

    public EitherT<Eval, Throwable, String> test1() {

        return (EitherT)((HandleCrossCompat.InnerWired)Handle$.MODULE$.allow().apply((Function1 & Serializable)contextual$1 -> {
            Error$ error$ = (Error$)all$.MODULE$.toRaiseOps((Object)Error$.MODULE$);
            return (EitherT)package.all$.MODULE$.toFunctorOps(RaiseOps$.MODULE$.raise$extension((Object)error$, (Raise)contextual$1), (Functor)EitherT$.MODULE$.catsDataMonadErrorForEitherT((Monad)Eval$.MODULE$.catsBimonadForEval())).as((Object)"nope");
        })).rescue((Function1 & Serializable)x$1 -> {
            Error$ error$ = x$1;
            if (Error$.MODULE$.equals(error$)) {
                String string = (String)package.all$.MODULE$.catsSyntaxApplicativeId((Object)"error");
                return (EitherT)ApplicativeIdOps$.MODULE$.pure$extension((Object)string, (Applicative)EitherT$.MODULE$.catsDataMonadErrorForEitherT((Monad)Eval$.MODULE$.catsBimonadForEval()));
            }
            throw new MatchError((Object)error$);
        }, (ApplicativeError)EitherT$.MODULE$.catsDataMonadErrorForEitherT((Monad)Eval$.MODULE$.catsBimonadForEval()));
    }
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! It looks like we are still allocating InnerWired, do you think we can make that one go away too?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! It looks like we are still allocating InnerWired, do you think we can make that one go away too?

I tried but couldn't find a way. opaque doesn't work because opaque doesn't work with context function.

I tried with value class by extending AnyVal because of Value classes may not be a member of another class error.

Maybe there is another way? Let me try some more.

btw, which approach do you prefer polymorphic function or inlining?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

btw, which approach do you prefer polymorphic function or inlining?

I think the polymorphic function is allocating something? to call the apply on?

Handle$.MODULE$.allow().apply

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're right, let me investigate.

@lenguyenthanh lenguyenthanh force-pushed the submarine-with-fancy-pants branch from c4ce3fa to ca4d5e8 Compare March 7, 2025 21:03
@lenguyenthanh lenguyenthanh marked this pull request as draft March 7, 2025 21:04
@lenguyenthanh lenguyenthanh force-pushed the submarine-with-fancy-pants branch from ca4d5e8 to 74dda73 Compare March 7, 2025 21:14
Combine with moving Inner class out of AdhocSyntaxWired, we completely
remove AdhocSyntaxWired footprint from generated bytecode (user's site)
@lenguyenthanh lenguyenthanh force-pushed the submarine-with-fancy-pants branch from 8eebab3 to 85e418b Compare March 8, 2025 18:12
And use assert.equals instead of ==
@lenguyenthanh lenguyenthanh requested a review from armanbilge March 8, 2025 18:38
@lenguyenthanh lenguyenthanh marked this pull request as ready for review March 8, 2025 18:38
Comment on lines +228 to +229
@scala.annotation.nowarn("msg=dubious usage of method hashCode with unit value")
private[mtl] final class AdHocSyntaxTired[F[_], E](private val unit: Unit) extends AnyVal {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh, that's a weird warning 😅 instead of Unit we could also use dummy: Byte or something else.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants