IO effect tracking using Ox
Ox, a library for safe direct-style concurrency and resiliency in Scala on the JVM, recently gained a new feature: the IO
capability. IO
is designed to be part of the signature of any method that performs I/O, either directly or indirectly.
The broader goal of the IO
capability feature is for method signatures to be truthful and specify the possible side effects, failure modes, and timing of methods in a reasonably precise and practical way.
The capability is passed using Scala's implicit parameters, which, on the one hand, are clearly visible in a method's signature and, on the other, provide minimal overhead when calling a method. For example:
// a method which performs I/O
def readFromFile(path: String)(using IO): String = ???
// pure method, no I/O
def compare(left: String, right: String): DiffResult = ???
@main def run(): Unit =
IO.unsafe:
val content1 = readFromFile("test1.txt")
val content1 = readFromFile("test2.txt")
val result = compare(content1, content2)
println(s"Result: $result")
In other words, the presence of a using IO
parameter indicates that the method might:
- have side effects: write to a file, send a network request, read from a database, etc.
- take a non-trivial amount of time to complete due to blocking, data transfer, etc.
- throw an exception (unless the exceptions are handled using
try-catch
)
Quite importantly, the absence of using IO
specifies that the method should have no I/O side effects. Note that such a method might still block a thread, e.g. when using blocking queues, or have other side effects, such as throwing exceptions, accessing the system clock, or picking a random number.
The Scala compiler will assist in checking that IO
is appropriately used, but only to a certain degree—it's possible to cheat!
Verifying usage of Java libraries
To improve the compile-time checking of proper IO
usage, Ox provides the requireIO
compiler plugin, which verifies invocations of methods from the JDK or Java libraries.
Whenever a method that throws an java.io.IOException
is invoked in the compiled code, the plugin will also check that an IO
capability is present.
For example, such usage will be a compile-time failure:
import java.io.InputStream
def test(): Unit =
val is: InputStream = ???
is.read()
/*
[error] -- Error: Test.scala:8:11
[error] 8 | is.read()
[error] | ^^^^^^^^^
[error] |The `java.io.InputStream.read` method throws an `java.io.IOException`,
[error] |but the `ox.IO` capability is not available in the implicit scope.
[error] |
[error] |Try adding a `using IO` clause to the enclosing method.
*/
You can think of the plugin as a way to translate between the effect system that is part of Java—checked exceptions—and the IO
effect specified by Ox. Only usages of Java methods with the proper throws
clauses will be checked (or of Scala methods, which have the @throws
annotation).
The goal is to verify the correct usage of the places where I/O happens at its roots. Once we have these properly annotated, any callers will require the IO
capability as well. This way, the IO
requirement should bubble up all the way to the main
method.
However, if you use a Scala library that uses Java's I/O under the covers, the plugin can't (and won't) check such usage. The plugin's scope is currently limited to the JDK and Java libraries only. It's also possible to configure the plugin to treat other exceptions as I/O exceptions if a library uses custom wrappers instead of throwing IOException
directly.
Introducing the IO capability
How can the IO
capability be provided at the top level? After all, it must come from somewhere.
As hinted above, this can be done using IO.unsafe
, which takes a code block to which an IO
capability instance will be provided. Ideally, this method should only be used at the edges of your application or when integrating with third-party libraries. Realistically, this might not always be possible. There's still value in IO
tracking, though: by looking up the usages of IO.unsafe
, we can quickly determine where the capability is introduced.
For testing, there's a different way of granting the capability, which is by using an import. Because there are different mechanisms for introducing IO
in production and test code, test usages don't pollute the search results when verifying IO.unsafe
usages (which should be as limited as possible). For example:
// production.scala
import ox.IO
def myMethod()(using IO): Unit = ???
// test.scala
import ox.IO.globalForTesting.given
def testMyMethod(): Unit = myMethod()
Capturing IO
The capability can be captured, e.g., when creating a lambda, and used "unsafely" outside of the scope where IO
is available, e.g.:
// a function which performs I/O
def sendHttpRequest(body: String)(using IO): Response = ???
// a lambda which captures and leaks the IO capability
val f = IO.unsafe(() => sendHttpRequest("hello world!"))
// unsafe usage—IO capability is not available here.
f()
That scenario should be prevented in the future with capture checking. Once it becomes stable and available in a Scala LTS release, we hope to use this feature in Ox.
Propagating the IO capability
As mentioned above, the primary way of propagating the IO capability is by using implicit parameters via using IO
method parameters. However, sometimes it's necessary to, e.g., return a lambda from a function where the IO capability is needed at some later stage, not by the entire method.
That's where context functions come into play. For example, here's a function from Ox's Kafka integration. Given a source of messages to send, we return a function that runs an I/O-performing process that publishes these messages to a topic. The publishSource
method itself doesn't perform any I/O effects, as it only constructs a function. Hence it doesn't have a using IO.
def publishSource(settings: Settings): Source[Message] => IO ?=> Unit =
source => supervised { doPublish(settings)(source).drain() }
IO.unsafe:
val source: Source[Message] = …
// capability automatically applied
publishSource(Settings.Default)(source)
However, the doPublish
method does need the IO
capability, as it publishes to Kafka. We require the IO
capability to be part of the returned function's signature to express this. The first parameter is a Source[Message]
, and given that, we obtain an IO ?=> Unit
function, which needs the IO
capability to be in scope to run. When available, such context parameters are automatically applied.
Back to coloring
Even though including capabilities such as IO
improves the truthfulness of method signatures, it does have downsides. Until now, calling blocking code in the Ox+direct style approach was trivial: you just call a blocking method whenever you want, and no additional code changes are necessary.
But such freedom comes at a price: by looking at the method signature, we have no idea if the function is pure or has any effects, not to mention I/O specifically. Adding an IO
capability constrains the code we can write and re-introduces function coloring.
Removing function coloring has been cited as one of the benefits of Project Loom. The Future
s or monadic IO[T]
s, used to represent asynchronous computations, are viral: any time we call a method that returns a Future
, our method has to return a Future
as well. We have a similar virality here: if a method requires the IO
capability, any callers must also contain the IO
capability.
Are we then undoing the advantages that Loom brought?
Partially: we must accept that by tracking I/O effects, we lose the "100%-non-viral" approach in favor of some coloring. There is an upside, though: this capability-coloring is less invasive than in the case of Future
s or the monadic IO
s. That's because effects are no longer represented as (return) values but as parameters.
This makes it much easier to integrate with higher-order functions, which are pervasive in functional programming. For example, you don't need a .map
specialized to dealing with functions which return Future
s. The lambda will capture the capability from the the outside method (just as in the capture checking example above, except that here the capture is correct; it doesn't leak the capability to the outside):
def sendRequest(body: String)(using IO): Response = ???
val toSend: List[String] = List("hello", "world")
IO.unsafe:
val responses = toSend.map(body => sendRequest(body))
…
By the way, such capturing also fixes one of the main pain points of checked exceptions: the lack of exception-polymorphic higher-order functions. Is this the reason why checked exceptions have such poor ergonomics as a programming construct in Java? We might just find out by adopting capabilities as described above!
Effects: coarse- or fine-grained?
There are a couple of approaches to effect systems, many of them under research. We've got checked exceptions in Java, which are often considered a failed feature: as mentioned above, they don't play well with lambdas, lack standardization, and are more often worked around using unchecked exceptions than not.
We also have functional effect systems such as cats-effect or ZIO, which use monads to represent effects. These are well-understood and are proven to work in production systems. They are not without their problems, though, mainly due to the syntactic overhead and the necessity to effectively write your program using an embedded DSL, which is only later executed by the library's runtime.
Finally, we've got algebraic effects, a relatively fresh approach, and the most active research. Algebraic effects might come in a monadic flavor (as in Kyo), or in direct style (as in Unison). They might also be introduced in Scala, under EPFL's project Caprese. The IO
capability in Ox simplifies the concept by having only a single effect.
As for the information carried by method signatures, Ox's approach is relatively similar to the one of cats-effect: if a method returns an IO[T]
, we know that under the covers, it performs some I/O operations (although in cats-effect, the meaning of IO is wider: it includes any blocking operations, including thread synchronization, and other side-effects, such as writing global state). That's not far from a method requiring an IO
capability.
Hence Ox or cats-effect represent a coarse-grained approach. In ZIO, this is extended by also tracking the exact errors that a method might end up with. We could implement something similar using capabilities: in fact, there's an experimental CanThrow
capability in Scala already.
However, our approach in Ox is to represent any typed errors as application/logical errors. Such errors are represented as Either
s or similar algebraic types and are fully visible in the signature. There are a number of utilities available for working with them in direct style (boundary-break), constructing, deconstructing (pattern matching), converting to and from exceptions, etc.
Finally, we could also take an even finer-grained approach and split the IO
capability into many smaller capabilities, such as Files
, Network
, Random,
etc. This has two downsides:
- It's heavier: we might need multiple capabilities, especially for high-level methods. How many capabilities is too much? We might also then consider some inheritance hierarchies between them. It's possible, but complicates the mechanism
- These capabilities are only useful if they are used; that is, we would need to make sure that any library code that we call, which wraps any of these side-effects, actually uses these capabilities. That's also a problem with
IO
, which we partly try to solve using therequiresIO
plugin, but only becomes larger with more capabilities. Introducing a single capability might be easier to adopt widely, than agreeing on a set of fine-grained ones
The question is, does the overhead induced by fine-grained effects balance out with the more precise specification we have as part of our method signatures? Or is it enough to know that "some" IO happens? How much type safety is practical? What about adoption? Tradeoffs!
Track suspensions?
In the gears library, which implements a feature set similar to Ox, however with some important differences, there's an Async
capability, which expresses the "capability to suspend". Hence, in gears we also have function coloring, but on a different axis—whether a function might suspend. It's a super-capability of Ox's IO
, as every I/O call is potentially blocking, but it also captures thread synchronization (blocking on semaphores, queues, channels etc.). That way, Async
is a bit closer to what cats-effect's IO
expresses, though it still does not capture all effects, such as accessing the system clock or randomness.
Generally, gears's Async
capability corresponds to throws InterruptedException
in Java. And by the way, I think gears could also benefit from a plugin similar to requireIO
on the JVM, but one that detects throws InterruptedException
.
Now comes the natural question: what's the "better" capability to have?
- capability to perform I/O, as in Ox's
IO
- capability to suspend, as in Gears's
Async
- both, with a subtyping relation
IO <: Async
I don't think there's a clear winner. In (1), IO
has the benefit of a precise meaning (only I/O effects) and additionally signals potential errors (I/O exceptions). As for (2), it might be helpful to know that a given method might be interrupted (i.e., throw InterruptedException
). Hence, there definitely is some value in the Async
capability, but should we treat thread synchronization and I/O the same? Finally, as in (3), each additional capability adds non-trivial complexity, which we discussed above.
Should we minimize coloring at the expense of some precision or go with multiple capabilities? (Although we already have multiple capabilities in Ox: IO
the second one, next to Ox
itself: a capability to fork.)
Summary
For details on using the IO
capability, please refer to the documentation. We'd be happy to hear what you think about this approach to tracking I/O effects what other effects are worth tracking—if any!
How fine-grained effect tracking should be? What about error handling—is the current IO
capability + Either
-based application errors ergonomic, or would you prefer to go all-in on exceptions with something like CanThrow
? Should we have a capability-to-suspend such as Async
in addition to IO
, instead of it, or not at all?
Your voice might help shape the future of direct style Scala!