10 - Error Accumulation: Collecting All Failures at Once
catch and catchTag stop at the first failure. For form validation, batch processing, or any scenario where you need all errors at once, Effect provides accumulating combinators.
What you’ll work through
index.test.ts contains four beats. The first is read-run, showing how Effect.all short-circuits at the first failure.
The next three are interactive: validateAll accumulates every failure into a Chunk, and partition splits a collection into successes and failures in one pass.
By the end you should know when to short-circuit (Effect.all) and when to accumulate (Effect.validate / Effect.partition).
Mental Model
Sequential (gen / flatMap) Accumulating (validate / validateAll)───────────────────────────── ───────────────────────────────────── validateName("") → FieldError validateName("") → FieldError validateAge(-1) → (never runs) validateAge(-1) → FieldError ↓ one error [name, age] errorsSequential validation stops early
Effect.gen and Effect.flatMap are sequential — each yield* (or each chained step) only runs after the previous one succeeds. The moment any step fails, the chain short-circuits and the rest is never evaluated.
For a control-flow pipeline that is exactly what you want; for form validation, it’s the wrong default.
import { Effect, Exit } from "effect"
const validate = (name: string, age: number) => Effect.gen(function* () { const validName = yield* validateName(name) // fails first const validAge = yield* validateAge(age) // never runs return { name: validName, age: validAge } })
const exit = await Effect.runPromiseExit(validate("", -1))// exit.cause.reasons[0].error is the FieldError for "name" — the "age" error is invisible.If a user submits a form with three invalid fields, sequential validation shows them one error, they fix it, they submit, they see the next error, and so on. Three round-trips for one form.
The remedy is to switch from chain operators to accumulate operators — that’s what the next three beats cover.
Your turn read the test that asserts only the `name` error appears. Then run the partner test that surfaces the `age` error in isolation and confirm both fields *are* invalid — sequential is hiding one of them.
Effect.validate accumulates two effects
Effect.validate(a, b) is the pair-wise accumulator. It runs both effects regardless of which fails first, and collects all failures into an array.
The result succeeds only when both effects succeed; the error channel widens to Array<E> (or NonEmptyArray<E>, depending on the variant).
import { Effect } from "effect"
// ┌─── Effect<[string, number], FieldError[], never>// ▼const both = Effect.validate(validateName(name), validateAge(age))
// If both invalid: Failure([FieldError("name"), FieldError("age")])// If only name bad: Failure([FieldError("name")])// If both good: Success([name, age])Effect.validate is right for exactly two effects whose success types you want to combine. For dynamic collections, use Effect.validate (next beat) — it’s the iterable-shaped sibling.
Your turn replace the stub with `Effect.validate(validateName(name), validateAge(age))` and confirm both `FieldError`s appear in the failure exit's error array.
Effect.validate over a collection
Effect.validate(items, fn) maps an effectful function over an iterable and collects every failure — even ones discovered after the first. Unlike Effect.forEach, it never short-circuits; unlike Effect.partition (next beat), the overall result fails if any input failed.
import { Effect } from "effect"
// ┌─── Effect<string[], FieldError[], never>// ▼const validateEmails = (emails: ReadonlyArray<string>) => Effect.validate(emails, validateEmail)
// Input: ["ok@a", "bad", "ok@b", "also-bad"]// Result: Failure([FieldError("bad"), FieldError("also-bad")])Reach for validateAll when an “all-or-nothing” outcome is the right semantics — e.g. validating a batch import where one bad row should reject the whole batch but you still want every problem surfaced to the user.
Your turn run `Effect.validate` over a mixed-validity email list and confirm the failure array contains every invalid input, not just the first.
Effect.partition — partial results are fine
Effect.partition(items, fn) is the never-fails sibling of validateAll. It returns a tuple [errors, successes] and accepts that some of each is fine.
Use it when partial results are acceptable — log ingestion, bulk email sends, anything where one bad row should not prevent the good rows from being processed.
import { Effect } from "effect"
// ┌─── Effect<[FieldError[], string[]], never, never>// ▼const sift = (emails: ReadonlyArray<string>) => Effect.partition(emails, validateEmail)
// Input: ["ok@a", "bad", "ok@b"]// Result: Success([[FieldError("bad")], ["ok@a", "ok@b"]])The E channel is never — partition cannot fail. The errors are data, not failures.
That’s the deliberate difference from validateAll: choose partition when “some failed” is an acceptable outcome and you want to proceed with the rest.
Your turn partition a mixed email list and assert *both* halves — the failure array contains the invalid inputs and the success array contains the valid ones, with no overlap and no loss.
Common Pitfalls
Using gen when you need all errors. yield* stops at the first failure. If you need every error, reach for validate or validateAll.
Confusing validateAll (all-or-nothing error array) with partition (always succeeds). validateAll fails with FieldError[] if any input fails; partition always succeeds and puts failures in the first tuple slot.
the result contains every failure, not just the first. - [ ] Finish with `partition` — note the tuple `[failures, successes]` order; it's the same convention as `Effect.either` flipped to a collection.
See Also
- Previous,
09-error-handling:catchTag,catch,mapError - Next,
13-async-operations:tryPromise,promise - Later,
49-payment-system: validation in a real workflow
Run the tests: pnpm test 10-error-accumulation
The full test suite
The index.test.ts for this lesson, verbatim from steps/10-error-accumulation/.
import { describe, expect, it } from "vite-plus/test"import { Data, Effect, Exit } from "effect"
class FieldError extends Data.TaggedError("FieldError")<{ readonly field: string }> {}
describe("error accumulation", () => { const validate = (field: string, valid: boolean) => valid ? Effect.succeed(field) : Effect.fail(new FieldError({ field }))
it("fails fast by default", () => { expect(Exit.isFailure(Effect.runSyncExit(Effect.all([validate("name", false), validate("age", false)])))).toBe(true) })
it("returns every result when validation needs a complete report", () => { const results = Effect.runSync(Effect.all( [validate("name", false), validate("age", false)], { mode: "result" }, )) expect(results.map((result) => result._tag)).toEqual(["Failure", "Failure"]) })})