Skip to content
This repository was archived by the owner on Dec 22, 2025. It is now read-only.

Commit 60e43ab

Browse files
authored
feat: errutil.Reduce (#18)
* test: add initial spec and tests for errutil.Reduce * feat: implement errutil.Reduce Implementation is not most efficient, naive recursive * docs: improve Reduce docs * feat: add initial Merge function * feat: add nil filtering on merge function * example: add merge example * fix: remove non-sense comments * fix: remove unnecessary return statement * docs: improve docs * feat: remove Merge and add nil filtering to reduce * fix: allow reducer to return/receive nils * docs: improve * docs: improve docs
1 parent 0ecd556 commit 60e43ab

4 files changed

Lines changed: 293 additions & 4 deletions

File tree

Makefile

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1+
coverage=coverage.txt
2+
13
all: lint test bench
24

35
test:
4-
go test -race -timeout 10s -coverprofile=coverage.txt -covermode=atomic ./...
6+
go test -race -timeout 10s -coverprofile=$(coverage) -covermode=atomic ./...
57

68
test/%:
7-
go test -race -timeout 10s -coverprofile=coverage.txt -covermode=atomic -run="${*}" ./...
9+
go test -race -timeout 10s -coverprofile=$(coverage) -covermode=atomic -run="${*}" ./...
10+
11+
coverage/show: test
12+
go tool cover -html=$(coverage)
813

914
fmt:
1015
gofmt -s -w .

errutil/error.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
// Utilities include:
55
//
66
// - An error type that makes it easy to work with const error sentinels.
7+
// - An easy way to wrap a list of errors together.
8+
// - An easy way to reduce a list of errors.
9+
//
10+
// Flexible enough that you can do your own wrapping/merging logic
11+
// but in a functional/simple way.
712
package errutil
813

914
import "errors"
@@ -14,6 +19,9 @@ import "errors"
1419
// as it's base type.
1520
type Error string
1621

22+
// Reducer reduces 2 errors into one
23+
type Reducer func(error, error) error
24+
1725
// Error return a string representation of the error.
1826
func (e Error) Error() string {
1927
return string(e)
@@ -40,6 +48,24 @@ func Chain(errs ...error) error {
4048
}
4149
}
4250

51+
// Reduce will reduce all errors to a single one using the
52+
// provided reduce function.
53+
//
54+
// If errs is empty it returns nil, if errs has a single err
55+
// (len(errs) == 1) it will return the err itself.
56+
//
57+
// Nil errors on the errs args will be filtered out initially,
58+
// before reducing, so you can expect errors passed to the reducer
59+
// to be always non-nil.
60+
//
61+
// But if the reducer function itself returns nil, then the returned nil
62+
// won't be filtered and will be passed as an argument on the next
63+
// reducing step.
64+
func Reduce(r Reducer, errs ...error) error {
65+
errs = removeNils(errs)
66+
return reduce(r, errs...)
67+
}
68+
4369
type errorChain struct {
4470
head error
4571
tail error
@@ -74,3 +100,15 @@ func removeNils(errs []error) []error {
74100
}
75101
return res
76102
}
103+
104+
func reduce(r Reducer, errs ...error) error {
105+
if len(errs) == 0 {
106+
return nil
107+
}
108+
if len(errs) == 1 {
109+
return errs[0]
110+
}
111+
err1, err2 := errs[0], errs[1]
112+
err := r(err1, err2)
113+
return reduce(r, append([]error{err}, errs[2:]...)...)
114+
}

errutil/error_example_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,36 @@ func ExampleChain() {
3939
fmt.Println(errors.Is(err, layer1Err))
4040
fmt.Println(errors.Is(err, layer2Err))
4141
fmt.Println(errors.Is(err, layer3Err))
42+
fmt.Println(err)
4243

4344
// Output:
4445
// true
4546
// true
4647
// true
48+
// layer1Err: layer2Err: layer3Err
49+
}
50+
51+
func ExampleReduce() {
52+
// call multiple functions that may return an error
53+
// but none of them should interrupt overall computation
54+
var i int
55+
someFunc := func() error {
56+
i++
57+
return fmt.Errorf("error %d", i)
58+
}
59+
60+
var errs []error
61+
62+
errs = append(errs, someFunc())
63+
errs = append(errs, someFunc())
64+
errs = append(errs, someFunc())
65+
66+
err := errutil.Reduce(func(err1, err2 error) error {
67+
return fmt.Errorf("%v,%v", err1, err2)
68+
}, errs...)
69+
70+
fmt.Println(err)
71+
72+
// Output:
73+
// error 1,error 2,error 3
4774
}

errutil/error_test.go

Lines changed: 221 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,9 +251,228 @@ func TestErrorChainRespectIsMethodOfChainedErrors(t *testing.T) {
251251
}
252252
}
253253

254-
// To test the Is method the error must not be comparable.
254+
func TestErrorReducing(t *testing.T) {
255+
type testcase struct {
256+
name string
257+
errs []error
258+
reduce errutil.Reducer
259+
want string
260+
wantNil bool
261+
}
262+
263+
mergeWithComma := func(err1, err2 error) error {
264+
return fmt.Errorf("%v,%v", err1, err2)
265+
}
266+
267+
tests := []testcase{
268+
{
269+
name: "reducing empty err list wont call reducer and returns nil",
270+
errs: []error{},
271+
reduce: func(err1, err2 error) error {
272+
panic("unreachable")
273+
},
274+
wantNil: true,
275+
},
276+
{
277+
name: "reducing one error wont call reducer and returns error",
278+
errs: []error{errors.New("one")},
279+
reduce: func(err1, err2 error) error {
280+
panic("should not be called")
281+
},
282+
want: "one",
283+
},
284+
{
285+
name: "reducing two errors",
286+
errs: []error{errors.New("one"), errors.New("two")},
287+
reduce: mergeWithComma,
288+
want: "one,two",
289+
},
290+
{
291+
name: "reducing three errors",
292+
errs: []error{
293+
errors.New("one"),
294+
errors.New("two"),
295+
errors.New("three"),
296+
},
297+
reduce: mergeWithComma,
298+
want: "one,two,three",
299+
},
300+
{
301+
name: "filtering just first err of 3",
302+
errs: []error{
303+
errors.New("one"),
304+
errors.New("two"),
305+
errors.New("three"),
306+
},
307+
reduce: func(err1, err2 error) error {
308+
return err1
309+
},
310+
want: "one",
311+
},
312+
{
313+
name: "filtering just first err of single err",
314+
errs: []error{errors.New("one")},
315+
reduce: func(err1, err2 error) error {
316+
return err1
317+
},
318+
want: "one",
319+
},
320+
{
321+
name: "filtering just second err",
322+
errs: []error{
323+
errors.New("one"),
324+
errors.New("two"),
325+
errors.New("three"),
326+
},
327+
reduce: func(err1, err2 error) error {
328+
return err2
329+
},
330+
want: "three",
331+
},
332+
{
333+
name: "reduces 3 errs to nil",
334+
errs: []error{
335+
errors.New("one"),
336+
errors.New("two"),
337+
errors.New("three"),
338+
},
339+
reduce: func(err1, err2 error) error {
340+
return nil
341+
},
342+
wantNil: true,
343+
},
344+
{
345+
name: "reduces 2 errs to nil",
346+
errs: []error{
347+
errors.New("one"),
348+
errors.New("two"),
349+
},
350+
reduce: func(err1, err2 error) error {
351+
return nil
352+
},
353+
wantNil: true,
354+
},
355+
{
356+
name: "first is nil",
357+
errs: []error{
358+
nil,
359+
errors.New("error 2"),
360+
errors.New("error 3"),
361+
},
362+
reduce: mergeWithComma,
363+
want: "error 2,error 3",
364+
},
365+
{
366+
name: "second is nil",
367+
errs: []error{
368+
errors.New("error 1"),
369+
nil,
370+
errors.New("error 3"),
371+
},
372+
reduce: mergeWithComma,
373+
want: "error 1,error 3",
374+
},
375+
{
376+
name: "third is nil",
377+
errs: []error{
378+
errors.New("error 1"),
379+
errors.New("error 2"),
380+
nil,
381+
},
382+
reduce: mergeWithComma,
383+
want: "error 1,error 2",
384+
},
385+
{
386+
name: "multiple nils interleaved",
387+
errs: []error{
388+
nil,
389+
nil,
390+
nil,
391+
errors.New("error 1"),
392+
nil,
393+
nil,
394+
errors.New("error 2"),
395+
nil,
396+
nil,
397+
},
398+
reduce: mergeWithComma,
399+
want: "error 1,error 2",
400+
},
401+
{
402+
name: "first err among nils",
403+
errs: []error{
404+
errors.New("error 1"),
405+
nil,
406+
nil,
407+
nil,
408+
},
409+
reduce: mergeWithComma,
410+
want: "error 1",
411+
},
412+
{
413+
name: "last err among nils",
414+
errs: []error{
415+
nil,
416+
nil,
417+
nil,
418+
errors.New("error 1"),
419+
},
420+
reduce: mergeWithComma,
421+
want: "error 1",
422+
},
423+
{
424+
name: "reduces list with nils to nil",
425+
errs: []error{
426+
nil,
427+
nil,
428+
nil,
429+
},
430+
reduce: mergeWithComma,
431+
wantNil: true,
432+
},
433+
}
434+
435+
for _, test := range tests {
436+
t.Run(test.name, func(t *testing.T) {
437+
g := errutil.Reduce(test.reduce, test.errs...)
438+
439+
if test.wantNil {
440+
if g == nil {
441+
return
442+
}
443+
t.Fatalf(
444+
"errutil.Reduce(%v)=%q; want nil",
445+
test.errs,
446+
g,
447+
)
448+
}
449+
450+
if g == nil {
451+
t.Fatalf(
452+
"errutil.Reduce(%v)=nil; want %q",
453+
test.errs,
454+
test.want,
455+
)
456+
}
457+
458+
got := g.Error()
459+
want := test.want
460+
461+
if got != want {
462+
t.Fatalf(
463+
"errutil.Reduce(%v)=%q; want=%q",
464+
test.errs,
465+
got,
466+
want,
467+
)
468+
}
469+
})
470+
}
471+
}
472+
473+
// To test the Is method the error base type must not be comparable.
255474
// If it is comparable, Go always just compares it, the Is method
256-
// is just a fallback, not an override of actual behavior.
475+
// is just a fallback, not an override of actual comparison behavior.
257476
type errorThatNeverIs []string
258477

259478
func (e errorThatNeverIs) Is(err error) bool {

0 commit comments

Comments
 (0)