Skip to content

Commit

Permalink
Merge pull request #9 from Ragazoor/final-rebranding
Browse files Browse the repository at this point in the history
IO is rebranded to Task
  • Loading branch information
Ragazoor authored Apr 14, 2024
2 parents 5e8132a + 5d8023f commit 4ec67e5
Show file tree
Hide file tree
Showing 21 changed files with 617 additions and 554 deletions.
190 changes: 91 additions & 99 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

A Future based monad with typed errors.
Designed to be a replacement for the `scala.concurrent.Future`
(`StdFuture`) with minimal migration needed. Entirely built on top
of the `StdFuture` it has
the same performance and easily integrates into existing `StdFuture`
(I'll call it StdFuture here) with minimal migration needed. Entirely built on top
of the StdFuture, it has
the same performance and easily integrates into existing StdFuture
based libraries.
It also extends the api of the `StdFuture`, which is heavily
It also extends the api of the StdFuture, which is heavily
inspired by ZIO ([github link](https://github.com/zio/zio)).

If you are already used to working with typed errors I would highly
Expand All @@ -16,135 +16,119 @@ However if you do not want to commit to another effect system and
still want complete control of your types this library is for you.

# Installation

> [!NOTE]
> Due to the new sonatype interace the library is not yet available
in maven central.
> Due to the new sonatype interface the library is not yet available
> in maven central.
Setup via `build.sbt`:

```sbt
libraryDependencies += "io.github.ragazoor" %% "io" % "0.1.0"
libraryDependencies += "io.github.ragazoor" %% "future" % "0.1.0"
```

# Getting Started

Compile and or run test

```shell
sbt compile
```

```shell
sbt test
```

## Examples

```scala
import scala.concurrent.{ExecutionContext, Future => StdFuture}
import common.{User, UserRepository}
import io.github.ragazoor.Future
import io.github.ragazoor.implicits._

case class User(name: String, age: Int)

trait UserRepository {
def getUser(id: Int): StdFuture[User]
}
import io.github.ragazoor.implicits.StdFutureToTask

class UserService(userRepo: UserRepository)(implicit ec: ExecutionContext) {
def getUser(id: Int): Future[User] = // Future[User] is an alias for IO[Throwable, User]
class UserServiceExample(userRepo: UserRepository) {
def getUser(id: Int): Future[User] = // Future[User] is an alias for Task[Throwable, User]
userRepo
.getUser(id)
.io // Converts to IO
.toTask // Converts to Task
}
```

In `io.github.ragazoor.migration.implicits._` there are implicits that
are used to convert an `IO` to a `Future`. This is useful in a migration
phase when you have a third party library which depends on getting a
`Future`.
are used to convert an `Task` to a `StdFuture`. This is useful in a migration
phase when you have a third party library which depends on getting a
`StdFuture`.

```scala
import scala.concurrent.{ExecutionContext, Future => StdFuture}
import common.User
import io.github.ragazoor.Task
import io.github.ragazoor.Future
import io.github.ragazoor.implicits._
import io.github.ragazoor.migration.implicits._
import io.github.ragazoor.implicits.StdFutureToTask

case class User(name: String, age: Int)
import scala.concurrent.{ExecutionContext, Future => StdFuture}

trait UserRepository {
def getUser(id: Int): IO[Exception, User]
/*
* Imagine this is in a third party library
*/
trait UserProcess {
def process(id: StdFuture[User]): StdFuture[User]
}

class UserService(userRepo: UserRepository)(implicit ec: ExecutionContext) {
// implicit conversion in io.github.ragazoor.migration.implicits._
// converts the IO to a Future

def getUser(id: Int): StdFuture[User] =
userRepo.getUser(id)

class UserServiceFutureExample(userProcess: UserProcess)(implicit ec: ExecutionContext) {

/* implicit conversion in io.github.ragazoor.migration.implicits._ converts
* the Task to a Future
*/
def getUser(id: Int): Future[User] =
userProcess.process {
Task.successful(User("Test name", 44))
}.toTask

// Does the same thing without implicits, but more migration needed
def getUserExplicit(id: Int): StdFuture[User] =
userRepo.getUser(id).toFuture
def getUserExplicit(id: Int): Future[User] =
userProcess.process {
Task.successful(User("Test name", 44)).toFuture // Here the conversion to future is explicit
}.toTask
}

```

This is the basics for using the typed future in
your code. The `IO` has the same API
as the `Future`, and thanks to the type alias
`type Future[+A] = IO[Throwable, A]` we don't need to rename `Future`s
This is the basics for using the `Task` type in
your code. The Task has the same API
as the StdFuture, and thanks to the type alias
`type Future[+A] = Task[Throwable, A]` we don't need to rename StdFutures
all over the code base.

### Error handling

Using the example above it is now trivial to map a failed `StdFuture`
to an `IO` with an error from our domain model.
to an `Task` with an error from our domain model.

```scala
case class UserNotFound(message: String, cause: Throwable) extends Exception(message, cause)

object UserNotFound {
def apply(id: Int)(cause: Throwable): UserNotFound = UserNotFound(s"User with id $id not found", cause)
}
import common.{User, UserNotFound, UserRepository}
import io.github.ragazoor.Task
import io.github.ragazoor.implicits.StdFutureToTask

class UserService(userRepo: UserRepository)(implicit ec: ExecutionContext) {
def getUser(id: Int): IO[UserNotFound, User] = // Future[User] is an alias for IO[Throwable, User]
userRepo
.getUser(id)
.io // Converts to IO
.mapError(UserNotFound(id))
```
import scala.concurrent.ExecutionContext

Similar to `ZIO` it is also possible to create IO's with errors that we cannot
recover from, except with a few methods like `catch` and `recover`. This is done by using `IO.fatal`:

```scala
case class UnrecoverableError(message: String, cause: Throwable) extends Exception(message, cause)

class UserService(userRepo: UserRepository)(implicit ec: ExecutionContext) {
def getUser(id: Int): IO[UserNotFound, User] =
if (id < 0) {
IO.fatal(UnrecoverableError("Not best example but lets say this is a fatal error", new RuntimeException("Fatal error")))
} else {
userRepo
.getUser(id)
.io // Converts to IO
.mapError(UserNotFound(id))
}
class UserServiceTaskExample(userRepo: UserRepository)(implicit ec: ExecutionContext) {
def getUser(id: Int): Task[UserNotFound, User] =
userRepo
.getUser(id)
.toTask // Converts to Task
.mapError(e => UserNotFound(s"common.User with id $id not found", e)) // Converts Error from Throwable -> UserNotFound
}
```

## Migration

The goal is to eventually be able to replace `scala.concurrent`, however
not everything is available yet. If you are only using `StdFuture`,
`ExecutionContext` and `NonFatal` you can use the following to migrate
most of the code:
The goal of the library is not to replace everything in `scala.concurrent.*`
since this would require a re-implementation of several key components. The
goal is rather to provide a typed alternative to the Future and
use the rest from the standard library.

The migration depends on how much of the `scala.concurrent` library you are
using. This example is for a migration where the project is only using
ExecutionContext and Future from `scala.concurrent`.

```text
replace:
import.scala.concurrent.*
import scala.concurrent.*
with:
import scala.concurrent.{ExecutionContext, Future => StdFuture}
import io.github.ragazoor.*
import io.github.ragazoor.implicits.*
import io.github.ragazoor.migration.implicits.*
Expand All @@ -153,24 +137,32 @@ import io.github.ragazoor.migration.implicits.*
There are a few occurrences where we need to manually fix the code:

- If we are using a third-party library returning a `scala.concurrent.Future`
we need to convert it to `IO` using `.io` and the implicit in
`ragazoor.implicits.*`.
- If there are async tests using `scala.concurrent.Future` but does not
have `scala.concurrent` in imported we need to add
`import io.github.ragazoor.migration.implicits.*`.
- If you are using implicit classes that uses the
`StdFuture` the compiler will not be able to convert
we need to convert it to `Task` using `.toTask` and the implicit
`io.github.ragazoor.implicits.StdFutureToTask`.
- If there are async tests using `StdFuture` but does not
have `scala.concurrent` imported we need to add
`import io.github.ragazoor.migration.implicits._`.
- If you have interfaces in your code like `A => StdFuture[B]` there are
implicits in `import io.github.ragazoor.migration.implicits._` which
help with this.
- If you are using implicit classes that extends `scala.concurrent.Future`
the compiler will not be able to convert
like one might think using the migration implicits. So we need to make
it explicit:

```scala
implicit class MyImplicitClassFunction(f: StdFuture[Int]) {
def bar: StdFuture[Option[Int]] = f.map(Some(_))
}
object ImplicitClassExample {
implicit class MyImplicitClassFunction(f: StdFuture[Int])(implicit ec: ExecutionContext) {
def bar: StdFuture[Option[Int]] = f.map(Some(_))
}

def foo: IO[Throwable, Int] = ???
val a: IO[Throwable, Option[Int]] = foo.bar.io // does not compile
val a: IO[Throwable, Option[Int]] = foo.toFuture.bar.io // compiles
import scala.concurrent.ExecutionContext.Implicits.global

def foo: Attempt[Throwable, Int] = ???

val a: Attempt[Throwable, Option[Int]] = foo.bar.attempt // does not compile
val b: Attempt[Throwable, Option[Int]] = foo.toFuture.bar.attempt
}
```

## Benchmarks
Expand All @@ -191,9 +183,9 @@ Example benchmark
[info] FutureBenchmark.futureMap thrpt 10 27.629 ± 0.490 ops/s
[info] FutureBenchmark.futureRecover thrpt 10 24.488 ± 0.415 ops/s
[info] FutureBenchmark.futureSequence thrpt 10 2.004 ± 0.203 ops/s
[info] FutureBenchmark.ioFlatMap thrpt 10 22.395 ± 0.375 ops/s
[info] FutureBenchmark.ioMap thrpt 10 27.328 ± 0.455 ops/s
[info] FutureBenchmark.ioMapError thrpt 10 27.177 ± 0.041 ops/s
[info] FutureBenchmark.ioSequence thrpt 10 1.817 ± 0.029 ops/s
[info] FutureBenchmark.taskFlatMap thrpt 10 22.395 ± 0.375 ops/s
[info] FutureBenchmark.taskMap thrpt 10 27.328 ± 0.455 ops/s
[info] FutureBenchmark.taskMapError thrpt 10 27.177 ± 0.041 ops/s
[info] FutureBenchmark.taskSequence thrpt 10 1.817 ± 0.029 ops/s
[success] Total time: 1623 s (27:03), completed Feb 20, 2024, 7:02:20 PM
```
24 changes: 12 additions & 12 deletions benchmark/src/main/scala/io/github/ragazoor/IOBenchmark.scala
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class IOBenchmark {
r.get.isInstanceOf[Success[T]]
}

protected final def await[E <: Throwable, T](result: IO[E, T]): Boolean = {
protected final def await[E <: Throwable, T](result: Task[E, T]): Boolean = {
var r: Option[Try[T]] = None
while (r eq None) r = result.value
r.get.isInstanceOf[Success[T]]
Expand All @@ -31,7 +31,7 @@ class IOBenchmark {
await(StdFuture.sequence(input.map(StdFuture.successful)))

@Benchmark def resultSequence: Boolean =
await(IO.sequence(input.map(IO.successful)))
await(Task.sequence(input.map(Task.successful)))

@tailrec private[this] final def futureFlatMapRec(i: Int, f: StdFuture[Int])(implicit
ec: ExecutionContext
Expand All @@ -42,14 +42,14 @@ class IOBenchmark {
@Benchmark final def futureFlatMap: Boolean =
await(futureFlatMapRec(recursion, StdFuture.successful(1)))

@tailrec private[this] final def resultFlatMapRec(i: Int, f: IO[Nothing, Int])(implicit
@tailrec private[this] final def resultFlatMapRec(i: Int, f: Task[Nothing, Int])(implicit
ec: ExecutionContext
): IO[Nothing, Int] =
if (i > 0) resultFlatMapRec(i - 1, f.flatMap(IO.successful)(ec))(ec)
): Task[Nothing, Int] =
if (i > 0) resultFlatMapRec(i - 1, f.flatMap(Task.successful)(ec))(ec)
else f

@Benchmark final def resultFlatMap: Boolean =
await(resultFlatMapRec(recursion, IO.successful(1)))
await(resultFlatMapRec(recursion, Task.successful(1)))

@tailrec private[this] final def futureMapRec(i: Int, f: StdFuture[Int])(implicit
ec: ExecutionContext
Expand All @@ -60,30 +60,30 @@ class IOBenchmark {
@Benchmark final def futureMap: Boolean =
await(futureMapRec(recursion, StdFuture.successful(1)))

@tailrec private[this] final def resultMapRec(i: Int, f: IO[Nothing, Int])(implicit
@tailrec private[this] final def resultMapRec(i: Int, f: Task[Nothing, Int])(implicit
ec: ExecutionContext
): IO[Nothing, Int] =
): Task[Nothing, Int] =
if (i > 0) resultMapRec(i - 1, f.map(identity)(ec))(ec)
else f

@Benchmark final def resultMap: Boolean =
await(resultMapRec(recursion, IO.successful(1)))
await(resultMapRec(recursion, Task.successful(1)))

@tailrec private[this] final def futureRecoverWithRec(i: Int, f: StdFuture[Int])(implicit
ec: ExecutionContext
): StdFuture[Int] =
if (i > 0) futureRecoverWithRec(i - 1, f.recoverWith(e => StdFuture.failed(e))(ec))(ec)
else f

@tailrec private[this] final def resultMapErrorRec(i: Int, f: IO[RuntimeException, Int])(implicit
@tailrec private[this] final def resultMapErrorRec(i: Int, f: Task[RuntimeException, Int])(implicit
ec: ExecutionContext
): IO[RuntimeException, Int] =
): Task[RuntimeException, Int] =
if (i > 0) resultMapErrorRec(i - 1, f.mapError(identity)(ec))(ec)
else f

@Benchmark final def futureRecover: Boolean =
await(futureRecoverWithRec(recursion, StdFuture.failed[Int](new RuntimeException("Future error"))))

@Benchmark final def resultMapError: Boolean =
await(resultMapErrorRec(recursion, IO.failed(new RuntimeException("Result error"))))
await(resultMapErrorRec(recursion, Task.failed(new RuntimeException("Result error"))))
}
18 changes: 13 additions & 5 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,25 @@ lazy val root =
project
.in(file("."))
.settings(publish / skip := true)
.aggregate(result, benchmark)
.aggregate(result, benchmark, examples)

lazy val result = module("typed-future-io", "result")
lazy val result = module("attempt", "result")
.enablePlugins(BuildInfoPlugin)
.settings(buildInfoSettings("ragz"))
.settings(buildInfoSettings("io.github.ragazoor"))
.settings(libraryDependencies += munit % Test)
.settings(stdSettings("io"))
.settings(stdSettings("attempt"))

lazy val examples = module("examples", "examples")
.enablePlugins(BuildInfoPlugin)
.settings(buildInfoSettings("io.github.ragazoor"))
.settings(libraryDependencies += munit % Test)
.settings(publish / skip := true)
.settings(stdSettings("examples"))
.dependsOn(result)

lazy val benchmark = module("typed-future-benchmark", "benchmark")
.enablePlugins(BuildInfoPlugin, JmhPlugin)
.settings(buildInfoSettings("ragz"))
.settings(buildInfoSettings("io.github.ragazoor"))
.settings(libraryDependencies += munit % Test)
.settings(publish / skip := true)
.settings(stdSettings("benchmark"))
Expand Down
3 changes: 3 additions & 0 deletions examples/src/main/scala/common/User.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package common

final case class User(name: String, age: Int)
3 changes: 3 additions & 0 deletions examples/src/main/scala/common/UserNotFound.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package common

final case class UserNotFound(msg: String, cause: Throwable) extends Exception(msg, cause)
6 changes: 6 additions & 0 deletions examples/src/main/scala/common/UserRepository.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package common

import scala.concurrent.{ Future => StdFuture }
trait UserRepository {
def getUser(id: Int): StdFuture[User]
}
Loading

0 comments on commit 4ec67e5

Please sign in to comment.