Stream #
A stream is an effect that emits many elements. It is lazy, it has typed errors, and it controls concurrency.
The problem. In plain TypeScript, data that arrives in parts gives you 2 tools. Both tools have problems. The first tool is an array:
const rows = await db.loadAllRows() // 2 million rows in memory
const top = rows.filter(isPaid).map(total).slice(0, 10)You want 10 rows. The program loads 2 million rows into memory. The second tool is an async generator with for await:
async function* rows() { /* yield 1 row at a time */ }
for await (const row of rows()) { /* ... */ }This tool solves the memory problem and nothing else. The generator has no error type. A throw inside the generator stops the loop. You cannot process 3 rows at the same time without manual bookkeeping code. If you break out of the loop, you must close the connection yourself. You cannot give the loop to another function that adds a retry or a timeout. A loop is not a value.
The shift
Today you think of a sequence as data that you pull from with a loop. In Effect, a sequence is a value. A Stream<A, E, R> is an effect that emits 0 or more elements of type A when you run it. An effect gives 1 result. A stream emits many elements. The 3 type parameters have the same meaning. The stream does nothing until you run it. Its errors are typed. Its requirements are tracked.
Because a stream is a value, you get more. Stream.map and Stream.filter work like the array methods, but they process 1 element at a time. Stream.take(10) stops the stream after 10 elements, so an infinite stream is safe. The part that emits elements is the producer. The part that reads elements is the consumer. The consumer pulls elements from the producer. A slow consumer does not receive more elements than it can process. This property is called backpressure. When the stream ends, fails, or is interrupted, Effect closes the resources that the stream opened.
| Array | Async generator | Stream | |
|---|---|---|---|
| Memory | All elements at once | 1 element at a time | 1 chunk (a small array) at a time |
| Error type | None. The code throws. | None. The generator throws. | Tracked in E |
| Concurrency | Promise.all on the full array |
Manual code | mapEffect(f, { concurrency }) |
| Stops early | slice after the full load |
break |
take, takeWhile |
| Cleanup on stop | Not applicable | finally in the generator |
Automatic |
| Retry, timeout | Manual code | Manual code | Stream.retry, Stream.timeout |
In this section you build streams, transform them, run them, and see why laziness is important. The next section, Sink, covers the consumer side.
Learn #
Lesson 1. A stream is an effect that emits many elements #
You build a stream with a constructor, in the same way that you build an effect. A stream is only a description. Nothing runs until you give the stream to a run function.
| Constructor | Emits | Use when |
|---|---|---|
Stream.make(1, 2, 3) |
The given elements | You have fixed test data |
Stream.fromIterable(xs) |
Each element of an array, a Set, or a generator | You have a collection |
Stream.range(1, 5) |
1, 2, 3, 4, 5, both ends included |
You count |
Stream.fromEffect(eff) |
1 element, the result of the effect | You have 1 effect |
Stream.succeed(x) / Stream.fail(e) |
1 element / no element and a failure | You have 1 element or 1 error |
Stream.fromQueue(q) |
Each element that the program offers to a queue, until Queue.end |
Another part of the program pushes data |
Stream.runCollect runs a stream and puts all elements into an array. It returns an Effect<Array<A>, E, R>. You must use yield* or a run function to get the array. This is the pattern for the full section: a run function changes a stream into an effect.
import { Cause, Effect, Queue, Stream } from "effect"
// Building a stream does nothing. This tap would print on every element,
// but nothing prints until the stream is run.
const numbers = Stream.range(1, 3).pipe(
Stream.tap((n) => Effect.sync(() => console.log("producing", n)))
)
const program = Effect.gen(function* () {
console.log("built, nothing produced yet")
const collected = yield* Stream.runCollect(numbers) // now it runs
console.log("collected", collected)
console.log("make", yield* Stream.runCollect(Stream.make("a", "b")))
console.log("fromIterable", yield* Stream.runCollect(Stream.fromIterable(new Set([1, 1, 2]))))
console.log("fromEffect", yield* Stream.runCollect(Stream.fromEffect(Effect.succeed(42))))
// A queue is a source that someone else fills. Queue.end tells the stream "no more".
const queue = yield* Queue.unbounded<string, Cause.Done>()
yield* Queue.offerAll(queue, ["job-1", "job-2"])
yield* Queue.end(queue)
console.log("fromQueue", yield* Stream.runCollect(Stream.fromQueue(queue)))
})
Effect.runPromise(program)
The "producing" lines appear after "built, nothing produced yet". Run Stream.runCollect(numbers) 2 times. The stream emits its elements again, because the stream is a description. Note: if you remove Queue.end, the queue stream waits for more elements, and the program does not end.
Lesson 2. Transform elements: the array functions, 1 element at a time #
The common stream functions look like the array methods, and they read the same in a pipe. The difference: a stream function processes each element when the element arrives. It does not build a new array at each step.
| Stream | Array equivalent | Notes |
|---|---|---|
Stream.map(f) |
.map(f) |
A pure transformation |
Stream.filter(p) |
.filter(p) |
A type guard narrows the element type |
Stream.take(n) |
.slice(0, n) |
Also stops the producer |
Stream.drop(n) |
.slice(n) |
|
Stream.takeWhile(p) |
A loop with break |
Stops at the first element that fails the test |
Stream.tap(f) |
A log call inside .map |
Runs an effect for each element and keeps the element |
Stream.scan(init, f) |
.reduce, but it emits each step |
The total so far |
scan has no direct array equivalent. It is a reduce that emits the accumulator after each element. It also emits the initial value first. With scan, a stream of amounts becomes a stream of balances.
import { Effect, Stream } from "effect"
const transactions = Stream.make(50, -20, 30, -5, 100, -60)
const program = Effect.gen(function* () {
const bigDeposits = yield* transactions.pipe(
Stream.filter((n) => n > 0), // keep deposits
Stream.map((n) => n * 100), // to cents
Stream.take(2), // first two only
Stream.runCollect
)
console.log("first two deposits in cents", bigDeposits)
const untilFirstWithdrawal = yield* transactions.pipe(
Stream.takeWhile((n) => n > 0),
Stream.runCollect
)
console.log("until first withdrawal", untilFirstWithdrawal)
const balances = yield* transactions.pipe(
Stream.scan(0, (balance, n) => balance + n), // emits 0, then every running balance
Stream.drop(1), // skip the initial 0
Stream.runCollect
)
console.log("running balance", balances)
})
Effect.runPromise(program)
Remove Stream.drop(1). The balance list then starts with the initial 0. scan always emits the initial value first. This is useful when the initial value is a real state. It is noise when it is not.
Lesson 3. Run a stream: the run functions #
A stream becomes an effect only through a run function. Select the function that matches the result that you want. runCollect is the most common function. It is also the function that loses the memory benefit, so learn the other functions.
| Function | Result type | Use when |
|---|---|---|
Stream.runCollect |
Effect<Array<A>> |
You need all elements, and they fit in memory |
Stream.runHead |
Effect<Option<A>> |
Only the first element is important. Stops early. |
Stream.runLast |
Effect<Option<A>> |
Only the last element is important |
Stream.runFold(() => init, f) |
Effect<Z> |
Reduce to 1 value: a sum, a maximum, a Map |
Stream.runForEach(f) |
Effect<void> |
Do an effect for each element: write, send |
Stream.runDrain |
Effect<void> |
Run for the side effects in tap. Ignores the elements. |
Stream.runCount |
Effect<number> |
Count the elements |
Note: runHead and runLast return an Option, because the stream can be empty. Note: runFold takes the initial value as a function () => init, not as a plain value. Each run then starts with a new accumulator, for example a new [] or a new Map.
import { Effect, Option, Stream } from "effect"
const scores = Stream.make(72, 95, 88, 61)
const program = Effect.gen(function* () {
const first = yield* Stream.runHead(scores)
console.log("head", Option.getOrElse(first, () => -1))
const last = yield* Stream.runLast(scores)
console.log("last", Option.getOrElse(last, () => -1))
const empty = yield* Stream.runHead(Stream.empty)
console.log("head of empty", Option.isNone(empty) ? "none" : "some")
// runFold: the initial value is a function so every run starts fresh
const best = yield* Stream.runFold(scores, () => 0, (max, n) => Math.max(max, n))
console.log("best", best)
console.log("count", yield* Stream.runCount(scores))
yield* Stream.runForEach(scores, (n) => Effect.sync(() => console.log("grade", n >= 70 ? "pass" : "fail")))
// runDrain: values are thrown away, only the tap's effect is kept
yield* scores.pipe(
Stream.tap((n) => Effect.sync(() => console.log("seen", n))),
Stream.take(2),
Stream.runDrain
)
})
Effect.runPromise(program)
runHead stopped after the first element. The stream did not emit the other elements. runLast pulled all 4 elements. Select the correct run function to prevent work whose result you do not use.
Lesson 4. Pull and laziness: infinite streams are safe #
A stream is pull-based. The consumer asks for the next chunk. The producer computes the chunk and then waits. The producer emits nothing before the consumer asks. 2 results follow from this.
First, an infinite stream is a normal value. Stream.iterate(0, (n) => n + 1) describes all natural numbers. Add Stream.take(5), and the run ends after 5 pulls. If you remove the take, runCollect never returns. Caution: an infinite stream must end with take, takeWhile, or a run function that stops early, for example runHead.
Second, an expensive source does only the work that the consumer uses. In the example, fetchPage counts its calls. When you take 3 elements from pages of 2, the stream calls fetchPage 2 times, not 4 times.
| Constructor | Ends? | What it does |
|---|---|---|
Stream.iterate(seed, next) |
Never | Emits seed, next(seed), next(next(seed)), ... |
Stream.unfold(seed, step) |
When step returns undefined |
Like iterate, but each step is an effect and can stop |
Stream.tick(interval) |
Never | Emits 1 void immediately, then 1 void after each interval |
Stream.fromSchedule(s) |
When the schedule ends | Emits 1 element for each schedule step, with the delays of the schedule |
Stream.paginate(cursor, fetch) |
When fetch returns Option.none() as the next cursor |
Reads paginated APIs. A cursor identifies the next page. |
import { Effect, Option, Schedule, Stream } from "effect"
// Every natural number. Only a description; nothing is computed here.
const naturals = Stream.iterate(0, (n) => n + 1)
// A fake paginated API: pages of two, four pages. Counts how often it is hit.
let fetches = 0
const pages: Record<number, Array<string>> = { 0: ["a", "b"], 1: ["c", "d"], 2: ["e", "f"], 3: ["g"] }
const fetchPage = (page: number) =>
Effect.sync(() => {
fetches++
const next = page < 3 ? Option.some(page + 1) : Option.none<number>()
return [pages[page]!, next] as const // [items on this page, next cursor]
})
const program = Effect.gen(function* () {
const squares = yield* naturals.pipe(
Stream.map((n) => n * n),
Stream.filter((n) => n % 2 === 0),
Stream.take(5), // without this line the program never ends
Stream.runCollect
)
console.log("first five even squares", squares)
const ticks = yield* Stream.tick("5 millis").pipe(Stream.take(3), Stream.runCount)
console.log("ticks", ticks)
const steps = yield* Stream.fromSchedule(Schedule.recurs(3)).pipe(Stream.runCollect)
console.log("schedule steps", steps)
const three = yield* Stream.paginate(0, fetchPage).pipe(Stream.take(3), Stream.runCollect)
console.log("took", three, "with", fetches, "fetches")
fetches = 0
const all = yield* Stream.paginate(0, fetchPage).pipe(Stream.runCollect)
console.log("took", all, "with", fetches, "fetches")
})
Effect.runPromise(program)
The take(5) comes after the filter. If take comes first, the filter receives only 5 candidates and emits fewer results. The order in a pipeline is the order of the pulls. Move the take above the filter and count the squares.
Lesson 5. Side by side: an async generator and Stream.mapEffect #
This is a common task in plain TypeScript. For a list of ids, call a slow API for each id, keep the results in order, and run at most 3 calls at the same time.
async function* enrich(ids: number[]) {
// "3 at a time, in order" needs a manual worker pool.
// Most code uses a serial loop (slow) or Promise.all
// (no limit; the order depends on the array index only).
for (const id of ids) yield await fetchUser(id)
}
for await (const user of enrich([1, 2, 3, 4, 5, 6])) console.log(user)Stream.mapEffect is map for a function that returns an effect. Without options, it runs 1 call at a time. With { concurrency: 3 }, it runs 3 calls at the same time and emits the results in the input order. The example counts the maximum number of calls that run at the same time. This number is stable, because each call waits for the same short time.
import { Effect, Stream } from "effect"
let inFlight = 0
let peak = 0
// A fake API call. Tracks how many are running at the same time.
const fetchUser = (id: number) =>
Effect.gen(function* () {
inFlight++
peak = Math.max(peak, inFlight)
yield* Effect.sleep("10 millis")
inFlight--
return "user-" + id
})
const ids = Stream.range(1, 6)
const program = Effect.gen(function* () {
const serial = yield* ids.pipe(Stream.mapEffect(fetchUser), Stream.runCollect)
console.log("serial ", serial, "peak", peak)
peak = 0
const parallel = yield* ids.pipe(
Stream.mapEffect(fetchUser, { concurrency: 3 }), // three at a time, order preserved
Stream.runCollect
)
console.log("concurrent", parallel, "peak", peak)
})
Effect.runPromise(program)
The results keep their order, although the calls overlap. If the order is not important, add unordered: true to the options. The stream then emits each result as soon as it is ready. Stream.tap and Stream.flatMap accept the same concurrency option.
Lesson 6. Combine streams: zip, merge, flatMap, grouped #
Real pipelines have more than 1 source, or they must put elements into groups. These functions combine streams or regroup elements.
| Function | Result | Use when |
|---|---|---|
Stream.zip(other) |
Pairs [a, b]. Stops at the end of the shorter stream. |
2 streams that line up, for example elements and indexes |
Stream.zipWith(other, f) |
f(a, b) for each pair |
The same, without the tuple |
Stream.merge(other) |
The elements of both streams, in arrival order | 2 independent sources, for example 2 queues |
Stream.flatMap(f) |
Each element of each f(a) |
1 input element becomes 0 or more output elements |
Stream.grouped(n) |
Arrays of n elements. The last array can be shorter. |
Batch writes |
Stream.groupedWithin(n, duration) |
Arrays of n elements at most, or the elements that arrived in the duration |
Batch writes with a time limit |
Note: merge emits elements in the order in which they become ready. The output order is not guaranteed. The example sorts the merged elements before it prints them. In a real program, do not depend on the order. In flatMap, return Stream.empty to remove an element. Return Stream.make(x, y) to expand an element into 2 elements.
import { Effect, Stream } from "effect"
const names = Stream.make("ada", "lin", "bo")
const errorsFromA = Stream.make("A1", "A2")
const errorsFromB = Stream.make("B1")
const program = Effect.gen(function* () {
const numbered = yield* names.pipe(
Stream.zipWith(Stream.iterate(1, (n) => n + 1), (name, i) => i + ". " + name),
Stream.runCollect
)
console.log("zipWith", numbered)
const merged = yield* errorsFromA.pipe(Stream.merge(errorsFromB), Stream.runCollect)
console.log("merge", merged.sort()) // sorted: merge order is not guaranteed
const expanded = yield* names.pipe(
Stream.flatMap((name) => name.length === 3 ? Stream.make(name, name.toUpperCase()) : Stream.empty),
Stream.runCollect
)
console.log("flatMap", expanded)
const batches = yield* Stream.range(1, 7).pipe(Stream.grouped(3), Stream.runCollect)
console.log("grouped", batches)
const pairs = yield* names.pipe(Stream.zip(Stream.make(true, false)), Stream.runCollect)
console.log("zip stops at the shorter", pairs)
})
Effect.runPromise(program)
zipWith with the infinite Stream.iterate(1, ...) is safe. zip stops when names ends, so it pulls only 3 elements from the infinite side. Stream.zipWithIndex does the same and adds the index for you.
Lesson 7. Streams from the outside: fromQueue, fromPubSub, callback #
The streams so far had their data inside the program: an array, a range, a fake API. Real data comes from the outside: a worker fiber, an event bus, a library that calls your function. 3 constructors connect these sources to a stream.
| Constructor | Source | How the stream ends |
|---|---|---|
Stream.fromQueue(queue) |
A Queue<A, Cause.Done>. Any fiber can offer elements. |
Queue.end(queue) |
Stream.fromSubscription(sub) |
A PubSub subscription. Each subscriber gets each message. | Stream.take, or the end of the scope |
Stream.fromPubSub(pubsub) |
The same, but the stream subscribes when it starts | The same |
Stream.callback((queue) => setup) |
A library with callbacks. setup registers the callbacks and pushes into the queue. |
Queue.endUnsafe(queue) from a callback |
A queue is the bridge between a producer fiber and a stream. The producer offers elements and then ends the queue. Stream.fromQueue needs the queue error type Cause.Done, because the end signal travels in the error channel. A bounded queue gives backpressure: Queue.offer waits while the queue is full.
A PubSub sends each message to all subscribers. A subscription exists only after PubSub.subscribe, and this needs a scope. A message that the program publishes before the subscribe does not reach the subscriber. Subscribe first, then publish. Note: PubSub.shutdown discards the messages that a subscriber has not read yet.
Stream.callback gives you a queue and expects a setup effect. The setup effect runs when the stream starts. Use Effect.acquireRelease inside it: the acquire registers the callbacks, and the release removes them. The release runs when the stream ends, for any reason. From a plain callback, use Queue.offerUnsafe and Queue.endUnsafe, because a callback cannot yield an effect.
import { Cause, Effect, PubSub, Queue, Stream } from "effect"
// A fake event source. It calls onEvent 3 times, 1 ms apart, then it calls onDone.
// The returned function stops the source. Most event emitters have this shape.
const listen = (onEvent: (n: number) => void, onDone: () => void) => {
let count = 0
const timer = setInterval(() => {
count++
onEvent(count)
if (count === 3) {
clearInterval(timer)
onDone()
}
}, 1)
return () => {
clearInterval(timer)
console.log("listener removed")
}
}
// Stream.callback gives you a queue. Push into it from plain callbacks. End it when the source is done.
const events = Stream.callback<number>((queue) =>
Effect.acquireRelease(
Effect.sync(() => listen((n) => Queue.offerUnsafe(queue, n), () => Queue.endUnsafe(queue))),
(stop) => Effect.sync(stop) // runs when the stream ends, also after take
)
)
const program = Effect.gen(function* () {
// 1. A queue. A producer fiber offers, then ends. The stream ends with the queue.
const queue = yield* Queue.bounded<string, Cause.Done>(2)
yield* Effect.forkChild(Effect.gen(function* () {
yield* Queue.offerAll(queue, ["job-1", "job-2", "job-3", "job-4"]) // waits while the queue is full
yield* Queue.end(queue)
console.log("producer ended")
}))
console.log("queue", yield* Stream.runCollect(Stream.fromQueue(queue)))
// 2. A PubSub. Subscribe first, in a scope. Then publish. Each subscriber gets each message.
const pubsub = yield* PubSub.unbounded<string>()
const seen = yield* Effect.scoped(Effect.gen(function* () {
const subscription = yield* PubSub.subscribe(pubsub) // removed when the scope closes
yield* PubSub.publishAll(pubsub, ["login", "click", "logout"])
return yield* Stream.fromSubscription(subscription).pipe(Stream.take(3), Stream.runCollect)
}))
console.log("pubsub", seen)
// 3. A callback source. The release runs in both cases.
console.log("callback", yield* Stream.runCollect(events))
console.log("callback take 2", yield* events.pipe(Stream.take(2), Stream.runCollect))
})
Effect.runPromise(program)
The "listener removed" line prints also after take(2). The release effect runs when the consumer stops, and the third event never fires. Change Queue.bounded(2) to Queue.unbounded(). The output is the same, but the producer no longer waits. Remove Stream.take(3) from the PubSub example. Caution: the subscription never ends by itself, and the program does not stop.
Lesson 8. Resource safety in streams: acquireRelease, ensuring, scoped #
A stream often reads from a resource: a file, a socket, a database cursor. The resource must open when the stream starts. It must close when the stream ends. This includes 3 cases: the stream emits its last element, a take stops it early, or a step fails.
In plain TypeScript with for await, the generator must close the resource in a finally block. If the caller uses break, the generator runs the finally only when the loop calls return(). Many callers forget this.
In Effect, Effect.acquireRelease(open, close) pairs an open with a close. This effect needs a Scope. Stream.scoped gives the stream its own scope for each run. The scope closes when the run ends, in all 3 cases.
| Function | When it runs | Use when |
|---|---|---|
Stream.scoped(Stream.fromEffect(acquireRelease)) |
The close runs when the run ends, in all cases | The stream owns a resource |
Stream.unwrap(effect) |
The same. effect returns the stream and can use a scope. |
You must open the resource to know what to emit |
Stream.ensuring(finalizer) |
After the finalizers of the stream, in all cases | Cleanup that does not own a resource, for example a log |
Stream.onEnd(effect) |
Only when the stream emits its last element | A "done" message |
Stream.onExit((exit) => ...) |
In all cases, with the Exit |
You must know why the stream ended |
The example runs the same stream 3 times: a complete run, a run with take(2), and a run that fails at line 3. Compare the lines that print. "close" prints in all 3 runs. "onEnd" prints only in the first run.
import { Effect, Stream } from "effect"
// A fake file. acquireRelease pairs the open with the close.
const openFile = (name: string) =>
Effect.acquireRelease(
Effect.sync(() => {
console.log("open", name)
return ["l1", "l2", "l3", "l4"]
}),
() => Effect.sync(() => console.log("close", name))
)
// Stream.scoped: the resource lives as long as 1 run of the stream
const lines = Stream.scoped(Stream.fromEffect(openFile("a.txt"))).pipe(
Stream.flatMap((all) => Stream.fromIterable(all)),
Stream.tap((line) => Effect.sync(() => console.log("read", line))),
Stream.ensuring(Effect.sync(() => console.log("ensuring: always"))),
Stream.onEnd(Effect.sync(() => console.log("onEnd: only after a complete run")))
)
const program = Effect.gen(function* () {
console.log("--- complete run")
console.log(yield* Stream.runCollect(lines))
console.log("--- take(2) stops the stream early")
console.log(yield* lines.pipe(Stream.take(2), Stream.runCollect))
console.log("--- a failure in the middle")
const result = yield* lines.pipe(
Stream.mapEffect((line) => line === "l3" ? Effect.fail("bad " + line) : Effect.succeed(line)),
Stream.runCollect,
Effect.catch((e) => Effect.succeed("failed: " + e))
)
console.log(result)
})
Effect.runPromise(program)
The order is fixed: the close of the resource, then ensuring, then onEnd. Remove Stream.scoped. The program no longer compiles, because Scope stays in the R of the stream. The challenge "The file that closes too late" shows what happens when the caller provides that scope. Replace Stream.onEnd with Stream.onExit and print exit._tag in each of the 3 runs.
Lesson 9. Group, buffer, broadcast #
These functions change the shape of the flow. They do not change the elements.
| Function | What it does | Use when |
|---|---|---|
Stream.groupByKey(f) |
Emits [key, substream] for each distinct key |
Totals for each customer, files for each day |
Stream.buffer({ capacity }) |
Puts a queue between the producer and the consumer | A fast producer and a slow consumer |
Stream.broadcast(options) |
1 source, many consumers. Each consumer sees each element. | 2 reports from 1 read of the data |
Stream.changes |
Drops an element that is equal to the previous element | Status changes, not status reports |
Stream.scan(init, f) |
Emits each intermediate state (lesson 2) | A running total |
groupByKey emits a stream for each key. The groups fill at the same time, while the source runs. The step that reads the groups must run with concurrency. A group that nobody reads blocks the source. Caution: without concurrency, the program never ends. The output order is the order of the first element of each key.
Without a buffer, the producer and the consumer take turns. The producer makes 1 element, then the consumer processes it. buffer lets the producer run ahead, up to capacity elements. When the buffer is full, the producer waits. This is the same backpressure as a bounded queue.
broadcast returns an effect that needs a Scope, because it starts a PubSub. Run the consumers at the same time with Effect.all and concurrency. Each consumer subscribes when it starts, and the source runs 1 time.
import { Effect, Stream } from "effect"
const orders = Stream.make(
{ city: "cairo", total: 10 },
{ city: "oslo", total: 5 },
{ city: "cairo", total: 7 },
{ city: "lima", total: 3 },
{ city: "oslo", total: 1 }
)
const program = Effect.gen(function* () {
// groupByKey: 1 substream for each key. Reduce each substream to 1 value.
// The groups fill at the same time, so the step that reads them must run with concurrency.
const perCity = yield* orders.pipe(
Stream.groupByKey((order) => order.city),
Stream.mapEffect(
([city, group]) =>
Stream.runFold(group, () => 0, (sum, order) => sum + order.total).pipe(
Effect.map((sum) => city + "=" + sum)
),
{ concurrency: "unbounded" }
),
Stream.runCollect
)
console.log("groupByKey", perCity)
// buffer: the producer runs ahead of the consumer, up to the capacity
const produce = (label: string) =>
Stream.range(1, 3).pipe(
Stream.rechunk(1), // 1 element for each chunk, so each element is 1 step
Stream.tap((n) => Effect.sync(() => console.log(label, "produced", n)))
)
const consume = (label: string) =>
Stream.runForEach((n: number) => Effect.sync(() => console.log(label, "consumed", n)))
yield* produce("plain").pipe(consume("plain"))
yield* produce("buffer").pipe(Stream.buffer({ capacity: 4 }), consume("buffer"))
// broadcast: 1 source, 2 consumers, each consumer sees each element. It needs a scope.
const [sum, max] = yield* Effect.scoped(Effect.gen(function* () {
const shared = yield* Stream.broadcast(Stream.make(3, 1, 4, 1, 5), { capacity: 8 })
return yield* Effect.all([
Stream.runFold(shared, () => 0, (a, b) => a + b),
Stream.runFold(shared, () => 0, (a, b) => Math.max(a, b))
], { concurrency: "unbounded" })
}))
console.log("broadcast sum", sum, "max", max)
// changes: drop an element that is equal to the previous element
const status = Stream.make("ok", "ok", "down", "down", "down", "ok")
console.log("changes", yield* status.pipe(Stream.changes, Stream.runCollect))
})
Effect.runPromise(program)
Compare the "plain" lines with the "buffer" lines. With the buffer, all 3 "produced" lines come before the first "consumed" line. Change the capacity to 1 and compare again. Add an order for a new city at the end of orders. The new key appears at the end of the groupByKey output. Note: Stream.changes compares with Equal.equals, so it also works for structural data such as Data.struct.
Lesson 10. Time-based operators with TestClock #
Some functions depend on time: throttle, debounce, timeout. A test must not wait for real seconds. Effect reads the time from the Clock service. TestClock from effect/testing is a fake clock. TestClock.layer() provides it. TestClock.adjust(duration) moves the clock forward and runs each timer that is now due. No real time passes.
| Function | What it does | Use when |
|---|---|---|
Stream.throttle({ cost, units, duration }) |
Lets units of cost through in each duration. The next chunk waits. |
A rate limit for an API |
Stream.debounce(duration) |
Emits the last element when the input is quiet for duration |
A search box |
Stream.timeout(duration) |
Ends the stream when no element arrives in duration |
A source that can stop without a signal |
Stream.schedule(schedule) |
Waits for 1 schedule step before each element | A slow replay of events |
The pattern is the same for each function. Fork the stream, because it waits for the clock. Move the clock with TestClock.adjust. Join the fiber with Fiber.join to get the result. Note: in v4, a fiber is not an effect. yield* fiber does not compile. Use Fiber.join(fiber).
throttle works on chunks, not on elements. Stream.range emits 1 chunk, so the example adds Stream.rechunk(1) before the throttle. Without it, the throttle sees 1 chunk with a cost of 3. The cost is above units, and the chunk never passes. The example prints the clock time for each element. The times are exact, because the clock is fake.
import { Cause, Clock, Effect, Fiber, Queue, Stream } from "effect"
import { TestClock } from "effect/testing"
// Prints the element with the test-clock time. The time is exact, because the clock is fake.
const stamp = <A>(label: string) => (a: A) =>
Effect.map(Clock.currentTimeMillis, (ms) => label + " " + ms + "ms " + a)
const program = Effect.gen(function* () {
// throttle: at most 1 element for each second
const throttled = yield* Effect.forkChild(
Stream.range(1, 3).pipe(
Stream.rechunk(1), // throttle counts chunks, so make each element a chunk
Stream.throttle({ cost: (chunk) => chunk.length, units: 1, duration: "1 second" }),
Stream.mapEffect(stamp("throttle")),
Stream.runCollect
)
)
yield* TestClock.adjust("2 seconds") // the fake clock moves 2 seconds at once
console.log(yield* Fiber.join(throttled))
// debounce: emit only when the input is quiet for 100 ms
const keys = yield* Queue.unbounded<string, Cause.Done>()
const debounced = yield* Effect.forkChild(
Stream.fromQueue(keys).pipe(Stream.debounce("100 millis"), Stream.runCollect)
)
yield* Queue.offerAll(keys, ["k", "ke", "key"]) // 3 keystrokes, no pause
yield* TestClock.adjust("100 millis") // a pause
yield* Queue.offerAll(keys, ["keyb", "keybo"])
yield* TestClock.adjust("100 millis")
yield* Queue.end(keys)
console.log("debounce", yield* Fiber.join(debounced))
// timeout: end the stream when no element arrives in 1 second
const pings = yield* Queue.unbounded<string, Cause.Done>()
const guarded = yield* Effect.forkChild(
Stream.fromQueue(pings).pipe(Stream.timeout("1 second"), Stream.runCollect)
)
yield* Queue.offer(pings, "ping")
yield* TestClock.adjust("500 millis")
yield* Queue.offer(pings, "pong")
yield* TestClock.adjust("1 second") // nothing arrives: the stream ends. The queue never ends.
console.log("timeout", yield* Fiber.join(guarded))
}).pipe(Effect.provide(TestClock.layer()))
Effect.runPromise(program)
Change the throttle duration to "500 millis". The times become 0, 500 and 1000. Change TestClock.adjust("2 seconds") to "1 second". Caution: the program then never ends, because the third element waits for a second that never comes. Remove Effect.provide(TestClock.layer()). The program still works, but it waits for real seconds. This is the reason to use the fake clock in tests.
Lesson 11. Errors in streams: catch, catchTag, retry #
The E of a stream works like the E of an effect. Each element step can fail. The first failure ends the stream. The catch functions mirror the effect functions, but the replacement is a stream. The stream that you return continues the output from the point of the failure.
| Function | What it does |
|---|---|
Stream.catch((e) => replacement) |
Catches each error and continues with the replacement stream |
Stream.catchTag("Tag", (e) => replacement) |
Catches 1 tagged error. The other errors stay in E. |
Stream.retry(schedule) |
Runs the full stream again from the start after a failure |
Stream.mapError(f) |
Changes the error type. Does not catch the error. |
Note: retry starts the stream again, so the stream runs its start-up steps again. This is correct for a source that failed to connect. It is wrong for a source that emitted half of its elements to a consumer that cannot accept duplicates. Use Stream.suspend when the failure depends on a state that changes between attempts. The flaky source below does this.
import { Effect, Schedule, Schema, Stream } from "effect"
class Corrupt extends Schema.TaggedError<Corrupt>()("Corrupt", { line: Schema.Number }) {}
// Two good lines, then a failure in the middle of the stream
const lines = Stream.make("ok 1", "ok 2").pipe(
Stream.concat(Stream.fail(new Corrupt({ line: 3 }))),
Stream.concat(Stream.make("never reached"))
)
// A source that fails on the first two attempts, then works
let attempts = 0
const flaky = Stream.suspend(() => {
attempts++
return attempts < 3 ? Stream.fail("connection refused") : Stream.make("connected on attempt " + attempts)
})
const program = Effect.gen(function* () {
const recovered = yield* lines.pipe(
Stream.catchTag("Corrupt", (e) => Stream.make("skipped line " + e.line)), // E becomes never
Stream.runCollect
)
console.log(recovered)
const raw = yield* lines.pipe(
Stream.runCollect,
Effect.catch((e) => Effect.succeed(["run failed with " + e._tag])) // or handle after running
)
console.log(raw)
const retried = yield* flaky.pipe(Stream.retry(Schedule.recurs(5)), Stream.runCollect)
console.log(retried)
})
Effect.runPromise(program)
The stream never emits "never reached". A failure ends the stream. The replacement stream replaces the rest of the stream, not only the element that failed. If you need recovery for each element, catch the error inside the mapEffect function with Effect.catch. The stream then never fails.
Do and don't #
| Do | Don't | Why |
|---|---|---|
End an infinite stream with Stream.take, Stream.takeWhile, or a run function that stops early, for example runHead. | Do not run Stream.iterate or Stream.tick with runCollect and no take. | The consumer pulls again and again, and the program never ends. |
Consume a stream with a run function (runCollect, runForEach, runDrain) before you give it to Effect.runPromise. | Do not pass a Stream to Effect.runPromise, and do not yield* a stream. | A stream is not an effect, so the program does not compile and nothing runs. |
Use Stream.mapEffect for a function that returns an effect. | Do not use Stream.map with a function that returns an effect. | map gives a Stream<Effect<A>>, and the next step receives effects instead of values. |
Give Stream.runFold the initial value as a function, () => init. | Do not pass a plain object or array as the initial value of runFold. | A plain mutable value is shared between runs, and the second run starts with the state of the first run. |
Put Stream.take(n) after Stream.filter when you want n matches. | Do not put take before filter. | take limits the elements above it, so the filter receives only n candidates and emits fewer matches. |
Sort the output of Stream.merge, or write code that does not depend on the order. | Do not depend on the element order after Stream.merge. | merge emits elements in arrival order, and the arrival order can be different in each run. |
Catch the error inside the mapEffect function with Effect.catch when each element must recover. | Do not use Stream.catch when the stream must continue after a bad element. | A failure ends the stream, and Stream.catch replaces the rest of the stream, not the 1 element that failed. |
Give a stream its own resource scope with Stream.scoped or Stream.unwrap. | Do not put Effect.acquireRelease in Stream.fromEffect and provide the scope with Effect.scoped around the program. | The resource closes when the program ends, not when the stream ends, and a take keeps the resource open. |
Fix it #
Each program below is broken or incomplete. Make it print the expected output with zero type errors. Use hints before the solution.
1. A stream is not an effect #
The program must print processing 1, processing 2, processing 3, but it does not compile. Change the last line only.
import { Effect, Stream } from "effect"
const pipeline = Stream.range(1, 3).pipe(
Stream.tap((n) => Effect.sync(() => console.log("processing", n)))
)
Effect.runPromise(pipeline)
2. Groups, not windows #
The writer expects groups of 3: [1,2,3], [4,5,6], [7]. The program prints windows that overlap. Change 1 function name.
import { Effect, Stream } from "effect"
const program = Stream.range(1, 7).pipe(
Stream.sliding(3),
Stream.runForEach((batch) => Effect.sync(() => console.log("write", JSON.stringify(batch))))
)
Effect.runPromise(program)
3. map or mapEffect? #
lookupPrice returns an effect. The pipeline must print the total 60, but it does not compile. Change the pipeline so that the prices are plain numbers before the sum.
import { Effect, Stream } from "effect"
const prices: Record<string, number> = { apple: 10, pear: 20, fig: 30 }
const lookupPrice = (item: string) => Effect.succeed(prices[item] ?? 0)
const program = Stream.make("apple", "pear", "fig").pipe(
Stream.map((item) => lookupPrice(item)),
Stream.runFold(() => 0, (total, price) => total + price)
)
Effect.runPromise(program).then((total) => console.log(total))
4. take in the wrong place #
We want the first 4 multiples of 7 from the natural numbers. The program prints fewer. Change the pipeline so that it prints [ 0, 7, 14, 21 ]. Keep each function. Only the order is wrong.
import { Effect, Stream } from "effect"
const program = Stream.iterate(0, (n) => n + 1).pipe(
Stream.take(4),
Stream.filter((n) => n % 7 === 0),
Stream.runCollect
)
Effect.runPromise(program).then((xs) => console.log(xs))
5. runFold needs a new start #
The word counter does not compile. Change the call to runFold so that it prints {"a":2,"b":1}.
import { Effect, Stream } from "effect"
const words = Stream.make("a", "b", "a")
const program = Stream.runFold(words, {} as Record<string, number>, (counts, w) => {
counts[w] = (counts[w] ?? 0) + 1
return counts
})
Effect.runPromise(program).then((counts) => console.log(JSON.stringify(counts)))
6. The tag that does not exist #
The stream fails with a ParseError in the middle. The catch function is in place, but the program does not compile. Change it so that it prints [ 1, 2, -1 ].
import { Effect, Schema, Stream } from "effect"
class ParseError extends Schema.TaggedError<ParseError>()("ParseError", { input: Schema.String }) {}
const numbers = Stream.make(1, 2).pipe(
Stream.concat(Stream.fail(new ParseError({ input: "x" })))
)
const program = numbers.pipe(
Stream.catchTag("ParseFailure", (e) => Stream.make(-1)),
Stream.runCollect
)
Effect.runPromise(program).then((xs) => console.log(xs))
7. The stream that says it cannot fail #
The annotation of parseAll says that the stream cannot fail. But a line that is not a number makes the stream fail with a string. The data below is clean, so the output is correct. The program still does not compile. Make the annotation true: catch the error inside parseAll. On a bad line, the stream must end and emit nothing more. Do not change the annotation.
import { Effect, Stream } from "effect"
const parseNumber = (line: string) =>
Effect.try({
try: () => {
const n = Number(line)
if (Number.isNaN(n)) throw new Error()
return n
},
catch: () => "not a number: " + line
})
const parseAll = (lines: Array<string>): Stream.Stream<number> =>
Stream.fromIterable(lines).pipe(
Stream.mapEffect(parseNumber)
)
Effect.runPromise(Stream.runCollect(parseAll(["10", "20", "30"]))).then((xs) => console.log(xs))
8. The queue that cannot end #
A producer fiber offers 3 jobs into a queue and ends the queue. The output is correct, but the program does not compile. Change the type arguments of the queue only.
import { Effect, Queue, Stream } from "effect"
const program = Effect.gen(function* () {
const queue = yield* Queue.unbounded<string>()
yield* Effect.forkChild(Effect.gen(function* () {
yield* Queue.offerAll(queue, ["job-1", "job-2", "job-3"])
yield* Queue.end(queue)
}))
const jobs = yield* Stream.runCollect(Stream.fromQueue(queue))
console.log(jobs)
})
Effect.runPromise(program)
9. The file that closes too late #
The stream reads 2 lines of a file. The file must close before "next task" prints. The program compiles and runs, but the close comes too late. Change the definition of lines only.
import { Effect, Stream } from "effect"
const openFile = Effect.acquireRelease(
Effect.sync(() => {
console.log("open")
return ["l1", "l2", "l3"]
}),
() => Effect.sync(() => console.log("close"))
)
const lines = Stream.fromEffect(openFile).pipe(
Stream.flatMap((all) => Stream.fromIterable(all))
)
const program = Effect.gen(function* () {
console.log(yield* lines.pipe(Stream.take(2), Stream.runCollect))
console.log("next task")
})
Effect.runPromise(Effect.scoped(program))
10. broadcast needs a scope #
2 reports read 1 broadcast stream. The program does not compile. Do not change the reports. Make the program provide what Stream.broadcast needs.
import { Effect, Stream } from "effect"
const program = Effect.gen(function* () {
const shared = yield* Stream.broadcast(Stream.make(3, 1, 4), { capacity: 8 })
const [sum, max] = yield* Effect.all([
Stream.runFold(shared, () => 0, (a, b) => a + b),
Stream.runFold(shared, () => 0, (a, b) => Math.max(a, b))
], { concurrency: "unbounded" })
console.log("sum", sum, "max", max)
})
Effect.runPromise(program)
Build it #
Write the program from the spec. The output must match exactly.
1. Log line pipeline #
You receive raw log lines. Build a stream pipeline that parses, filters and counts the lines.
parse(line)splits the line at the first space into{ level, message }. A line with a level other thanINFO,WARNorERRORis malformed.- Use
Stream.flatMapto remove malformed lines withStream.empty. Good lines pass through. - Use
Stream.tapto printalert: <message>for eachERRORline when the line passes. - Use
Stream.runFoldto count the lines for each level in aRecord<string, number>.
Exact output:
alert: disk full
alert: timeout
{"INFO":2,"ERROR":2,"WARN":1}import { Effect, Stream } from "effect"
const lines = [
"INFO server started",
"ERROR disk full",
"garbage line",
"WARN slow query",
"INFO request ok",
"ERROR timeout"
]
interface Entry {
readonly level: string
readonly message: string
}
// TODO: parse(line): Entry | null
const program = Stream.fromIterable(lines).pipe(
// TODO: flatMap to drop malformed lines
// TODO: tap to print alerts for ERROR lines
// TODO: runFold into counts per level
Stream.runCollect
)
Effect.runPromise(program).then((counts) => console.log(JSON.stringify(counts)))
2. Paginated API fetcher #
A test API returns users 1 page at a time. api.page(cursor) is given. It returns a Promise of { users, next }. next is the next cursor, or null on the last page. Each call increments api.calls.
- Write
fetchPage(cursor). It putsapi.pageinEffect.promiseand returns the pair[users, Option<cursor>]thatStream.paginateneeds. allUsersisStream.paginate(0, fetchPage).- Take 3 users, collect them, and print their names. Then print the number of API calls.
- Set
api.callsto 0. Print the number of all users withStream.runCount. Then print the number of API calls again.
Exact output:
first three [ "u1", "u2", "u3" ]
calls 2
total users 5
calls 3import { Effect, Option, Stream } from "effect"
const api = {
calls: 0,
page: (cursor: number): Promise<{ users: Array<string>; next: number | null }> => {
api.calls++
const pages = [["u1", "u2"], ["u3", "u4"], ["u5"]]
return Promise.resolve({ users: pages[cursor] ?? [], next: cursor < 2 ? cursor + 1 : null })
}
}
// TODO: fetchPage(cursor) returning Effect<readonly [Array<string>, Option<number>]>
// TODO: allUsers = Stream.paginate(0, fetchPage)
const program = Effect.gen(function* () {
// TODO: first three, then calls
// TODO: reset calls, runCount, then calls
})
Effect.runPromise(program)
3. Moving average with alerts #
Temperature readings arrive as a stream. Compute a moving average over a window of 3 readings. Print an alert when the average is above a limit.
readingsisStream.make(20, 22, 27, 31, 33, 28, 24).- Use
Stream.sliding(3)to get the windows. UseStream.mapto change each window into its average. Format each average withtoFixed(1). - Use
Stream.tapto printALERT <average>when an average is above29. - End with
Stream.runForEach. Printavg <average>for each window.
Exact output:
avg 23.0
avg 26.7
ALERT 30.3
avg 30.3
ALERT 30.7
avg 30.7
avg 28.3import { Effect, Stream } from "effect"
const readings = Stream.make(20, 22, 27, 31, 33, 28, 24)
const program = readings.pipe(
// TODO: sliding windows of 3
// TODO: map each window to its average
// TODO: tap: print ALERT when the average is above 29
// TODO: runForEach printing "avg <value>" with toFixed(1)
Stream.runDrain
)
Effect.runPromise(program)
Recall #
Answer in your head first, then reveal. Come back to these tomorrow.
What is the type of `Stream.runCollect(Stream.make(1, 2, 3))`? #
Effect<Array<number>, never, never>. Each run function changes a stream into an effect. You must use yield* or Effect.runPromise to get the array.
What happens when you `runCollect` an infinite stream, for example `Stream.iterate(0, n => n + 1)`, without a `take`? #
The run never ends. The consumer pulls again and again, and the producer always has more elements. Each infinite stream must have a take, a takeWhile, or a run function that stops early, for example runHead.
Which function do you use to call an API once for each element, with 3 calls at the same time, and keep the output order? #
Stream.mapEffect(f, { concurrency: 3 }). It runs the effect for each element with a concurrency limit and emits the results in the input order. Add unordered: true when the order is not important.
Why does `Stream.runFold` take `() => 0` and not `0`? #
You can run a stream many times, and each run must start with a new initial value. With a mutable initial value such as {} or [], a plain value is shared between the runs.
What does a pull-based stream give you? #
The producer emits nothing before the consumer asks. This gives backpressure (a slow consumer does not receive too many elements), laziness (the stream fetches only the pages that you take), and safe infinite sources.
You have a paginated API. Each call returns elements and a next cursor. Which constructor do you use? #
Stream.paginate(firstCursor, fetch). fetch returns an effect of [elements, Option<nextCursor>]. Option.none() ends the stream.
A stream fails in the middle. What does `Stream.catchTag("Tag", (e) => Stream.make(x))` do with the elements after the failure? #
They are lost. The failure ends the original stream, and the replacement stream continues from that point. For recovery of each element, catch the error inside the mapEffect function. The stream then never fails.
A library gives you events through `on(callback)` and `off()`. Which constructor makes a stream from it? #
Stream.callback((queue) => Effect.acquireRelease(register, remove)). Push with Queue.offerUnsafe(queue, x) from the callback, and end with Queue.endUnsafe(queue). The release removes the callback when the stream ends, also after a take.