Streams and for!

Streams let a pipeline perform effects as its items are requested. This file starts with a small pipeline that prints messages, then shows how to create a custom stream for reading lines from a file with basic-cli.

Using a stream

Calling .stream() on an iterator lets us use .map!() for an effectful transformation. Here, preparing each value prints a message, and for! consumes the results:


show_pipeline! = || {
	values = [1.U64, 2, 3].iter().stream().map!(
		|n| {
			Stdout.line!("Preparing ${n.to_str()}")?
			Ok(n * 10)
		},
	)

	for! result in values.take_first(2) {
		value = result?
		Stdout.line!("Got ${value.to_str()}")?
	}
	Ok({})
}

This prints:

Preparing 1
Got 10
Preparing 2
Got 20

Preparation and consumption alternate because the pipeline is lazy. .take_first(2) stops it before the third value is prepared. The Try values and ? handle possible errors when writing to standard output.

An ordinary for loop consumes a pure iterator, although its body may still perform effects. Streams are useful when producing or transforming the items also needs effects, as the .map!() callback does here.

Creating a stream from file reads

The basic-cli platform provides read_line!(), which reads one line at a time. The lines function below adapts those individual reads into a custom stream (the reader also has a ready-made lines() method, this example builds the stream by hand to show how custom streams work). This lets callers use the same pipeline operations and for! syntax as in the first example:


lines = |reader| Stream.custom(
	Reading(reader),
	Unknown,
	|state| match state {
		Finished => Err(NoMore)
		Reading(current) => match current.read_line!() {
			Ok(line) => Ok((Ok(line), Reading(current)))
			Err(EndOfInput) => Err(NoMore)
			Err(err) => Ok((Err(err), Finished))
		}
	},
)

show_file! = |path| {
	reader = File.open_reader!(path)?

	for! result in lines(reader).take_first(2) {
		line = result?
		Stdout.line!(line)?
	}
	Ok({})
}

A custom stream supplies its initial state, a size hint (Unknown here), and a callback that produces the next item and state. In this example:

The loop uses ? to propagate read and UTF-8 errors. Since read_line!() removes the newline, Stdout.line!() adds it back when printing. The reader closes automatically when its last reference is dropped.

The sample file contains three lines:

Hello from a file!
Streams read on demand.
This third line is not requested.

Output

Run both examples from the directory that has main.roc and input.txt in it:

$ roc main.roc -- input.txt
Preparing 1
Got 10
Preparing 2
Got 20
Hello from a file!
Streams read on demand.

Only two lines are requested from the reader. basic-cli may buffer bytes ahead internally, but the app does not load the whole file into a string or a list of lines.

Validated with Roc nightly-2026-10-29-a0a9fc2.