12 - Combining Effects: all and forEach
Combines several effects into one. Effect.all takes a fixed shape, a tuple or a record, and Effect.forEach walks a collection.
Both accept a concurrency option that controls whether the work runs sequentially, in parallel, or with a bounded fan-out.
What you’ll work through
index.test.ts contains four beats. The first two cover Effect.all in both its tuple and record forms; the third covers Effect.forEach over a collection; the fourth threads the concurrency option through both operators and asserts how it changes runtime ordering.
By the end you should be able to pick between all (fixed shape) and forEach (dynamic collection) and choose concurrency deliberately.
Sequential by Default
Both combinators are sequential unless you ask for concurrency. That choice is deliberate: sequential execution is easier to reason about and matches the shape of Effect.gen.
Promise users coming from Promise.all should note the difference.
Sequential (default, concurrency: 1)─────────────────────────────────────[ A ]──►[ B ]──►[ C ] start start start total ≈ tA + tB + tC
Concurrent (concurrency: "unbounded")─────────────────────────────────────[ A ]──┐[ B ]──┼──► all running[ C ]──┘ total ≈ max(tA, tB, tC)
Bounded (concurrency: N)────────────────────────at most N effects in flight at any momentResult order always matches input order, regardless of execution order.
Effect.all
Combines a tuple or record of effects into a single effect whose success value mirrors the input shape.
import { Effect } from "effect"
// ┌─── Effect<[number, string, boolean], never, never>// ▼const tuple = Effect.all([ Effect.succeed(1), Effect.succeed("hi"), Effect.succeed(true),])The result type is a tuple in the same order as the inputs, regardless of which effect happens to finish first at runtime. Errors and requirements are unioned: if any input has E = ParseError, the whole all carries ParseError in E.
Default behaviour is sequential and short-circuits on the first failure — pass { concurrency: "unbounded" } (next beat but one) to fan them out.
Rule of thumb. Use the tuple form when each slot has a distinct static type; use the record form (next beat) when you’d rather destructure by name than by index.
Your turn combine the three `Effect.succeed` calls into one `Effect.all([...])` so the result is `[1, "hi", true]`.
// ┌─── Effect<{ name: string; age: number }, never, never>// ▼const record = Effect.all({ name: Effect.succeed("Alice"), age: Effect.succeed(30),})Details
The error channel widens to the union of every effect’s E; the requirements channel widens to the union of every R. With mode: "default", the first failure short-circuits the rest.
mode: "validate" runs every effect and collects all errors into an array; mode: "either" returns an array of Result.
When to Use
When the set of effects is fixed and known at compile time, and you want their results combined into a tuple or named object.
@see Effect.forEach for variable-length collections.
Your turn build a record-shaped `Effect.all` and confirm the first failure short-circuits the rest.
Effect.forEach
Maps each element of an iterable through an effectful function and collects the results.
import { Effect } from "effect"
declare const fetchUser: (id: string) => Effect.Effect<{ id: string; name: string }>
// ┌─── Effect<Array<{ id: string; name: string }>, never, never>// ▼const users = Effect.forEach(["1", "2", "3"], (id) => fetchUser(id), { concurrency: "unbounded",})Details
Pass discard: true when the effect is run for its side effects and the results can be thrown away, the return type becomes Effect<void, E, R> and Effect avoids allocating the result array.
When to Use
Whenever the collection is variable-length or comes from runtime data, fetching many users, processing a queue, applying a migration to every row.
Your turn call `Effect.forEach` to map ids to user records; the `discard: true` test wants you to drop the result array.
Concurrency Options
| Option | Meaning |
|---|---|
concurrency: 1 (default) |
Sequential. Each effect waits for the previous one. |
concurrency: "unbounded" |
All effects start immediately. |
concurrency: N |
At most N effects in flight at once. |
concurrency: "inherit" |
Inherit the value from the surrounding scope. |
Pick "unbounded" when the operations are independent and latency dominates. Pick a bounded N when rate limits, connection pools, or memory pressure matter.
Stay sequential when ordering of side effects matters or each step depends on the previous result.
// Sequential (default) — three serial round-tripsEffect.forEach(ids, fetchUser)
// Fan out — all in flight at onceEffect.forEach(ids, fetchUser, { concurrency: "unbounded" })
// Bounded — never more than 2 in flight (respects connection pool)Effect.forEach(ids, fetchUser, { concurrency: 2 })Your turn pass `{ concurrency: "unbounded" }` for the fan-out test so all ids resolve in parallel, then `{ concurrency: 2 }` for the bounded test so at most two effects are ever in flight together.
flatMap, not map, for effectful transforms
Effect.map expects a pure A => B transform. If the callback returns an Effect<B>, map happily wraps that inside another layer — you end up with Effect<Effect<B>>, and running it once just yields the inner Effect back.
The fix is Effect.flatMap, which unwraps the inner Effect and chains it into the pipeline.
import { Effect } from "effect"
declare const lookupName: (id: string) => Effect.Effect<string, FetchError>
// ✗ map with an effectful function — one layer too manyconst nested: Effect.Effect<Effect.Effect<string, FetchError>> = Effect.succeed("u1").pipe(Effect.map(lookupName))
// ✓ flatMap chains the inner Effect into the outer one// ┌─── Effect<string, FetchError>// ▼const flat = Effect.succeed("u1").pipe(Effect.flatMap(lookupName))Rule of thumb. If the callback returns a plain value (string, number, object), reach for Effect.map. If it returns another Effect, reach for Effect.flatMap.
Effect.gen removes the choice entirely — yield* does the unwrapping for you.
Your turn read the `map`-with-an-effect test and see how the result is an Effect object, not a string. Then complete the partner test by replacing the stub with `Effect.flatMap(lookupName)` so the pipeline returns `"Name-u1"`.
A Full Example
Fetching a user and their posts in parallel, then combining them in Effect.gen.
import { Effect, Data } from "effect"
class FetchError extends Data.TaggedError("FetchError")<{ readonly source: string}> {}
declare const fetchUser: () => Promise<{ id: string; name: string }>declare const fetchPosts: () => Promise<Array<{ title: string }>>
// ┌─── Effect<{ user: { id: string; name: string }; posts: Array<{ title: string }> }, FetchError, never>// ▼const program = Effect.gen(function* () { const { user, posts } = yield* Effect.all( { user: Effect.tryPromise({ try: () => fetchUser(), catch: () => new FetchError({ source: "user" }), }), posts: Effect.tryPromise({ try: () => fetchPosts(), catch: () => new FetchError({ source: "posts" }), }), }, { concurrency: "unbounded" } )
return { user, posts }})Common Pitfalls
1, Expecting Promise.all-style parallelism. Effect.all is sequential by default. Pass { concurrency: "unbounded" } for true fan-out.
2, Losing late failures with mode: "default". The first failure stops the rest. Use mode: "validate" to collect every error.
3, Unbounded fan-out against rate-limited services. A 10,000-element Effect.forEach with "unbounded" will hit every limit you have. Use a numeric concurrency value.
4, concurrency on dependent steps. If step B needs step A’s result, use Effect.gen, concurrency cannot run them in parallel anyway.
mirrors the input shape exactly. - [ ] Then `for-each` over a dynamic collection — the result is an array of results, not a single combined value. - [ ] Finish with `concurrency-option` — flip between `"unbounded"` and a fixed number and watch the assertion on completion order shift.
See Also
- Previous,
13-async-operations: lifting Promises and side effects. - Next,
11-option-either: lighter alternatives when you do not needEffect. - Related,
07-effect-gen: sequential composition withyield*. - Later,
35-concurrency: fibers, races, and structured concurrency.
Run the tests: pnpm test 12-combining-effects
The full test suite
The index.test.ts for this lesson, verbatim from steps/12-combining-effects/.
import { describe, it, expect } from "vite-plus/test"import { Effect, Data, Exit } from "effect"
// =============================================================================// 09 - Combining Effects: all and forEach// =============================================================================//// Effect.all fixed-shape combinator: tuple or record// Effect.forEach iterates a collection with an effectful function//// Both default to sequential execution. Pass { concurrency: "unbounded" } for// fan-out, or a number for a bounded pool.
class FetchError extends Data.TaggedError("FetchError")<{ readonly source: string}> {}
describe("09 - Combining Effects", () => { // @beat // id: all-tuple // shape: one-todo // title: Combine a fixed tuple of effects // teaches: Effect.all over a tuple keeps positions and types aligned. // hint: pass an array of effects to Effect.all describe("Effect.all with a tuple", () => { it("preserves the shape of the input", async () => { // ┌─── Effect<[number, string, boolean], never, never> // ▼ const program = /* @stub */ Effect.all([ Effect.succeed(1), Effect.succeed("hello"), Effect.succeed(true), ]) /* @end */
await expect(Effect.runPromise(program)).resolves.toEqual([1, "hello", true]) }) }) // @end-beat
// @beat // id: all-record // shape: multi-todo // title: Combine a record of effects // teaches: Effect.all over a record returns an object keyed by the same fields and short-circuits on the first failure. // hint: pass an object whose values are effects describe("Effect.all with a record", () => { it("produces an object keyed by the same fields", async () => { const program = /* @stub */ Effect.all({ name: Effect.succeed("Alice"), age: Effect.succeed(30), }) /* @end */
const result = await Effect.runPromise(program) expect(result.name).toBe("Alice") expect(result.age).toBe(30) })
it("widens the error channel to the union of inputs", async () => { const program = /* @stub */ Effect.all({ user: Effect.tryPromise({ try: () => Promise.resolve({ id: "1" }), catch: () => new FetchError({ source: "user" }), }), posts: Effect.tryPromise({ try: () => Promise.resolve([{ title: "p" }]), catch: () => new FetchError({ source: "posts" }), }), }) /* @end */
const result = await Effect.runPromise(program) expect(result.user.id).toBe("1") expect(result.posts).toHaveLength(1) }) })
describe("failure semantics", () => { it("short-circuits on the first failure by default", async () => { let secondRan = false
const program = /* @stub */ Effect.all([ Effect.fail("first error"), Effect.sync(() => { secondRan = true return "ok" }), ]) /* @end */
const exit = await Effect.runPromiseExit(program) expect(Exit.isFailure(exit)).toBe(true) expect(secondRan).toBe(false) }) }) // @end-beat
// @beat // id: concurrency-option // shape: multi-todo // title: Tune concurrency // teaches: Effect.all is sequential by default; concurrency unbounded or a number changes the fan-out. // hint: pass { concurrency: "unbounded" } or { concurrency: 2 } as the second argument describe("concurrency", () => { it("sequential execution starts effects one after another", async () => { const started: Array<number> = []
const make = (id: number) => Effect.gen(function* () { started.push(id) yield* Effect.sleep("10 millis") return id })
await Effect.runPromise(/* @stub */ Effect.all([make(0), make(1), make(2)]) /* @end */)
expect(started).toEqual([0, 1, 2]) })
it("unbounded concurrency starts every effect immediately", async () => { const inFlight = { current: 0, peak: 0 }
const make = (id: number) => Effect.gen(function* () { inFlight.current += 1 inFlight.peak = Math.max(inFlight.peak, inFlight.current) yield* Effect.sleep("10 millis") inFlight.current -= 1 return id })
const result = await Effect.runPromise( /* @stub */ Effect.all([make(0), make(1), make(2)], { concurrency: "unbounded" }) /* @end */ )
expect(result).toEqual([0, 1, 2]) expect(inFlight.peak).toBeGreaterThan(1) })
it("bounded concurrency caps the in-flight count", async () => { const inFlight = { current: 0, peak: 0 }
const tasks = Array.from({ length: 6 }, (_, i) => Effect.gen(function* () { inFlight.current += 1 inFlight.peak = Math.max(inFlight.peak, inFlight.current) yield* Effect.sleep("10 millis") inFlight.current -= 1 return i }) )
await Effect.runPromise(/* @stub */ Effect.all(tasks, { concurrency: 2 }) /* @end */)
expect(inFlight.peak).toBeLessThanOrEqual(2) }) }) // @end-beat
// @beat // id: for-each // shape: multi-todo // title: Walk a collection effectfully // teaches: Effect.forEach maps an effectful function over an iterable; discard skips the result array. // hint: Effect.forEach(items, fn, options?) describe("Effect.forEach", () => { it("maps an effectful function over a collection", async () => { const program = /* @stub */ Effect.forEach(["1", "2", "3"], (id) => Effect.succeed({ id, name: `User ${id}` }) ) /* @end */
const result = await Effect.runPromise(program) expect(result).toHaveLength(3) expect(result[0].name).toBe("User 1") })
it("preserves order under unbounded concurrency", async () => { const items = [1, 2, 3, 4, 5]
const program = /* @stub */ Effect.forEach( items, (n) => Effect.gen(function* () { yield* Effect.sleep("5 millis") return n * 2 }), { concurrency: "unbounded" } ) /* @end */
const result = await Effect.runPromise(program) expect(result).toEqual([2, 4, 6, 8, 10]) })
it("discard: true returns void and skips the result array", async () => { const seen: Array<number> = []
const program = /* @stub */ Effect.forEach( [1, 2, 3], (n) => Effect.sync(() => { seen.push(n) }), { discard: true } ) /* @end */
const result = await Effect.runPromise(program) expect(result).toBeUndefined() expect(seen).toEqual([1, 2, 3]) }) }) // @end-beat
// @beat // id: flatmap-not-map // shape: multi-todo // title: Use flatMap when the function returns an Effect // teaches: Effect.map applies a pure A => B transform; if the function returns Effect<B>, map produces Effect<Effect<B>>. Use flatMap when the transform is itself effectful. // hint: if your callback returns an Effect, reach for flatMap — map is only for plain values like strings and numbers
describe("map vs flatMap", () => { it("Effect.map with an effectful function gives you a nested Effect (read this)", () => { const lookupName = (id: string): Effect.Effect<string, FetchError> => id === "bad" ? Effect.fail(new FetchError({ source: id })) : Effect.succeed(`Name-${id}`)
// Effect.map expects a pure function: A => B // Passing A => Effect<B> wraps it in another layer instead of chaining. const nested: Effect.Effect<Effect.Effect<string, FetchError>, never> = Effect.succeed("u1").pipe(Effect.map(lookupName))
// Running it unwraps one layer — you get the inner Effect, not "Name-u1" const inner = Effect.runSync(nested) expect(typeof inner).toBe("object") // it's an Effect object, not a string })
it("Effect.flatMap unwraps the inner Effect — chain effectful transforms with it", async () => { const lookupName = (id: string): Effect.Effect<string, FetchError> => id === "bad" ? Effect.fail(new FetchError({ source: id })) : Effect.succeed(`Name-${id}`)
// ┌─── Effect<string, FetchError, never> // ▼ const program = /* @stub */ Effect.succeed("u1").pipe( Effect.flatMap(lookupName), ) /* @end */
expect(await Effect.runPromise(program)).toBe("Name-u1") }) }) // @end-beat})