Error handling
Try[T] is iter.Seq2[T, error]: a lazy sequence whose elements each
either succeeded or carry an error. The design’s central choice is that
the pipeline does not decide what an error means — the consumer does,
because both policies are legitimate in the same codebase: a malformed
line in a log scan should probably be skipped, a failed row scan should
probably abort.
vals, err := t.Collect() // stop at the first error; partial slice + errorvals, errs := t.CollectAll() // drain everything, gather all errorsvals := t.Ignore().Collect() // drop failures, continue as a plain Seqerrs := t.Errs().Collect() // the other half: just the errorsThe five rules
Section titled “The five rules”Every Try operator follows the same rule set, so nothing needs looking
up per operator. They come from one idea: an errored element is opaque
data to an intermediate and meaningful to a terminal. Rules 1–3 are the
first half, rule 5 is the second; rule 4 is a producer convention.
- Intermediates never inspect errored elements.
Filter’s predicate andMap’s function are simply not called on them; the errored element flows through untouched. - Counting is positional.
Take(3)takes three elements, errored or not — so it consumes at most three, which is what keeps termination reasoning intact. The spelling for “three successes” is.Ignore().Take(3). Note the deliberate asymmetry withCount:Takeis an intermediate and counts errored elements, whileCountis a terminal and stops at the first error (rule 5), returning the successes before it. - An error never terminates
TakeWhile. Only a successful element failing the predicate ends the sequence. - Operators that generate an error yield the zero value with it.
When
err != nil, don’t read the value — that’s the one obligation on consumers. - Single-error terminals stop at the first error (
Collect,Fold,ForEach,Err,Count). OnlyCollectAlland the intermediates continue past errors.
Try is deliberately small
Section titled “Try is deliberately small”Try carries 13 intermediates, three policy exits back to Seq (Ignore,
Errs, Must) and the collecting terminals — against the 37 chainable
operators Seq has. That is the design, not an omission: the
moment data can fail, you should pick an error policy early and get the
full API back. The intended shape is to stay in Try only while errors are
still in play, then commit — Ignore(), Must(), Collect(),
CollectAll() — and continue on Seq.
If you find yourself wanting Sorted or GroupBy on a Try, the errors
have been carried one stage too far.
Adding context where it exists
Section titled “Adding context where it exists”A scan pipeline five stages deep produces errors with no positional
information. WrapErr at the stage that has the position is the
difference between a debuggable error and a useless one:
rows.WrapErr(func(err error) error { return fmt.Errorf("orders row %d: %w", n, err)})Recover turns chosen errors back into values; OnError is the logging
tap; Must converts the first error into a panic carrying the error value
itself — for pipelines where failure is a programming bug.
Writing a producer that owns a resource
Section titled “Writing a producer that owns a resource”There is no Close on an iterator, and adding one would break range.
The pattern that works is lazy acquisition: open the resource inside
the iteration closure, so a pipeline that is built but never consumed
holds nothing, and defer fires even when a downstream stage stops early.
func Rows[T any](open func() (*sql.Rows, error), scan func(*sql.Rows) (T, error)) catena.Try[T] { return func(yield func(T, error) bool) { var zero T rows, err := open() if err != nil { yield(zero, err) // acquisition failure is the first element return } defer rows.Close() // runs on normal exit AND early termination for rows.Next() { v, err := scan(rows) if err != nil { v = zero } if !yield(v, err) { return // the defer fires } } if err := rows.Err(); err != nil { yield(zero, err) } }}Two properties carry the whole design, and both are tested against a real
database/sql driver in bake_test.go:
- Never consumed ⇒ never opened. Take an opener, not an open resource.
- Early termination closes. A downstream
Take(3)returns through the producer, which runs its defers — composing through any number of stages.
The same shape works for files (bufio.Scanner inside the closure — see
example_test.go), network readers, and anything else that must be
released. Cancellation enters the same way, at the edge:
s.UntilDone(ctx) passes elements through until the context is done, then
yields the context’s error and stops.
One deliberate hole to know about: Pull inverts control, and if you
never call its stop, the producer never returns and its defers never
run. Pull transfers ownership to you; the doc comment says so in
capitals.