Effect Tracking Comparison
Article that references this page: Effect Tracking
Simple Comparison
AE [1] version
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
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
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
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
Readermonad) 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
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
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.
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
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].
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
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.
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
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)
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
}