Skip to main content

IO

IO is one of the most useful types in Ribs because it enables us to control side effects and make it easier to write purely functional programs.

info

If you're familiar with the IO type from Cats Effect, then Ribs IO should look very similar. In fact, Ribs IO is a very close port from Scala to Dart! The API is designed to stay as close as possible to the original implementation.

IO

Motivation

While Darts Future type has it's uses, it also suffers from issues that make it unsuitable for functional programming. Consider the following code:

final rng = Future(() => Random.secure().nextInt(1000));

await rng.then((x) => rng.then((y) => print('x: $x / y: $y')));

If you run this code, you should notice that the value of x and y are always the same! Can you see why this is problematic? Now consider this piece of code where we replace each reference to fut with the expression that fut evaluated to:

// Substitute the definition of fut with it's expression
// x and y are different! (probably)
await Future(() => Random.secure().nextInt(1000)).then((x) =>
Future(() => Random.secure().nextInt(1000))
.then((y) => print('x: $x / y: $y')));

When we do the substitution, the meaning of the program changes which leads us to the conclusion that Future is not referentially transparent! That means that it's insufficient for use in pure functions.

Here is where IO steps in. It provides lazy, pure capabilities that provide greater control over execution and dealing with failure. Here's the same program from above, using IO instead of Future.

final rng = IO.delay(() => Random.secure().nextInt(1000));

// x and y are different! (probably)
await rng
.flatMap((x) => rng.flatMap((y) => IO.println('x: $x / y: $y')))
.unsafeRunFuture();

You'll notice there are some differences in the APIs between Future and IO but for the sake of this example, you can assume that flatMap is equivalent to then and IO.println is equivalent to print. If you squint hard enough, this IO version should look pretty similar to the original implementation where we defined rng using Future. However, this piece of code is pure and referentially transparent because of the way IO is implemented!

Along with this small but important quality, IO provides the following features:

  • Asynchronous Execution
  • Error Handling
  • Safe Resource Handling
  • Cancelation

Asynchronous Execution

IO is able to describe both synchronous and asynchronous effects.

IO.pure

IO.pure accepts a value. This means that there is no laziness or delaying of effects. Any parameter is eagerly evaluated.

IO.delay

IO.delay can be used for synchronous effects that can be evaluated immediately once the IO itself is evaluated (within the context of the IO run loop).

IO.async

IO.async and IO.async_ is used to describe asynchronous effects that require a callback to be invoked to indicate completion and resume execution. Using async, we can write a function that will convert a lazy Future into an IO.

IO.async_
IO<A> futureToIO<A>(Function0<Future<A>> fut) {
IO.async_<A>((cb) {
fut().then(
(a) => cb(Right(a)),
onError: (Object err, StackTrace st) =>
cb(Left(RuntimeException(err, st))),
);
});

throw UnimplementedError();
}
info

IO already has this conversion for Future included but the example illustrates one case where IO.async_ is useful.

The only difference between IO.async and IO.async_ is that with IO.async you can include a cancelation finalizer. Since Future doesn't have a mechanism for cancelation (at least at the time of this writing), we can safely use IO.async_.

tip

To see an example of using IO.async, check out the implementation of IO.fromCancelableOperation.

Error Handling

One of the first recommendations on the Dart Error Handling page demonstrates using Exceptions paired with try/catch/finally to manage errors in your programs. But it's alredy been established that throwing exceptions is a side-effect! This rules out using them in our pure FP programs.

That begs the question on how we create and handle errors using IO.

Error Handling with IO
// composable handler using handleError
final ioA = IO.delay(() => 90 / 0).handleError((ex) => 0);
final ioB =
IO.delay(() => 90 / 0).handleErrorWith((ex) => IO.pure(double.infinity));

IO<double> safeDiv(int a, int b) => IO.defer(() {
if (b != 0) {
return IO.pure(a / b);
} else {
return IO.raiseError(RuntimeException('cannot divide by 0!'));
}
});

Safe Resource Handling

Let's begin with a fairly typical resource pattern used in Dart program that want's opens a file, writes some data and then wants to make sure the file resource is closed:

Resource Safety try/catch/finally
final sink = File('path/to/file').openWrite();

try {
// use sink...
} catch (e) {
// catch error
} finally {
sink.close();
}

Now let's write an equivalent program using IO:

Resource Safety with IO
final sink = IO.delay(() => File('path/to/file')).map((f) => f.openWrite());

// bracket *ensures* that the sink is closed
final program = sink.bracket(
(sink) => IO.exec(() => sink.writeAll(['Hello', 'World'])),
(sink) => IO.exec(() => sink.close()),
);

This version using IO has all the resource safety guarentees of the try/catch version but doesn't use Exceptions to avoid side-effects.

Conversions

IO comes with a few helper functions to convert common FP types into an IO.

IO Conversions
IO.fromOption(const Some(42), () => Exception('raiseError: none'));

IO.fromEither(Either.right(42));

IO.fromFuture(IO.delay(() => Future(() => 42)));

IO.fromCancelableOperation(IO.delay(
() => CancelableOperation.fromFuture(
Future(() => 32),
onCancel: () => print('canceled!'),
),
));
  • IO.fromOption: Takes an Option and will either return a pure synchronous IO in the case of Some or raise an error in the case of None

  • IO.fromEither: Returns a pure IO if the Either is Right or if Left, will raise an error using the value of the Left.

  • IO.fromFuture: Since Future is eagerly evaluated and memoized, fromFuture takes a parameter of type IO<Future> to control the laziness and ensure referential transparency.

caution

Simply using IO doesn't magically make the Future parameter referentially transparent! You must still take care on controlling the evaluation of the Future.

IO.fromFuture
final fut = Future(() => print('bad'));

// Too late! Future is already running!
final ioBad = IO.fromFuture(IO.pure(fut));

// IO.pure parameter is not lazy so it's evaluated immediately!
final ioAlsoBad = IO.fromFuture(IO.pure(Future(() => print('also bad'))));

// Here we preserve laziness so that ioGood is referentially transparent
final ioGood = IO.fromFuture(IO.delay(() => Future(() => print('good'))));
  • IO.fromCancelableOperation: This behaves in the same way as IO.fromFuture but is able to take advantage of some of the advanced features of CancelableOperation.

Cancelation

IO also allows you to build cancelable operations.

IO Cancel Example
int count = 0;

// Our IO program
final io = IO
.pure(42)
.delayBy(10.seconds)
.onCancel(IO.exec(() => count += 1))
.onCancel(IO.exec(() => count += 2))
.onCancel(IO.exec(() => count += 3));

// .start() kicks off the IO execution and gives us a handle to that
// execution in the form of an IOFiber
final fiber = await io.start().unsafeRunFuture();

// We immediately cancel the IO
fiber.cancel().unsafeRunAndForget();

// .join() will wait for the fiber to finish
// In this case, that's immediate since we've canceled the IO above
final outcome = await fiber.join().unsafeRunFuture();

// Show the Outcome of the IO as well as confirmation that our `onCancel`
// handlers have been called since the IO was canceled
print('Outcome: $outcome | count: $count'); // Outcome: Canceled | count: 6

This is obviously a contrived example but exhibits that you have a great deal of power controlling the execution of an IO.

Also note that an IO can only be checked for cancelation at it's asynchronous boundaries. Types of asynchronous boundaries include:

  • IO.sleep
  • IO.cede (or autoCede occurances)
  • IO.async

'Unsafe' Operations

There are a few functions on IO prefixed with the word unsafe. These are what you should be calling at the 'edge(s)' of your program. In a completely pure program, you should only call an 'unsafe' function once, in the main method after you've built and described your program using IO.

The reason these functions include the 'unsafe' keyword isn't because your computer will explode when they're called. They're unsafe because they are not pure functions and will interpret your IO and perform side effects. 'Unsafe' is included because you should always take care before deciding to call these functions.

  • unsafeRunAsync: As the name indicates, this will evaluate the IO asynchronously, and the provided callback will be executed when it has finished;

  • unsafeRunAndForget: Same as unsafeRunAsync but no callback is provided.

  • unsafeRunFuture: Evaluates the IO and returns a Future that will complete with the value of the IO or any error that is encountered during the evaluation.

  • unsafeRunFutureOutcome: Returns a Future that will complete with the Outcome of the IO, being one of 3 possible states:

    • The value itself (successful)
    • The error encountered (errored)
    • Marker indicating the IO was canceled before completing (canceled)