Skip to content

Resource

Motivation

Whenever you open a file, acquire a database connection, or bind a network socket, you take on a responsibility: you must release that resource when you are done, regardless of whether your program succeeds, fails, or is canceled. Forgetting to do so leaks finite system resources and can silently corrupt state.

The standard Dart answer is try/finally. For a single resource this is manageable, but it degrades quickly as soon as you need two resources at once:

dart
Future<Uint8List> copyFirstN_tryCatch(String src, String dst, int n) async {
  final input = await File(src).open();

  try {
    final output = await File(dst).open(mode: FileMode.write);

    try {
      final bytes = await input.read(n);

      await output.writeFrom(bytes);

      return bytes;
    } finally {
      await output.close();
    }
  } finally {
    await input.close();
  }
}

The deeper you nest, the more surface area exists for subtle mistakes — a misplaced return, an exception thrown in the wrong finally block, or simply losing track of which resource is closed by which handler.


Step up: IO.bracket

IO provides bracket, which guarantees that a release action runs after a use block, whether it completes successfully, raises an error, or is canceled. The same two-file copy using bracket looks like this:

dart
IO<Uint8List> copyFirstN_bracket(String src, String dst, int n) => IO
    .fromFutureF(() => File(src).open())
    .bracket(
      (input) => IO
          .fromFutureF(() => File(dst).open(mode: FileMode.write))
          .bracket(
            (output) => IO
                .fromFutureF(() => input.read(n))
                .flatMap((bytes) => IO.fromFutureF(() => output.writeFrom(bytes)).as(bytes)),
            (output) => IO.fromFutureF(() => output.close()).voided(),
          ),
      (input) => IO.fromFutureF(() => input.close()).voided(),
    );

This is already safer than try/finally — cancellation is handled correctly, and the release is guaranteed. But notice the shape: each additional resource adds another level of nesting. With three or more resources, bracket chains become a rightward-growing pyramid that is hard to read and harder to refactor.

INFO

These examples use the built in dart:io functions to read/write to file. Ribs also provides it's own functional ways of reading and writing files and sockets in the ribs_rill_io package that seamlessly integrates with IO and Resource, eliminating most of the noisy boilerplate you see in this contrived example.


Resource: composable lifecycle management

Resource<A> pairs an acquisition effect with its finalizer into a single, named value that can be stored, passed around, and composed just like any other type. Under the hood, Resource is implemented in terms of bracket, so all the same safety guarantees apply — but the nesting disappears.

Creating a Resource

Resource.make takes an acquire IO and a release function:

dart
Resource<RandomAccessFile> openFile(String path, [FileMode mode = FileMode.read]) => Resource.make(
  IO.fromFutureF(() => File(path).open(mode: mode)),
  (raf) => IO.fromFutureF(() => raf.close()).voided(),
);

The resulting Resource<RandomAccessFile> is completely inert until it is used — no file is opened yet.

Using a Resource

Call .use(f) to open the resource, run f with the acquired value, then close the resource. The finalizer runs even if f raises an error or the fiber is canceled:

dart
IO<Uint8List> readFirstN(String path, int n) =>
    openFile(path).use((raf) => IO.fromFutureF(() => raf.read(n)));

use returns a plain IO<B>, so the result integrates naturally with the rest of your IO program. You can observe the outcome to react to success, failure, or cancellation:

dart
IO<Unit> readAndReport(String path, int n) {
  final program = openFile(path).use((raf) => IO.fromFutureF(() => raf.read(n)));

  return program
      .start()
      .flatMap((fiber) => fiber.join())
      .flatMap(
        (oc) => oc.fold(
          () => IO.print('Program canceled.'),
          (err, _) => IO.print('Error: $err — but the file was still closed!'),
          (bytes) => IO.print('Read ${bytes.length} bytes.'),
        ),
      );
}

Composing multiple Resources

Because Resource has map and flatMap, two resources compose with the same combinators you already use on IO. When you need several resources open at once, tuple them and call useN to receive each value as a separate argument — no nesting required:

dart
/// Copy the first [n] bytes from [src] and [src2] concatenated to [dst].
IO<Unit> copyN(String src, String src2, String dst, int n) => (
  openFile(src),
  openFile(src2),
  openFile(dst, FileMode.write),
).tupled.useN(
  (a, b, out) => IO
      .fromFutureF(() => a.read(n))
      .flatMap((bytesA) => IO.fromFutureF(() => b.read(n)).map((bytesB) => [...bytesA, ...bytesB]))
      .flatMap((bytes) => IO.fromFutureF(() => out.writeFrom(bytes)).voided()),
);

Finalizers run in reverse acquisition order (LIFO), so the last resource opened is always the first to be closed.

TIP

Resource comes with the full set of IO-style combinators: map, flatMap, flatTap, evalMap, mapN / useN for tuples, and more. Any transformation you can express on IO can be expressed on Resource.