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:
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:
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:
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:
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:
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:
/// 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.