typed-future

An error typed Future: Task[+E, +A]

Scala Steward badge CI Badge Maven Central Version

A thin wrapper on the Future monad in order to give it a type parameter for the error channel, enabling you to see how a Future can fail just as how it succeeds. Built on top of the scala.concurrent.Future there is little migration needed to get started, it has the same performance and integrates into existing Future based libraries. It also extends the api of the Future to enable working with typed errors.

If you are already used to working with typed errors I would highly recommend checking out ZIO or Monix BIO instead. However if you do not want to commit to another effect system and still want typed errors feel free to use this library, or copy the code to your project.

Installation

Setup via build.sbt:

libraryDependencies += "io.github.ragazoor" %% "task" % "0.1.16"

Getting Started

In this library the monad is called a Task, which has the type signature Task[+E, +A]. This Task is just a thin wrapper on top of the Future we know from Scala, which we have defined here as the type alias type Future[+A] = Task[Throwable, A]. This is to keep backward compatability if you were to adopt this library.

Examples

In io.github.ragazoor.implicits._ there is an implicit class that allows you to convert from an scala.concurrent.Future to a Task using .toTask.

import common.User
import io.github.ragazoor.Future
import io.github.ragazoor.implicits.StdFutureToTask

import scala.concurrent.Future as StdFuture

trait UserRepository {
  def getUser(id: Int): StdFuture[User]

class UserExample(userRepo: UserRepository) {
  def getUser(id: Int): Future[User] = // Future[User] is an alias for Task[Throwable, User]
    userRepo
      .getUser(id)  // This returns a scala.concurrent.Future
      .toTask // Converts to Task[Throwable, User]
}

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

import common.User
import io.github.ragazoor.Task
import io.github.ragazoor.Future
import io.github.ragazoor.migration.implicits._
import io.github.ragazoor.implicits.StdFutureToTask

import scala.concurrent.{ExecutionContext, Future => StdFuture}

/*
 * Imagine this is in a third party library
 */
trait UserProcess {
  def process(id: StdFuture[User]): StdFuture[User]  // Works with scala.concurrent.Future
}

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

  def processUser(userTask: Task[User]): Task[Throwable, User] =
    userProcess.process(userTask)    // Using scala.concurrent.Future as input and output, implicit conversion
      .toTask

  // Does the same thing without implicits, but more migration needed
  def processUser2(userTask: Task[User]): Task[Throwable, User] =
    userProcess.process(userTask.toFuture)  // Using scala.concurrent.Future as input and output, explicit conversion
      .toTask
}

This is the basics for using the Task type in your code. The Task has the same API as the Future, and thanks to the type alias type Future[+A] = Task[Throwable, A] we don’t need to rename a lot of unnecessary renaming.

Error handling

Using the example above it is now trivial to map a failed scala.concurrent.Future to a Task with an error from our domain model.

import common.{User, UserNotFound, UserRepository}
import io.github.ragazoor.Task
import io.github.ragazoor.implicits.StdFutureToTask

import scala.concurrent.ExecutionContext


class UserServiceTaskExample(userRepo: UserRepository)(implicit ec: ExecutionContext) {
  def getUser(id: Int): Task[UserNotFound, User] =
    userRepo
      .getUser(id)  // Returns a scala.concurrent.Future
      .toTask // Converts to Task
      .mapError(e => UserNotFound(s"common.User with id $id not found", e)) // Converts Error from Throwable -> UserNotFound
}

Migration

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.

replace: 
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.*

There are a few occurrences where we need to manually fix the code:

object ImplicitClassExample {
  implicit class MyImplicitClassFunction[A](f: StdFuture[A])(implicit ec: ExecutionContext) {
    def bar: StdFuture[Option[A]] = f.map(Some(_))
  }
  def foo: Task[Throwable, Int] = ???
  /* does not compile */
  val a: Task[Throwable, Option[Int]] = foo.bar.toTask

  import scala.concurrent.ExecutionContext.Implicits.global
  val b: Task[Throwable, Option[Int]] = foo.toFuture.bar.toTask
}

Benchmarks

Any contribution to more or improved benchmarks are most welcome!

Run benchmarks

sbt "benchmark/jmh:run -i 10 -wi 10 -f 1 -t 1 io.github.ragazoor.TaskBenchmark"

Example benchmark

[info] Benchmark                      Mode  Cnt   Score   Error  Units
[info] TaskBenchmark.futureFlatMap   thrpt   10  34.419 ± 1.406  ops/s
[info] TaskBenchmark.futureMap       thrpt   10  34.556 ± 0.850  ops/s
[info] TaskBenchmark.futureRecover   thrpt   10  33.102 ± 0.802  ops/s
[info] TaskBenchmark.futureSequence  thrpt   10   1.858 ± 0.019  ops/s
[info] TaskBenchmark.taskFlatMap     thrpt   10  34.451 ± 0.961  ops/s
[info] TaskBenchmark.taskMap         thrpt   10  36.490 ± 1.042  ops/s
[info] TaskBenchmark.taskMapError    thrpt   10  35.284 ± 1.302  ops/s
[info] TaskBenchmark.taskSequence    thrpt   10   1.558 ± 0.047  ops/s