This is the third and final part of the series. The preceding section are Comparing effect systems in Scala: The Problem and Future and Comparing effect systems in Scala: Cats Effect and ZIO.
Comparing effect systems in Scala
Download full guideFreedom for the effects - Kyo
Kyo is one of the younger kids on the block - at the time of writing of this blogpost it has only reached its first release candidate for version 1.0. It is the brainchild of Flavio Brasil, a tenured Scala open source author and contributor who, in the past, created Quill, an SQL library based on Scala’s metaprogramming facilities and contributed to Twitter’s Finagle stack. Kyo attempts to take the idea of fused effects introduced in the ZIO monad and generalise it to provide a complete algebraic effects runtime and solve the “monads do not compose” problem at the same time. In practice, Kyo is also a purely functional, monadic solution but it’s built for the future and uses a lot of new features of Scala 3 to aggressively avoid allocations and inline common operations.
The core idea of Kyo revolves around the question “what if we could avoid hardcoding the error channel and environment channel into the effect like ZIO and just add them where we need them?”. The answer to this question is exactly how Kyo’s effects are defined: A < S designates an effectful computation which will result with a value of type A once all effects listed in intersection type S are handled. The <[A, S] type is called pending. For example an effect of type Int < (Sync & Scope & Abort[IOException]) will return an Int once it’s evaluated (Sync means that the computation may contain side effects but is suspended and therefore pure), once the possible IOExceptions are dealt with and once the Scope delimiting the lifetime of resources is provided. To put it in other words, integer will be returned once the pending effects’ handlers are invoked:
This approach allows for impressive expressivity and composability. To make things even more interesting - Kyo is also able to statically outlaw combinations where effects cannot safely commute like in the case of Var, which models stateful computations and Async which introduces concurrency and parallelism:
Let’s see how these capabilities can be used to encode our scraping algorithm:
The general shape of the implementation follows other monadic effect systems but there are also some interesting differences. Kyo offers a blocking queue in the form of the Channel data type which, similarly to ZIO, provides open/closed semantics and allows us to just close the queue once one of the workers decides all the work is done. Other workers that are still waiting for the next element from the queue will then, differently than in ZIO, receive a short-circuit due to the Abort[Closed] effect returned by all Channel operations. We handle that Abort in the scope above queue.take. The worker loop is implemented using the Loop effect which allows us to deal with the Abort[Closed] elegantly by just falling back to Loop.done.
The ergonomics of Kyo are nice as the library also eschews implicitly resolved typeclasses and typeclass-dependent extension methods and offers a set of combinators on top of every effect’s companion object. The aggregation of effects in the pending type’s effect set works without surprises and the errors provided by the library - thanks to the use of Scala 3 inlines - are quite helpful. The built-in library of effects and datatypes is also surprisingly large and complete. Kyo evokes a feeling of being its own language with the ability to define its own set of keywords.
The only usability hiccup of the library is related to Scala’s default behavior around value discards in methods that return Unit. This problem is a fairly known gotcha for all Scala programs but especially for monadic effects. The compiler, by default, will happily assume that an expression returning some type other than Unit that is placed in a statement position or as the last expression of a method returning Unit should be discarded. This naturally leads to silent no-ops that can be a nasty surprise during refactors. Kyo has a very useful feature where any expression of type A can be automatically treated as expression of type A < Any and lifted to the effect level. The two things can however become quite confusing together in case of methods that return Unit < (whatever effects) as Scala compiler will happily convert anything to Unit and then lift it to Unit < Any which in turn matches any pending effect set whatsoever. Kyo’s authors are well aware of this and the preamble of Kyo’s documentation clearly prescribes that all projects use a set of compiler linting options that disable this automatic coercion to Unit and turn these situations into proper type errors. With these compiler flags no more surprises await the user and it’s back to the fun puzzle game of composition.
Dealing with effects directly - Gears
Gears is a very young (the latest public release is 0.2.0!) experimental strawman concurrency library created at EPFL by Nguyen Pham under the direction of Martin Odersky. It’s part of the new research effort called Caprese, an acronym for “Capabilities for Resources and Effects”. Caprese is an attempt to introduce a general mechanism for effect tracking in the environment of a hybrid object-oriented and functional language. This attempt is quite different from all the monadic encodings of effects as monads capture effects as values returned from functions. These values have to be then evaluated, typically at the end of the world, a poetic description of the entrypoint of the application. Caprese, just like ZIO and Kyo, attempts to attack the problem of monads not composing. It does it, however, from a completely different direction - by not using monads to encode effects in the first place. Instead of encoding effects as values, Caprese and, by extension, Gears represent them on the side of arguments as capabilities - contextual parameters that are required for a function to be evaluated. This approach has some interesting benefits - for example the problem of traversing monads is immediately solved:
To make it a bit easier to understand how this is possible, let’s analyze the signatures (simplified a bit):
The most important element is the new Scala 3 syntax for context functions - Async ?=> T and Label[A] ?=> A. This notation encodes a block in which the argument is available as a given (or as an implicit, as it was called before Scala 3). The argument here, for example Async or Label[A], is a capability which expresses the permission to execute effects of that type in the scope of that function. Context functions can also be passed around as values which allows us to suspend the evaluation until necessary givens are provided. This mechanism is unfortunately quite brittle as it’s fairly easy to break the suspension inadvertently in contexts where these givens are already provided. Complete referential transparency isn’t however among the stated goals of the project. The focus is placed instead on making effect tracking more accessible and more convenient. A deeper dive into this encoding of effects, along with a brief explanation of the new capture checker type system extension, can be found in this blogpost by Noel Welsh.
It’s quite important to note that both Gears and Caprese itself are still in very active development and a lot of elements haven’t landed yet. For example, the Result datatype often presented in conference talks by Nguyen Pham and Martin Odersky isn’t available in any stable release of the standard library yet. This isn’t going to be a blocker for us however as we can use the Steps experimental library which does define the Result for us. We will, however, specialize it to Throwables with a type ResultEx[+A] = Result[A, Throwable] as our shared abstractions require a single type parameter on the effect type. For our algorithm we only require the capabilities to fork computations and to handle errors.
Let’s put Gears to work:
The first thing that’s immediately noticeable is that all for-comprehensions are indeed gone and that the code looks completely synchronous, if not for the using Async clause in the signatures of our methods. The capability marks the functions that need to spawn concurrent tasks and also gives them the ability to await for asynchronous results. Another interesting curiosity is the sudden appearance of the import of scala.language.experimental.captureChecking. We can actually enable it but the gears library is not yet prepared to use it. That’s not a problem - we can define a small shim that will prevent the blocks of code passed to Future’s constructor from capturing important flow control capabilities like Label[A] used by boundary/break mechanism used by Result’s .ok combinator:
The ?-> syntax signifies here that the context function passed to apply will receive a Async.Spawn capability but itself cannot capture any capabilities. This is not a proper solution really as there’s a lot of capabilities that we could safely capture in a Future but this blogpost is not about capture checking and it must be enough to say that this makes it impossible for us to mistakenly call .ok on a Result inside of a Future. Another curious bit of the implementation is that due to the multi-platform nature of the gears project (it works in Scala.js and Scala Native too!), if we want to retain the capability to interrupt blocks of code with Java’s built-in interruption protocol, we need to wrap interruptible code with a JvmAsyncOperations.jvmInterruptible block.
The ergonomics of code using gears are ok-ish, even considering the fact that it’s explicitly not a production grade library and rather an experiment of how things could work once Caprese matures. There’s really not much to be said beside that because it’s just plain synchronous code with a single new given parameter. Scala 3.8 makes the capture checker a bit less cryptic in terms of error messages but there’s still a long way to go. It’s good however to be able to see that it already delivers on some of the promises of safe, structured effect operators, in this case Result for error handling and Async for concurrent execution. It’s still too early to tell how this approach to effect management will scale in larger and more complex scenarios however.
One additional thing worth noting is that gears has some requirements on the runtime it’s using, namely, it must provide continuations. A direct support for continuations has been implemented in Scala Native and in Scala.js / WASM via JSPI interface and is emulated on the JVM using Project Loom’s virtual threads.
Effect tracking considered commercially worthless - Ox
This last section is simultaneously a bit of a joke and a bit of a “what if?” consideration. In all of the previous sections we discussed how different solutions allow us to keep effects in check while working on a concurrent workload that has many ways in which it can fail. Ox doesn’t really do that. In fact, effect tracking is explicitly not in scope of what the library tries to solve. Instead, Ox is designed to stay out of your way while still preventing you from shooting your own feet. It does that by enforcing structural constraints locally, usually allowing the user to completely avoid polluting the signatures with effect constraints. You do get compile errors if something that belongs to Ox is broken, be it concurrency scoping rules or error handling rules. I like to call this approach localised or tactical capabilities personally - you are guided towards building a correct piece of concurrent code and then the library disappears from your signatures and leaves you with old, boring synchronous code.
Let’s see how that works out:
The code is as simple as it gets as there’s no additional syntax overhead at all. Similarly to Gears, we need to create a type alias for Either to work with our shared abstractions using type EitherErrorOr[+A] = Either[Throwable, A]. In this variant the only scoped capability that we use is the either: scope for error handling, which is very similar to what the Result datatype offers in the steps library.
The nice thing about how Ox doesn’t get in your way is that it’s very serious about the structured concurrency approach and how it composes with other pieces of code. In Gears the canonical way to run two functions in parallel is to wrap both of them into a Future (it’s a Gears Future, not the one from the standard library!), zip the Future datatypes and only then .await the resulting Future. This means however that you don’t have to immediately await the Future and therefore the safety net of capture checker is necessary to guarantee the captured Label does not leave the scope in which it can be recovered. Ox offers a higher level construct - par - which executes two blocks of code in parallel in place and automatically awaits them. This way there’s nothing to leak and therefore that .ok() in the persist method is fine - if persist fails, par will interrupt the parallel function and then rethrow the Label back to the encompassing either block. Of course there exists a lower level Ox API that’s equivalent to Gears’ Future - the fork family, but it too can be made safe using capture checking:
This way the only parts of the code where execution actually happens asynchronously won’t be able to capture capabilities that would break the flow control once pulled out of the lifetime of their parent scope.
Ox also makes some assumptions about the runtime that is used - similarly to Gears it too leverages Project Loom on the JVM but does it differently. Ox is not based on continuations, Ox expects the runtime to provide lightweight threads and is therefore available on the JVM only. This might change in the future as there’s some work being done in Scala Native that will bring virtual threads to that platform.
It’s a bit unfair to praise the ergonomics of our own library and while we like the simplicity it gives us, we’ll comment on things that we found missing instead. mapPar and *Par combinator family could also support the Either-based error handling channel - that would provide a safer alternative to just throwing the exception in the worker function. Similarly, the Vector of Units idiom necessary for mapPar usage is rather unfortunate and reveals a missing piece in the API. We’ll work on improving these areas in future releases!
Summary
In this blogpost we’ve explored how various effect systems available in Scala allow us to express the same parallel web scraping algorithm and noted the various degrees of safety and composability they offer. Let’s recap the most important takeaways:
Ergonomics
Standard library's Future offers familiar syntax but leaves you to deal with Java's concurrent collections and manual coordination - there's simply nothing built-in for the kind of work we're doing here. Cats Effect provides a rich combinator library that feels natural once you're comfortable with monadic style, though discoverability depends on knowing which import brings which extension method into scope. ZIO takes a different approach - combinators live on the ZIO companion object and on effect instances directly, no implicit magic needed, and the naming is deliberately readable. Kyo is novel but learnable - effect companions offer clear entry points and the Loop construct is quite elegant, though one has to be careful about Unit coercion without the recommended compiler flags as the compiler errors without them become quite difficult to understand. Gears gives you direct style - plain synchronous code with a using Async parameter and minimal syntax overhead, but the lack of built-in concurrency primitives forces you back into Java stdlib. Ox has the least ceremony of the bunch - the code reads like plain synchronous Scala with either:/.ok() for error handling and par and mapPar for parallelism.
Type safety
Future is doing a very minimal job here - ExecutionContext is passed implicitly and asynchronously executing code is easy to distinguish due to Future wrapper. Side effects are not tracked otherwise. Cats Effect tracks effects via the IO monad and errors are always Throwable, though the tagless final variant adds capability constraints at the type level via typeclass bounds on F[_], which is a very powerful and typesafe extension mechanism. ZIO goes further and tracks effects, errors, and dependencies in the shape of ZIO[R, E, A] - the typed error channel is always available and additional dependencies can be requested as part of the environment of the computation. Kyo has the most precise effect tracking of all with the pending effect type set showing exactly which capabilities a computation needs and the compiler statically prevents unsafe combinations like concurrent mutation of mutable Var. Gears library tracks capabilities as context parameters and the capture checker can prevent leaking scoped capabilities but this machinery is still experimental. Ox takes a deliberately different stance - effects are invisible in signatures by design and safety is enforced locally via scoped constructs. The either: boundary tracks errors within its scope and structured concurrency prevents resource leaks. Both libraries will gain a significant safety boost once they become capture checked.
Footguns
Future is the most mistake-prone - an unfamiliar user will be surprised by eager evaluation semantics or silent error loss when an error handler is not attached. Additionally it’s fairly easy to encounter thread pool starvation issues when blocking {} wrappers are not used and the poor abstraction means a lot of manual coordination without reliable automation. Cats Effect solves most of these but retains the coordination problems - poison pills for queue shutdown are still necessary. Implicit resolution of extension methods can also produce rather cryptic compile errors when the right instance isn't in scope. ZIO's queue.shutdown interrupts all fibers blocked on queue.take which then have to be converted into normal exits using catchAllCause - a handler that is easy to forget and that the compiler does not require. Kyo is very effective in preventing footguns due to how granular effect control is so there’s not much that can go wrong in runtime once the code compiles with the exception of a rather surprising choice of finalizer semantics that don’t backpressure by default - that’s solved by Scope effect however. Gears and Ox share a weakness with monadic effects when using a datatype like Either for error handling - without a linter rule against silent discards of values it is relatively easy to silently drop errors but otherwise both solutions gain a lot by being much terser and less noisy and therefore more readable. Ox additionally prevents some issues by making the queue shutdown protocol explicit via a union type that has to be dealt with using explicit pattern matching.
Cancellation
This is where the generational divide is most visible - Future has no cancellation mechanism without external coordination - the best we could do was biasing a PriorityBlockingQueue towards immediate propagation of poison pills and hoping for the best. Every other solution provides automatic cancellation via structured concurrency scopes - parSequenceN_ in Cats Effect, collectAllParDiscard in ZIO, Async.fill in Kyo, awaitAllOrCancel in Gears, and mapPar in Ox all cancel sibling computations when one fails. The minor differences are in how the cancellation signal propagates: ZIO's queue.shutdown interrupts all blocked takers, Kyo propagates channel close as Abort[Closed], and Gears requires a jvmInterruptible wrapper around blocking operations to make them responsive to interruption on the JVM platform.
Maturity
The Future is stable and universally available but limited by design. Cats Effect and ZIO are both production-grade with large ecosystems of compatible ecosystems of libraries - the choice between them is largely one of taste and team experience. Kyo is pre-1.0 at the RC stage and rapidly maturing. Gears is an experimental research project at version 0.2.0, explicitly not meant for production use. Ox is production-grade at 1.0.0+.
The overarching pattern is that monadic solutions - Cats Effect, ZIO, Kyo - pay for effect tracking with allocation overhead (with Kyo doing the most to actually cut that cost down using advanced Scala 3 techniques leveraging opaque types) and a required mental model shift. Every step in the computation builds a data structure that is later interpreted by a runtime. The direct-style solutions - Ox and Gears - pay for simplicity with less compile-time information about what a function does - def start(): Unit could do anything as far as the caller is concerned. Within each camp, the remaining differences are about maturity, ecosystem breadth and taste. Finally, the standard library’s Future is what motivated everyone else to do better.
The algorithmic approach we have selected was the one that was easiest to compare between different technologies. Comparisons like these are quite difficult to execute fairly because every technology has a slightly different sweet spot when it comes to the idiomatic, preferred way of dealing with problems. That’s why this post is the first in a series - in the next one we’ll try to compare the low level idioms that give the user the biggest amount of flexibility and we’ll see how they protect the user from potential mistakes. In the final setting of this story we’ll try to implement the solutions using the approaches considered idiomatic by the authors of given technologies, whatever those may be.




