Repository: rcardin/raise4s (scala)

Issue:
Add a new strategy to map logical typed-error without using the `withError` function
The `withError` function lets us use errors from different hierarchies in the same scope. See the following tweet for an example:

https://x.com/riccardo_cardin/status/1783803145111351797

However, it adds a lot of complexity to the code since the happy path is mixed up with the code for the type transformation of the error channel.

Implement a new _strategy_ called `RaiseMap` that enables us to do something similar to the following:

```scala
class Users(repo: UserRepository) {
    given RaiseMap[NetworkError, UserNotFound] = error => UserNotFound
    def findUserById(id: String): User raises UserNotFound =
      val user = repo.findById(id)
      user
}
```

Pull request description:
Closes #71

Patch:
diff --git a/README.md b/README.md
index c0eba99..8801a15 100644
--- a/README.md
+++ b/README.md
@@ -335,6 +335,40 @@ given Raise[ConfigurationError] = error => exit(1)
 
 In the above example we're saying that a configuration error must stop the execution of the program.
 
+### `MapError` Strategy
+
+The library defines a `withError` function to map an error raised by a function to an error of a different type:
+
+```scala 3
+val actual = either {
+  Raise.withError[Int, String, Int](s => s.length) { raise("error") }
+}
+actual should be(Left(5))
+```
+
+In the above example, we map the error of type `String` raised by the given lambda into an error of type `Int`. The `withError` function does its job quite well. However, the function's ergonomics are not so good due to the pair of lambdas we need to provide: The first one is the function that maps the error, and the second one is the function that raises the error.
+
+We can achieve the same result using a dedicated strategy. In detail, the library defines a `MapError` strategy that maps an error of type `E` into an error of type `F`. The `MapError` strategy is defined as follows:
+
+```scala 3
+trait MapError[From, To] extends Raise[From] {
+  def map(error: From): To
+  def raise(error: From): Nothing = throw Raised(map(error))
+}
+```
+
+Then, we can map an error of type `String` into an error of type `Int` by implementing the `MapError` strategy as follows:
+
+```scala 3
+val finalLambda: String raises Int = {
+  given MapError[String, Int] = error => error.length
+  raise("Oops!")
+}
+val result: Int | String = Raise.run(finalLambda)
+result shouldBe 5
+```
+
+As you can see, we're entirely focused on the happy path, and we can define how to handle the errors in a dedicated place.
 
 ## Contributing
 
diff --git a/core/src/main/scala/in/rcard/raise4s/Strategies.scala b/core/src/main/scala/in/rcard/raise4s/Strategies.scala
index 016a9bc..ea0032e 100644
--- a/core/src/main/scala/in/rcard/raise4s/Strategies.scala
+++ b/core/src/main/scala/in/rcard/raise4s/Strategies.scala
@@ -2,13 +2,49 @@ package in.rcard.raise4s
 
 object Strategies {
 
-  /**
-   * An exception that make it easy to throw a logic-typed error if the error is not already a [[Throwable]].
-   *
-   * @param error The logic-typed error to raise
-   * @tparam E The type of the logic-typed error
-   */
+  /** An exception that make it easy to throw a logic-typed error if the error is not already a
+    * [[Throwable]].
+    *
+    * @param error
+    *   The logic-typed error to raise
+    * @tparam E
+    *   The type of the logic-typed error
+    */
   class UnsafeRaiseException[E](val error: E) extends Exception()
-  
+
   type anyRaised = Raise[Any]
+
+  infix type mapTo[From, To] = MapError[From, To]
+
+  /** A strategy that allow to map an error to another one. As a strategy, it should be used as a
+    * `given` instance. Its behavior is comparable to the [[Raise.withError]] method.
+    *
+    * <h2>Example</h2>
+    * {{{
+    * val finalLambda: String raises Int = {
+    *   given MapError[String, Int] = error => error.length
+    *   raise("Oops!")
+    * }
+    *
+    * val result: Int | String = Raise.run(finalLambda)
+    * result shouldBe 5
+    * }}}
+    *
+    * @tparam From
+    *   The original error type
+    * @tparam To
+    *   The error type to map to
+    */
+  trait MapError[From, To] extends Raise[From] {
+
+    /** Maps an error to another one.
+      * @param error
+      *   The error to map
+      * @return
+      *   The mapped error
+      */
+    def map(error: From): To
+
+    def raise(error: From): Nothing = throw Raised(map(error))
+  }
 }


Test patch:
diff --git a/core/src/test/scala/in/rcard/raise4s/StrategiesSpec.scala b/core/src/test/scala/in/rcard/raise4s/StrategiesSpec.scala
index e6a127c..bfde237 100644
--- a/core/src/test/scala/in/rcard/raise4s/StrategiesSpec.scala
+++ b/core/src/test/scala/in/rcard/raise4s/StrategiesSpec.scala
@@ -1,17 +1,42 @@
 package in.rcard.raise4s
 
-import in.rcard.raise4s.Strategies.anyRaised
+import in.rcard.raise4s.Raise.raise
+import in.rcard.raise4s.Strategies.{MapError, anyRaised}
 import org.scalatest.flatspec.AnyFlatSpec
 import org.scalatest.matchers.should.Matchers
 
 class StrategiesSpec extends AnyFlatSpec with Matchers {
 
   "anyRaised" should "allow defining a strategy that throw any given error" in {
-    val lambdaRaisingError: Int raises String = Raise.raise("Oops!")
-    given anyRaised = error => throw new RuntimeException(error.toString)
+    val lambdaRaisingError: Int raises String = raise("Oops!")
+    given anyRaised                           = error => throw new RuntimeException(error.toString)
     val ex = intercept[RuntimeException] {
       lambdaRaisingError
     }
     ex.getMessage shouldBe "Oops!"
   }
+
+  it should "return the happy path value if no error is raised" in {
+    val lambdaRaisingError: String raises Int = "Hello"
+    given anyRaised                           = error => throw new RuntimeException(error.toString)
+    lambdaRaisingError shouldBe "Hello"
+  }
+
+  "MapError" should "allow defining a strategy that map an error to another one" in {
+    val finalLambda: String raises Int = {
+      given MapError[String, Int] = error => error.length
+      raise("Oops!")
+    }
+    val result: Int | String = Raise.run(finalLambda)
+    result shouldBe 5
+  }
+
+  it should "return the happy path value if no error is raised" in {
+    val finalLambda: String raises Int = {
+      given MapError[String, Int] = error => error.length
+      "Hello"
+    }
+    val result: Int | String = Raise.run(finalLambda)
+    result shouldBe "Hello"
+  }
 }