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, but does not provide a ready-made stream of lines. The lines function below adapts those individual reads into a custom stream. 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(bytes) if bytes.is_empty() => Err(NoMore)
			Ok(bytes) => Ok((Ok(bytes), Reading(current)))
			Err(err) => Ok((Err(err), Finished))
		}
	},
)

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

	for! result in lines(reader).take_first(2) {
		bytes = result?
		line = Str.from_utf8(bytes) ? |_| InvalidUtf8
		Stdout.write!(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!() retains the newline, Stdout.write!() prints the text without adding another one. 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-14-38a3029.