Skip to main content

Overview

ribs_effect is a functional effects library for Dart, ported from the Scala Cats Effect library. It gives you a principled toolkit for writing programs that interact with the world — performing I/O, managing resources, sharing mutable state, and running concurrent tasks — all without sacrificing predictability or testability.

The core idea: effects as values

Dart's built-in Future executes immediately when created. This eager evaluation makes a Future a running computation, not a description of one. That distinction matters: you cannot safely reuse a Future, pass it around as a value, or compose it with other effects without reasoning carefully about when and how many times it has already been executed.

IO<A> is a lazy, referentially transparent description of an effect. Creating an IO does nothing on its own — it is a blueprint. The same IO value can be reused, composed, and executed any number of times without surprising behaviour:

IO.delay is lazy — safe to reuse
final rng = IO.delay(() => Random.secure().nextInt(1000));

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

Nothing in the snippet above runs until .unsafeRunFuture() is called. The same rng IO value is used twice, and each use produces an independent result.

What the package provides

IO

IO<A> is the fundamental building block. It models any computation that may produce a value of type A, perform side effects, raise errors, or never complete.

Resource

Resource<A> models the acquire-use-release lifecycle of a value that needs cleanup. Finalizers are guaranteed to run regardless of whether the inner computation succeeds, fails, or is cancelled. Nested resources finalize in LIFO order.

Ref

Ref<A> is a concurrent, purely functional mutable variable. All updates are atomic and exposed as IO operations so they compose safely with the rest of the effect stack.

Deferred

Deferred<A> is a single-assignment, purely functional promise. A fiber that calls .value() on an empty Deferred will semantically block until another fiber calls .complete(a). It is the canonical primitive for coordinating two independent fibers.

Queue

Queue<A> is a concurrent, back-pressured queue. Multiple producers and consumers can interact with the same queue safely via IO. Both bounded and unbounded variants are available.

Semaphore

Semaphore controls access to a shared resource by limiting the number of fibers that can hold a permit simultaneously. It is the standard primitive for rate-limiting, connection pooling, and mutual exclusion.

IO Retry

ribs_effect includes a retry combinator built on top of IO. Policies are composable values — cap the number of attempts, add exponential backoff, or combine policies with and/or.

Concurrency model

ribs_effect implements a green-thread concurrency model on top of Dart's event loop. A Fiber is a lightweight, cancellable unit of concurrency — far cheaper than a Dart Isolate (~270 bytes for a created fiber). Calling .start() on any IO forks it onto a new fiber, and fibers can be joined, cancelled, or raced with IO.race and IO.both.

Cancellation is cooperative and safe: when a fiber is cancelled, any Resource finalizers and IO.onCancel handlers it holds are guaranteed to run.

Execution

IO values are pure descriptions — they do nothing until explicitly executed. At the edge of your application, call unsafeRunFuture() (or unsafeRunFutureOutcome() to inspect the Outcome) to hand the computation to the Dart runtime:

void main() {
myProgram().unsafeRunFuture();
}

The unsafe prefix is a deliberate signal: this is the boundary where pure functional code meets the imperative world. Keep this call as close to main as possible, and let IO handle everything inside.