go: errors: Is and As make it impossible to maintain error hygiene
$ go version go version devel +e883d000f4 Wed May 29 13:27:00 2019 +0000 linux/amd64
It’s typical that Go code has many lines of code that return errors as a consequence of other errors. In large programs, it can be hard to refactor this code with confidence because by changing the code, we might be changing the set of possible errors that can be returned, and some unknown caller might be broken by the refactor because they’re relying on a specific error that’s no longer returned.
The nature of such breakage is that it often goes unnoticed for long periods because errors are usually infrequent.
For this reason, I believe it’s good to maintain what I call error hygiene: to restrict the set of returned errors to those that are explicitly part of a function’s contract.
One good example of this is fmt.Errorf("message: %v", err)
which hides the type and value of err from any subsequent Is
or As
call.
Unfortunately, I don’t believe it’s possible to preserve error hygiene in general with the scheme used for Is and As.
Suppose that we want to define the following function that we’ll use to restrict error values to a particular type. This is a bare-minimum requirement for error hygiene.
// KeepType keeps only the given targetType as a possible target
// type for the As function.
//
// For any error e and types T and U:
//
// As(KeepType(e, new(T)), new(U))
//
// will return true if and only if As(e, new(T)) is true and U is
// identical to T. In other words, it hides all types other than T from
// future inspections with As.
func KeepType(err error, targetType interface{}) error
How can we implement this function? For errors that don’t fit the designated target type, the implementation is easy: just hide the whole error chain. However, if there’s an error that does fit the target type, there’s a problem: we can’t preserve the target type without also preserving the error chain that’s below it, because in general the chain is held in a field within the error value that we might not have access to. We can’t make a new error value because then it wouldn’t be the required target type.
I think that As
and Is
are thus potentially problematic and we should consider whether it’s a good idea to keep them as is. (so to speak 😃 )
About this issue
- Original URL
- State: closed
- Created 5 years ago
- Reactions: 3
- Comments: 26 (25 by maintainers)
@rogpeppe
I struggle to see a situation where you want non-transitive Is:
x
isMyEOF
,MyEOF
isio.EOF
, butx
is notio.EOF
.The similar case with
As
seems equally confusing:a
can be converted tob
,b
can be converted toc
, buta
cannot be converted toc
?A concrete example would help here, I think.
We address this in our draft design: “We feel that a single error is too limited a view into the error chain. More than one error might be worth examining.”
So
Is
can be used in place of error predicates. For example, to ask iferr
is temporary, you writeerrors.Is(err, os.ErrTemporary)
. This is used in several places in 1.13, notably thenet
package. For example, see thenet.DNSError.Is
method.It’s unfortunate that we needed reflectlite, but that’s an implementation detail. You need reflection for
As
, and we thinkAs
is important because it generalizes the common practice of type-asserting errors to the case of error chains.The intent of Unwrap/Is/As is to provide error information to programs, for handling (the second bullet). The
Error
method returns the string for logging. It should include information for the entire list of errors, even those not returned byUnwrap
.The two cases correspond to two
Errorf
verbs:fmt.Errorf("...: %v", err)
: displayerr
for logging, but not handlingfmt.Errrof("...: %w", err)
: displayerr
for logging, and also make it available for handlingI believe this is an implementation of
KeepType
:Proof:
KeepType
should "return true if and only ifAs(e, new(T))
is true and U is identical to T. "If direction: if U is identical to T, we skip past the initial
if
statement, executeAs(e, new(T))
(since U is identical to T) and return its value, so if U is identical to T andAs(e, new(T))
returns true, so doesKeepType
.Only if direction: we need to show that if U is not identical to T or if
As(e, new(T))
is false, thenKeepType
returns false. The initialif
handles the first disjunct, and the subsequent call toerrors.As
handles the second.This example confuses me, because
errors.Is(io.ErrUnexpectedEOF, io.EOF)
does return false. They may be related, butErrUnexpectedEOF
is notEOF
.Go already has a type assertion statement. The fact that such a low level package as
errors
needs to resort tointerface{}
and reflect-based magic is surely not ideal. Beginners will need to be exposed to this from a very early stage, and it’s really not easy to explain.Just want to add a big +1 to this point in particular. I think the current design’s reliance on reflect isn’t a good idea, and the need for reflectlite to exist is a good example.
This seems odd to me. Why should the power of hiding errors be up to the creator of the error rather than the code that wants to hide it?
There is an alternative to walking the chain. Each time we wrap an error, instead of recording a link in the chain, we instead decide whether to keep or drop the underlying error value. I don’t actually believe that it’s necessary to expose the concept of an error chain at all.
Now that the printing and source location concerns have been removed from the errors package, it has become clear to me that all we really need here is a way of distinguishing error metadata from the actual error itself. By metadata, I mean extra information such as message annotation and source location info.
One thing that was concerning to me about the current proposal is the amount of mechanism involved. Not only do we have an
Unwrap
type, we also have other interface conventions, such asinterface{Is(error)bool}
andinterface{As(interface{}) bool}
, and theAs
implementation requires the non-trivialreflectlite
package as a dependency.To me, this feels a bit wrong. Error handling is one of the lowest levels of the language. I would like an errors package that feels irreducibly simple, and the current proposal doesn’t currently make me feel that way.
How about something like this instead, as the entire implementation of the errors package?
We don’t need the
Is
orAs
methods any more, because they’re trivially implemented in normal Go:Although the
E
method looks superficially similar toUnwrap
, it’s not the same, because error wrappers don’t need to preserve the error chain - they can just keep the most recent E-value of the error that’s being wrapped. This means that error inspection is O(1). The reason for the loop inside theE
function is to keep error implementations honest and to avoid confusion:errors.E(err)
will always be the same aserrors.E(errors.E(err))
.Once we have this, error annotation and other error metadata can be left to other packages. The
%v
and%w
verbs of thefmt
package can retain their current semantics.Here’s a simple error-wrapping example: