Effect Tracking Comparison

Article that references this page: Effect Tracking

Simple Comparison Link to heading

AE 1 version

1
2
3
4
5
6
val jimsAgeEff: Int < Abort[Absent] = for
    family <- possibleFamily
    user   <- Abort.get(family.members.find(_.name == "Jim"))
yield user.age

val jimsAge: Option[Int] = Abort.run(jimsAgeEff).toMaybe.toOption

monadic version

1
2
3
4
val jimsAge: Option[Int] = for
    family <- possibleFamily
    user   <- family.members.find(_.name == "Jim")
yield user.age

When you need to compose multiple effects, AEs become notably better than standalone monads

AE Version

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
val jimsAgeEff: Int < (IO & Abort[Exception] & Abort[Absent] & Env[FamilyMap]) = for
    familyId <- networkIO
    family   <- getFamily(familyId)
    user     <- Abort.get(family.members.find(_.name == "Jim"))
yield user.age

val jimsAge: Option[Int] < IO = jimsAgeEff
    .pipe(Env.run(familyMap))
    .pipe(Abort.run(_))
    .map(_.toMaybe.toOption)

Note: effects can easily be added by just doing & NewEffect. These effects can even be inferred by what’s used in the function

MTL 2 version

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
val jimsAgeT: OptionT[[G] =>> EitherT[[F] =>> ReaderT[IO, FamilyMap, F], Exception, G], Int] =
    for
        familyId <- OptionT.liftF(EitherT.liftF(ReaderT.liftF[IO, FamilyMap, Int](networkIO)))
        family   <- OptionT.liftF(getFamily(familyId))
        user     <- OptionT(EitherT.liftF(ReaderT.liftF[IO, FamilyMap, Option[User]](IO(family.members.find(_.name == "Jim")))))
    yield user.age

val jimsAge: IO[Option[Int]] = jimsAgeT
    .value.value
    .run(familyMap)
    .map(_.toOption.flatten)

You don’t generally see several layers of monad transformers stacked. Instead, many effects are relegated to (sometimes untyped) alternatives while using a single transformer:

  • dependencies (the Reader monad) are relegated to either explicit passing as parameters, untyped effect handling (traditional dependency injection frameworks), or in Scala specifically implicits/givens.
  • you generally don’t see OptionT and EitherT stacked, instead one is chosen and the other is converted into it
  • logging is often handled implicitly by the runtime or imperatively
  • cats.effect.IO and ZIO translate to several AE effects (error handling + side effects + async for both, zio also has deps management)

Advanced Comparison Link to heading

Let’s translate a slightly more advanced version of the above (specifically jimsAgeEff/jimsAgeT) to other languages. In addition to the effects we managed above, we’ll also concurrently run two processes, one to get a familyId and the other to get a name to find the user by, then we’ll sequentially run getting the family and getting the user.

Entirely MTL Link to heading

This requires a good deal of type annotations (ReaderT.liftF requires annotations everytime, the return type needs annotations, getFamily needs its return type annotated). Even if the inference algorithm improved, this would still be a good deal more complicated than the next couple Scala alternatives presented.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
val jimsAgeT: OptionT[[G] =>> EitherT[[F] =>> ReaderT[IO, FamilyMap, F], Exception, G], Int] = for
    familyIdFiber <- OptionT.liftF(EitherT.liftF(ReaderT.liftF[IO, FamilyMap, FiberIO[Int]](fetchFamilyId.start)))
    userNameFiber <- OptionT.liftF(EitherT.liftF(ReaderT.liftF[IO, FamilyMap, FiberIO[String]](fetchUserName.start)))

    familyId <- OptionT.liftF(EitherT.liftF(ReaderT.liftF[IO, FamilyMap, Int](familyIdFiber.joinWithNever)))
    family   <- OptionT.liftF(getFamily(familyId))

    name <- OptionT.liftF(EitherT.liftF(ReaderT.liftF[IO, FamilyMap, String](userNameFiber.joinWithNever)))
    user <- OptionT(EitherT.liftF(ReaderT.liftF[IO, FamilyMap, Option[User]](IO(family.members.find(_.name == name)))))
yield user.age

More realistic MTL Link to heading

This still uses monads, but only has 1 transformer layer, replacing Reader with givens, and flattening OptionT/EitherT. getFamily also has using FamilyMap, which is how the dependency is passed down implicitly. The return is inferred as OptionT[IO, Int].

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def jimsAgeOptT(using FamilyMap) =
    import OptionT.*
    for
        familyIdFiber <- liftF(fetchFamilyId.start)
        userNameFiber <- liftF(fetchUserName.start)

        familyId <- liftF(familyIdFiber.joinWithNever)
        family   <- apply(getFamily(familyId).value.map(_.toOption))

        name <- liftF(userNameFiber.joinWithNever)
        user <- fromOption(family.members.find(_.name == name))
    yield user.age

Algebraic Effects Link to heading

The type of jimsAgeEff is entirely inferred by the effects used. The inferred type would be equivalent to Int < (Async & Abort[Absent | Exception] & Env[FamilyMap]). The order might be different but that doesn’t matter, effects can be ran in whatever order.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
val jimsAgeEff = for
    familyIdFiber <- Async.run(fetchFamilyId)
    userNameFiber <- Async.run(fetchUserName)
        
    familyId <- familyIdFiber.get
    family   <- getFamily(familyId)
        
    name <- userNameFiber.get
    user <- Abort.get(family.members.find(_.name == name))
yield user.age

Swift Link to heading

Since Swift doesn’t have a language-level mechanism for injection (nor a general mechanism for effect handling), we’ll rely on a DI framework, which generally requires using classes if we want to separate the injection requirement for a set of arguments required to run the function.

Hence the use of try? FamilyInteractor.perform(familyId)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func fetchJimsAge() async throws -> Int? {
    async let familyIdAsync = fetchFamilyId()
    async let userNameAsync = fetchUserName()

    let familyId = await familyIdAsync
    let family   = try? FamilyInteractor.perform(familyId)

    let name       = await userNameAsync
    guard let user = family.members.first(where: { $0.name == name }) else { return nil }

    return user.age
}

  1. AE = Algebraic Effects ↩︎

  2. MTL = Monad Transformer Library ↩︎