Repository: uber-go/dig
Pull request #256 (2019-12-17 17:27:50)

errors: Add errf to support multi-line errors
*Commits are individually reviewable.*

Errors printed by dig are currently a massive wall of text. We've
received consistent feedback that these errors are difficult to read.
To help make these error messages more readable, we're going to split
them across multiple lines when printed with `%+v` instead of `%v`.

As a first step to doing this, we need to inform the system where
individual sections of error messages end so that newlines can be
introduced. A natual point for most errors in the form `foo: bar` is
after the `:`. So, `something went wrong: great sadness` could be
printed as the following with `%+v`.

    something went wrong:
    great sadness

To inform the system where error messages end, we're going to resort to
chaining error messages with our existing `wrappedError` typed. The
error above will become,

    wrappedError{
      msg: "something went wrong",
      cause: errors.New("great sadness"),
    }

Doing this by hand is cumbersome, so to build these error chains
conveniently, this change introduces an `errf(string, ...interface{})`
function. This will replace use of `fmt.Errorf` and our previous
`errWrapf` functions. It supports building chains of multiple formatted
errors by adding another printf-style string and its arguments when the
arguments for the previous string end.

    errf(
      "could not process %q (ID: %v)", thing, thingID,
      "request %v is invalid", requestID,
    )

The above will build an error chain where the second string is the
cause of the first. So the above is roughly equivalent to the following.

    wrappedError{
      msg: `could not process "foo" (ID: 12)`,
      err: errors.New("request 42 is invalid"),
    }

In addition to that, the function supports a standalone `error`
argument in place of a string for its last argument. This helps use
replace the `errWrapf` function we previously used.

    errf("could not parse %q", s, err)
    // equivalent to
    errWrapf(err, "could not parse %q", s)

    // Output:
    // could not parse "foo": not a number

You can interpret each string and its formatting arguments as one node
in the chain of errors, where the final node can be another such string
and its arguments, or a different `error`.

---

This change holds a series of individually reviewable changes that
introduce this functionality and transition existing usage of
`fmt.Errorf` and `errWrapf` over to it. Additionally, this also breaks
apart `fmt.Errorf`-based error messages on sentence boundaries so that
they are chained together, with the intent of having them broken across
multiple lines in the future.

    fmt.Errorf("cannot do x: thing y is invalid: %v", err)
    // becomes
    errf("cannot do x", "thing y is invalid", err)

This has no user-facing changes at this time, but the idea is that
we'll be able to print multi-line error messages, inserting newlines at
these boundaries.

Refs T2587659

codecov[bot]: # [Codecov](https://codecov.io/gh/uber-go/dig/pull/256?src=pr&el=h1) Report
> Merging [#256](https://codecov.io/gh/uber-go/dig/pull/256?src=pr&el=desc) into [master](https://codecov.io/gh/uber-go/dig/commit/cfca627871180f3a5d7dab4453f7598e0c758013?src=pr&el=desc) will **increase** coverage by `0.08%`.
> The diff coverage is `98.03%`.

[![Impacted file tree graph](https://codecov.io/gh/uber-go/dig/pull/256/graphs/tree.svg?width=650&token=ARuH7Frwgw&height=150&src=pr)](https://codecov.io/gh/uber-go/dig/pull/256?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #256      +/-   ##
==========================================
+ Coverage   98.05%   98.14%   +0.08%     
==========================================
  Files          12       12              
  Lines        1134     1183      +49     
==========================================
+ Hits         1112     1161      +49     
  Misses         17       17              
  Partials        5        5
```


| [Impacted Files](https://codecov.io/gh/uber-go/dig/pull/256?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [cycle.go](https://codecov.io/gh/uber-go/dig/pull/256/diff?src=pr&el=tree#diff-Y3ljbGUuZ28=) | `100% <100%> (ø)` | :arrow_up: |
| [dig.go](https://codecov.io/gh/uber-go/dig/pull/256/diff?src=pr&el=tree#diff-ZGlnLmdv) | `100% <100%> (ø)` | :arrow_up: |
| [param.go](https://codecov.io/gh/uber-go/dig/pull/256/diff?src=pr&el=tree#diff-cGFyYW0uZ28=) | `96.33% <100%> (+0.01%)` | :arrow_up: |
| [error.go](https://codecov.io/gh/uber-go/dig/pull/256/diff?src=pr&el=tree#diff-ZXJyb3IuZ28=) | `100% <100%> (ø)` | :arrow_up: |
| [result.go](https://codecov.io/gh/uber-go/dig/pull/256/diff?src=pr&el=tree#diff-cmVzdWx0Lmdv) | `98.1% <88.88%> (+0.01%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/uber-go/dig/pull/256?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/uber-go/dig/pull/256?src=pr&el=footer). Last update [cfca627...c7c5c16](https://codecov.io/gh/uber-go/dig/pull/256?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

mh-park: lgtm too. Can you also add a little more context and/or link to an issue to the pull request message on why a multi-line error is helpful and a desired change?

Patch:
diff --git a/cycle.go b/cycle.go
index 03f9f6b..356c1dd 100644
--- a/cycle.go
+++ b/cycle.go
@@ -68,7 +68,7 @@ func verifyAcyclic(c containerStore, n provider, k key) error {
 		{Key: k, Func: n.Location()},
 	}, visited)
 	if err != nil {
-		err = errWrapf(err, "this function introduces a cycle")
+		err = errf("this function introduces a cycle", err)
 	}
 	return err
 }
diff --git a/dig.go b/dig.go
index 36df087..2fcab16 100644
--- a/dig.go
+++ b/dig.go
@@ -66,8 +66,9 @@ type provideOptions struct {
 
 func (o *provideOptions) Validate() error {
 	if len(o.Group) > 0 && len(o.Name) > 0 {
-		return fmt.Errorf(
-			"cannot use named values with value groups: name:%q provided with group:%q", o.Name, o.Group)
+		return errf(
+			"cannot use named values with value groups",
+			"name:%q provided with group:%q", o.Name, o.Group)
 	}
 
 	// Names must be representable inside a backquoted string. The only
@@ -75,10 +76,10 @@ func (o *provideOptions) Validate() error {
 	// https://golang.org/ref/spec#raw_string_lit is that they cannot contain
 	// backquotes.
 	if strings.ContainsRune(o.Name, '`') {
-		return fmt.Errorf("invalid dig.Name(%q): names cannot contain backquotes", o.Name)
+		return errf("invalid dig.Name(%q): names cannot contain backquotes", o.Name)
 	}
 	if strings.ContainsRune(o.Group, '`') {
-		return fmt.Errorf("invalid dig.Group(%q): group names cannot contain backquotes", o.Group)
+		return errf("invalid dig.Group(%q): group names cannot contain backquotes", o.Group)
 	}
 	return nil
 }
@@ -331,7 +332,7 @@ func (c *Container) Provide(constructor interface{}, opts ...ProvideOption) erro
 		return errors.New("can't provide an untyped nil")
 	}
 	if ctype.Kind() != reflect.Func {
-		return fmt.Errorf("must provide constructor function, got %v (type %v)", constructor, ctype)
+		return errf("must provide constructor function, got %v (type %v)", constructor, ctype)
 	}
 
 	var options provideOptions
@@ -365,7 +366,7 @@ func (c *Container) Invoke(function interface{}, opts ...InvokeOption) error {
 		return errors.New("can't invoke an untyped nil")
 	}
 	if ftype.Kind() != reflect.Func {
-		return fmt.Errorf("can't invoke non-function %v (type %v)", function, ftype)
+		return errf("can't invoke non-function %v (type %v)", function, ftype)
 	}
 
 	pl, err := newParamList(ftype)
@@ -410,7 +411,7 @@ func (c *Container) verifyAcyclic() error {
 	visited := make(map[key]struct{})
 	for _, n := range c.nodes {
 		if err := detectCycles(n, c, nil /* path */, visited); err != nil {
-			return errWrapf(err, "cycle detected in dependency graph")
+			return errf("cycle detected in dependency graph", err)
 		}
 	}
 
@@ -437,7 +438,7 @@ func (c *Container) provide(ctor interface{}, opts provideOptions) error {
 
 	ctype := reflect.TypeOf(ctor)
 	if len(keys) == 0 {
-		return fmt.Errorf("%v must provide at least one non-error type", ctype)
+		return errf("%v must provide at least one non-error type", ctype)
 	}
 
 	for k := range keys {
@@ -539,9 +540,10 @@ func (cv connectionVisitor) Visit(res result) resultVisitor {
 		k := key{name: r.Name, t: r.Type}
 
 		if conflict, ok := cv.keyPaths[k]; ok {
-			*cv.err = fmt.Errorf(
-				"cannot provide %v from %v: already provided by %v",
-				k, path, conflict)
+			*cv.err = errf(
+				"cannot provide %v from %v", k, path,
+				"already provided by %v", conflict,
+			)
 			return nil
 		}
 
@@ -551,9 +553,10 @@ func (cv connectionVisitor) Visit(res result) resultVisitor {
 				cons[i] = fmt.Sprint(p.Location())
 			}
 
-			*cv.err = fmt.Errorf(
-				"cannot provide %v from %v: already provided by %v",
-				k, path, strings.Join(cons, "; "))
+			*cv.err = errf(
+				"cannot provide %v from %v", k, path,
+				"already provided by %v", strings.Join(cons, "; "),
+			)
 			return nil
 		}
 
@@ -680,9 +683,10 @@ func isFieldOptional(f reflect.StructField) (bool, error) {
 
 	optional, err := strconv.ParseBool(tag)
 	if err != nil {
-		err = errWrapf(err,
+		err = errf(
 			"invalid value %q for %q tag on field %v",
-			tag, _optionalTag, f.Name)
+			tag, _optionalTag, f.Name, err)
+
 	}
 
 	return optional, err
diff --git a/error.go b/error.go
index 6bf3d01..72b23d9 100644
--- a/error.go
+++ b/error.go
@@ -22,6 +22,7 @@ package dig
 
 import (
 	"bytes"
+	"errors"
 	"fmt"
 	"reflect"
 	"sort"
@@ -54,23 +55,118 @@ func RootCause(err error) error {
 	}
 }
 
-// errWrapf wraps an existing error with more contextual information.
+// errf is a version of fmt.Errorf with support for a chain of multiple
+// formatted error messages.
 //
-// The given error is treated as the cause of the returned error (see causer).
+// After msg, N arguments are consumed as formatting arguments for that
+// message, where N is the number of % symbols in msg. Following that, another
+// string may be added to become the next error in the chain. Each new error
+// is the `cause()` for the prior error.
 //
-//   RootCause(errWrapf(errWrapf(err, ...), ...)) == err
+//   err := errf(
+//     "could not process %v", thing,
+//     "name %q is invalid", thing.Name,
+//  )
+//  fmt.Println(err)  // could not process Thing: name Foo is invalid
+//  fmt.Println(RootCause(err))  // name Foo is invalid
 //
-// Use errWrapf instead of fmt.Errorf if the message ends with ": <original error>".
-func errWrapf(err error, msg string, args ...interface{}) error {
-	if err == nil {
-		return nil
-	}
+// In place of a string, the last error can be another error, in which case it
+// will be treated as the cause of the prior error chain.
+//
+//   errf(
+//     "could not process %v", thing,
+//     "date %q could not be parsed", thing.Date,
+//     parseError,
+//  )
+func errf(msg string, args ...interface{}) error {
+	// By implementing buildErrf as a closure rather than a standalone
+	// function, we're able to ensure that it is called only from errf, or
+	// from itself (recursively). By controlling these invocations in such
+	// a tight space, we are able to easily verify manually that we
+	// checked len(args) > 0 before making the call.
+	var buildErrf func([]interface{}) error
+	buildErrf = func(args []interface{}) error {
+		arg, args := args[0], args[1:] // assume len(args) > 0
+		if arg == nil {
+			panic("It looks like you have found a bug in dig. " +
+				"Please file an issue at https://github.com/uber-go/dig/issues/ " +
+				"and provide the following message: " +
+				"arg must not be nil")
+		}
+
+		switch v := arg.(type) {
+		case string:
+			need := numFmtArgs(v)
+			if len(args) < need {
+				panic(fmt.Sprintf(
+					"It looks like you have found a bug in dig. "+
+						"Please file an issue at https://github.com/uber-go/dig/issues/ "+
+						"and provide the following message: "+
+						"string %q needs %v arguments, got %v", v, need, len(args)))
+			}
+
+			msg := fmt.Sprintf(v, args[:need]...)
+			args := args[need:]
+
+			// If we don't have anything left to chain with, build the
+			// final error.
+			if len(args) == 0 {
+				return errors.New(msg)
+			}
+
+			return wrappedError{
+				msg: msg,
+				err: buildErrf(args),
+			}
+		case error:
+			if len(args) > 0 {
+				panic(fmt.Sprintf(
+					"It looks like you have found a bug in dig. "+
+						"Please file an issue at https://github.com/uber-go/dig/issues/ "+
+						"and provide the following message: "+
+						"error must be the last element but got %v", args))
+			}
+
+			return v
 
-	if len(args) > 0 {
-		msg = fmt.Sprintf(msg, args...)
+		default:
+			panic(fmt.Sprintf(
+				"It looks like you have found a bug in dig. "+
+					"Please file an issue at https://github.com/uber-go/dig/issues/ "+
+					"and provide the following message: "+
+					"unexpected errf-argument type %T", arg))
+		}
 	}
 
-	return wrappedError{err: err, msg: msg}
+	// Prepend msg to the args list so that we can re-use the same
+	// args processing logic. The msg is a string just for type-safety of
+	// the first error.
+	newArgs := make([]interface{}, len(args)+1)
+	newArgs[0] = msg
+	copy(newArgs[1:], args)
+	return buildErrf(newArgs)
+}
+
+// Returns the number of formatting arguments in the provided string. Does not
+// count escaped % symbols, specifically the string "%%".
+//
+//   fmt.Println(numFmtArgs("rate: %d%%"))  // 1
+func numFmtArgs(s string) int {
+	var (
+		count   int
+		percent bool // saw %
+	)
+	for _, c := range s {
+		if percent && c != '%' {
+			// Counts only if it's not a %%.
+			count++
+		}
+
+		// Next iteration should consider % only if the current %
+		// stands alone.
+		percent = !percent && c == '%'
+	}
+	return count
 }
 
 type wrappedError struct {
diff --git a/param.go b/param.go
index 4daa413..c639d8f 100644
--- a/param.go
+++ b/param.go
@@ -64,17 +64,17 @@ var (
 func newParam(t reflect.Type) (param, error) {
 	switch {
 	case IsOut(t) || (t.Kind() == reflect.Ptr && IsOut(t.Elem())) || embedsType(t, _outPtrType):
-		return nil, fmt.Errorf("cannot depend on result objects: %v embeds a dig.Out", t)
+		return nil, errf("cannot depend on result objects", "%v embeds a dig.Out", t)
 	case IsIn(t):
 		return newParamObject(t)
 	case embedsType(t, _inPtrType):
-		return nil, fmt.Errorf(
-			"cannot build a parameter object by embedding *dig.In, embed dig.In instead: "+
-				"%v embeds *dig.In", t)
+		return nil, errf(
+			"cannot build a parameter object by embedding *dig.In, embed dig.In instead",
+			"%v embeds *dig.In", t)
 	case t.Kind() == reflect.Ptr && IsIn(t.Elem()):
-		return nil, fmt.Errorf(
-			"cannot depend on a pointer to a parameter object, use a value instead: "+
-				"%v is a pointer to a struct that embeds dig.In", t)
+		return nil, errf(
+			"cannot depend on a pointer to a parameter object, use a value instead",
+			"%v is a pointer to a struct that embeds dig.In", t)
 	default:
 		return paramSingle{Type: t}, nil
 	}
@@ -177,7 +177,7 @@ func newParamList(ctype reflect.Type) (paramList, error) {
 	for i := 0; i < numArgs; i++ {
 		p, err := newParam(ctype.In(i))
 		if err != nil {
-			return pl, errWrapf(err, "bad argument %d", i+1)
+			return pl, errf("bad argument %d", i+1, err)
 		}
 		pl.Params = append(pl.Params, p)
 	}
@@ -296,7 +296,7 @@ func newParamObject(t reflect.Type) (paramObject, error) {
 
 		pof, err := newParamObjectField(i, f)
 		if err != nil {
-			return po, errWrapf(err, "bad field %q of %v", f.Name, t)
+			return po, errf("bad field %q of %v", f.Name, t, err)
 		}
 
 		po.Fields = append(po.Fields, pof)
@@ -345,7 +345,7 @@ func newParamObjectField(idx int, f reflect.StructField) (paramObjectField, erro
 	var p param
 	switch {
 	case f.PkgPath != "":
-		return pof, fmt.Errorf(
+		return pof, errf(
 			"unexported fields not allowed in dig.In, did you mean to export %q (%v)?",
 			f.Name, f.Type)
 
@@ -420,11 +420,13 @@ func newParamGroupedSlice(f reflect.StructField) (paramGroupedSlice, error) {
 	optional, _ := isFieldOptional(f)
 	switch {
 	case f.Type.Kind() != reflect.Slice:
-		return pg, fmt.Errorf("value groups may be consumed as slices only: "+
+		return pg, errf("value groups may be consumed as slices only",
 			"field %q (%v) is not a slice", f.Name, f.Type)
+
 	case name != "":
-		return pg, fmt.Errorf(
-			"cannot use named values with value groups: name:%q requested with group:%q", name, pg.Group)
+		return pg, errf(
+			"cannot use named values with value groups",
+			"name:%q requested with group:%q", name, pg.Group)
 
 	case optional:
 		return pg, errors.New("value groups cannot be optional")
diff --git a/result.go b/result.go
index d113e26..e6e1ec4 100644
--- a/result.go
+++ b/result.go
@@ -67,19 +67,19 @@ type resultOptions struct {
 func newResult(t reflect.Type, opts resultOptions) (result, error) {
 	switch {
 	case IsIn(t) || (t.Kind() == reflect.Ptr && IsIn(t.Elem())) || embedsType(t, _inPtrType):
-		return nil, fmt.Errorf("cannot provide parameter objects: %v embeds a dig.In", t)
+		return nil, errf("cannot provide parameter objects", "%v embeds a dig.In", t)
 	case isError(t):
-		return nil, fmt.Errorf("cannot return an error here, return it from the constructor instead")
+		return nil, errf("cannot return an error here, return it from the constructor instead")
 	case IsOut(t):
 		return newResultObject(t, opts)
 	case embedsType(t, _outPtrType):
-		return nil, fmt.Errorf(
-			"cannot build a result object by embedding *dig.Out, embed dig.Out instead: "+
-				"%v embeds *dig.Out", t)
+		return nil, errf(
+			"cannot build a result object by embedding *dig.Out, embed dig.Out instead",
+			"%v embeds *dig.Out", t)
 	case t.Kind() == reflect.Ptr && IsOut(t.Elem()):
-		return nil, fmt.Errorf(
-			"cannot return a pointer to a result object, use a value instead: "+
-				"%v is a pointer to a struct that embeds dig.Out", t)
+		return nil, errf(
+			"cannot return a pointer to a result object, use a value instead",
+			"%v is a pointer to a struct that embeds dig.Out", t)
 	case len(opts.Group) > 0:
 		return resultGrouped{Type: t, Group: opts.Group}, nil
 	default:
@@ -196,7 +196,7 @@ func newResultList(ctype reflect.Type, opts resultOptions) (resultList, error) {
 
 		r, err := newResult(t, opts)
 		if err != nil {
-			return rl, errWrapf(err, "bad result %d", i+1)
+			return rl, errf("bad result %d", i+1, err)
 		}
 
 		rl.Results = append(rl.Results, r)
@@ -273,13 +273,13 @@ func (ro resultObject) DotResult() []*dot.Result {
 func newResultObject(t reflect.Type, opts resultOptions) (resultObject, error) {
 	ro := resultObject{Type: t}
 	if len(opts.Name) > 0 {
-		return ro, fmt.Errorf(
-			"cannot specify a name for result objects: %v embeds dig.Out", t)
+		return ro, errf(
+			"cannot specify a name for result objects", "%v embeds dig.Out", t)
 	}
 
 	if len(opts.Group) > 0 {
-		return ro, fmt.Errorf(
-			"cannot specify a group for result objects: %v embeds dig.Out", t)
+		return ro, errf(
+			"cannot specify a group for result objects", "%v embeds dig.Out", t)
 	}
 
 	for i := 0; i < t.NumField(); i++ {
@@ -291,7 +291,7 @@ func newResultObject(t reflect.Type, opts resultOptions) (resultObject, error) {
 
 		rof, err := newResultObjectField(i, f, opts)
 		if err != nil {
-			return ro, errWrapf(err, "bad field %q of %v", f.Name, t)
+			return ro, errf("bad field %q of %v", f.Name, t, err)
 		}
 
 		ro.Fields = append(ro.Fields, rof)
@@ -335,7 +335,7 @@ func newResultObjectField(idx int, f reflect.StructField, opts resultOptions) (r
 	var r result
 	switch {
 	case f.PkgPath != "":
-		return rof, fmt.Errorf(
+		return rof, errf(
 			"unexported fields not allowed in dig.Out, did you mean to export %q (%v)?", f.Name, f.Type)
 
 	case f.Tag.Get(_groupTag) != "":
@@ -392,8 +392,9 @@ func newResultGrouped(f reflect.StructField) (resultGrouped, error) {
 	optional, _ := isFieldOptional(f)
 	switch {
 	case name != "":
-		return rg, fmt.Errorf(
-			"cannot use named values with value groups: name:%q provided with group:%q", name, rg.Group)
+		return rg, errf(
+			"cannot use named values with value groups",
+			"name:%q provided with group:%q", name, rg.Group)
 	case optional:
 		return rg, errors.New("value groups cannot be optional")
 	}


Test patch:
diff --git a/error_test.go b/error_test.go
index 2194779..21ee484 100644
--- a/error_test.go
+++ b/error_test.go
@@ -28,36 +28,9 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
-func TestErrWrapf(t *testing.T) {
-	t.Run("nil", func(t *testing.T) {
-		err := errWrapf(nil, "hi")
-		assert.NoError(t, err, "expected no error")
-		assert.NoError(t, RootCause(err), "root cause must be nil")
-	})
-
-	t.Run("single wrap", func(t *testing.T) {
-		err := errors.New("great sadness")
-		werr := errWrapf(err, "something went %s", "wrong")
-
-		assert.Equal(t, err, RootCause(werr), "root cause must match")
-		assert.Equal(t, "something went wrong: great sadness", werr.Error(),
-			"error message must match")
-	})
-
-	t.Run("double wrap", func(t *testing.T) {
-		err := errors.New("great sadness")
-
-		werr := errWrapf(err, "something went %s", "wrong")
-		werr = errWrapf(werr, "something else went wrong")
-
-		assert.Equal(t, err, RootCause(werr), "root cause must match")
-		assert.Equal(t, "something else went wrong: something went wrong: great sadness", werr.Error(),
-			"error message must match")
-	})
-}
-
 // assertErrorMatches matches error messages against the provided list of
 // strings.
 //
@@ -145,3 +118,119 @@ func (s stringFinder) Find(got string) (rest string, ok bool) {
 	}
 	return got[i+len(s):], true
 }
+
+func TestErrf(t *testing.T) {
+	type args = []interface{}
+
+	tests := []struct {
+		desc string
+		give error
+
+		wantMsg       string
+		wantRootCause error
+	}{
+		{
+			desc:          "single unformatted error",
+			give:          errf("foo"),
+			wantMsg:       "foo",
+			wantRootCause: errors.New("foo"),
+		},
+		{
+			desc:          "single formatted error",
+			give:          errf("foo %d %s", 42, "bar"),
+			wantMsg:       "foo 42 bar",
+			wantRootCause: errors.New("foo 42 bar"),
+		},
+		{
+			desc:          "multiple unformatted errors",
+			give:          errf("foo", "bar", "baz"),
+			wantMsg:       "foo: bar: baz",
+			wantRootCause: errors.New("baz"),
+		},
+		{
+			desc:          "multiple formatted errors",
+			give:          errf("foo %d", 42, "bar %s", "baz", "qux %q", "quux"),
+			wantMsg:       `foo 42: bar baz: qux "quux"`,
+			wantRootCause: errors.New(`qux "quux"`),
+		},
+		{
+			desc:          "single error",
+			give:          errf("foo", "bar", errors.New("great sadness")),
+			wantMsg:       "foo: bar: great sadness",
+			wantRootCause: errors.New("great sadness"),
+		},
+		{
+			desc:          "multiple errors",
+			give:          errf("foo", "bar: %v", errors.New("baz"), errors.New("great sadness")),
+			wantMsg:       "foo: bar: baz: great sadness",
+			wantRootCause: errors.New("great sadness"),
+		},
+		{
+			desc:          "escaped percent",
+			give:          errf("foo %% %v", "bar"),
+			wantMsg:       "foo % bar",
+			wantRootCause: errors.New("foo % bar"),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.desc, func(t *testing.T) {
+			err := tt.give
+			require.NotNil(t, err, "invalid test: must not be nil")
+
+			t.Run("Error", func(t *testing.T) {
+				assert.Equal(t, tt.wantMsg, err.Error())
+			})
+
+			t.Run("RootCause", func(t *testing.T) {
+				assert.Equal(t, tt.wantRootCause, RootCause(err))
+			})
+		})
+	}
+}
+
+func TestErrfInvalid(t *testing.T) {
+	t.Run("nil panics", func(t *testing.T) {
+		assert.Panics(t, func() {
+			errf("foo", nil)
+		})
+	})
+
+	t.Run("too few argumetns", func(t *testing.T) {
+		assert.Panics(t, func() {
+			errf("foo %v")
+		})
+	})
+
+	t.Run("error before last", func(t *testing.T) {
+		assert.Panics(t, func() {
+			errf("foo", errors.New("bar"), "baz %v", 42)
+		})
+	})
+
+	t.Run("unknown type", func(t *testing.T) {
+		assert.Panics(t, func() {
+			errf("foo %v", 42, 43)
+		})
+	})
+}
+
+func TestNumFmtArgs(t *testing.T) {
+	tests := []struct {
+		desc string
+		give string
+		want int
+	}{
+		{"empty", "", 0},
+		{"none", "foo bar", 0},
+		{"some", "foo %v b %d ar", 2},
+		{"trailing", "foo %v 100%", 1},
+		{"escaped", "foo %v bar %% baz %d", 2},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.desc, func(t *testing.T) {
+			assert.Equal(t, tt.want, numFmtArgs(tt.give))
+		})
+	}
+}
diff --git a/graph_test.go b/graph_test.go
index 154b4ed..96facea 100644
--- a/graph_test.go
+++ b/graph_test.go
@@ -21,7 +21,6 @@
 package dig
 
 import (
-	"fmt"
 	"reflect"
 	"testing"
 
@@ -498,7 +497,7 @@ func TestVisualize(t *testing.T) {
 		c.Provide(func(in1) out1 { return out1{} })
 		c.Provide(func(in2) t4 { return t4{} })
 		c.Provide(func() out2 { return out2{} })
-		c.Provide(func() (out3, error) { return out3{}, fmt.Errorf("great sadness") })
+		c.Provide(func() (out3, error) { return out3{}, errf("great sadness") })
 		err := c.Invoke(func(t4 t4) { return })
 
 		VerifyVisualization(t, "error", c, VisualizeError(err))
@@ -509,7 +508,7 @@ func TestVisualize(t *testing.T) {
 				c := New()
 				c.Provide(func(in1) out1 { return out1{} })
 				c.Provide(func(in2) t4 { return t4{} })
-				c.Provide(func() (out2, error) { return out2{}, fmt.Errorf("great sadness") })
+				c.Provide(func() (out2, error) { return out2{}, errf("great sadness") })
 				c.Provide(func() out3 { return out3{} })
 				err := c.Invoke(func(t4 t4) { return })
 
@@ -519,7 +518,7 @@ func TestVisualize(t *testing.T) {
 			t.Run("if only the root node fails all node except for the root should be pruned", func(t *testing.T) {
 				c := New()
 				c.Provide(func(in1) out1 { return out1{} })
-				c.Provide(func(in2) (t4, error) { return t4{}, fmt.Errorf("great sadness") })
+				c.Provide(func(in2) (t4, error) { return t4{}, errf("great sadness") })
 				c.Provide(func() out2 { return out2{} })
 				c.Provide(func() out3 { return out3{} })
 				err := c.Invoke(func(t4 t4) { return })
@@ -566,12 +565,12 @@ func TestCanVisualizeError(t *testing.T) {
 	}{
 		{
 			desc:         "unvisualizable error",
-			err:          fmt.Errorf("great sadness"),
+			err:          errf("great sadness"),
 			canVisualize: false,
 		},
 		{
 			desc:         "nested unvisualizable error",
-			err:          nestedErr{err: fmt.Errorf("great sadness")},
+			err:          nestedErr{err: errf("great sadness")},
 			canVisualize: false,
 		},
 		{