-
Notifications
You must be signed in to change notification settings - Fork 18.3k
Description
Hi everyone,
Following @robpike 's suggestion, I'd like to create a proposal for a new way to think about errors given that Go supports generics now.
This is similar in spirit the plan to think about APIs in the presence of generics but for error handling specifically.
Preliminary notes:
- This is my first proposal, so apologies if I used the wrong format or if it's too long.
- All names used in this proposal, such as,
ErrorOr
,Val()
,Err()
, etc, are meant for illustration purposes only and any new names are welcome!
Objective
Rethink Go's error handling mechanism in the presence of generics to overcome the following problems:
(T, error)
allows for 4 possible states but the majority of APIs only use 2 states and the other 2 states could be considered incorrect.- Error-agnostic generic APIs: Generic parameters like
T
and generic functions likefunc()T
cannot be instantiated with(T, error)
orfunc()(T, error)
respectively. This means API developers must artificially bake in errors in their APIs to allow callers to pass errors or pass error-returning functions. - Errors / values in channels, maps, slices
- Naming problems with
err
. - Inconsistent use of
(T, error)
The following are out of scope for this proposal:
- Solve the
context.Context
problem for APIs. - Treat
(x, y, z, ...)
argument like a tuple. - Treat
(T, error)
return like a tuple (see alternatives considered). - Currying.
Background
The 4 states of (T, error)
The current mechanism to return errors is (T, error)
which being a product type means it allows for 4 possible states, i.e., any combination of a proper / improper value, and an error or nil
. But the majority of APIs only care about either returning a value or an error, they don't care about the other 2 states, which could even be considered incorrect. Notable exceptions to this rule are the io.Reader.Read
API (more on this later).
Because most APIs care only about 2 states, there is an opportunity to leverage the type system and generics to eliminate the 2 undesirable states, by introducing a new type ErrorOr
that can only represent a value or an error, and doesn't allow for any other states. This would be used in new generics aware APIs.
Error-agnostic generic APIs
One such API is the singleflight.Do
, which could be reimagined with generics as:
func (g *Group[K, V]) Do(key K, fn func()V) (v V, shared bool)
The Do
API is error-agnostic, i.e., in principle it could accept in fn
either an error-returning function or not because it doesn't do anything with the error. But in current generics, it's not possible to pass an error-returning function to func()V
since V
cannot be instantiated with multiple-return values or (T, error)
.
This applies not only to singleflight
but to any API that accepts a generic argument T
or a generic function func()T
, does not inspect that argument, but simply returns it later.
To overcome this limitation, API developers must artificially bake in errors in their APIs, for example:
func (g *Group[K, V]) Do(key Key, fn func()(V, error)) (v V, err error, shared bool)
or:
func (g *Group[K, V]) Do(key Key, fn func()V) (v V, shared bool)
func (g *Group[K, V]) DoErr(key Key, fn func()(V, error)) (v V, err error, shared bool)
both of which are suboptimal because even though this API is error-agnostic, this property was lost and it's not longer reflected in its type or enforced by the typesystem.
With ErrorOr
, the burden of deciding on whether the API needs error handling or not goes away, and instead API callers have a free choice on whether to call Do
with an error-returning function (e.g., func()ErrorOr[T]
) or not (e.g., func()T
).
Also, with ErrorOr
the fact that singleflight.Do
is error-agnostic remains captured in the type and enforced by the typesystem. This is a property that is worth retaining.
Another (small) benefit of the ErrorOr
in this case is that the singleflight.Group
implementation would be simpler because it would need to store only 1 value per call (i.e., the ErrorOr[T]
) instead of storing 2 values per call (i.e., the T
and the error
).
Errors / values in channels, maps, slices
When I use channels to implement pipelines or glue computations together that can either return a value or fail with an error, I found myself having to define a new struct
type to encapsulate the value / error to use in the channel, e.g., chan IntOrError
(pre-generics).
The ErrorOr
would also be useful for channels, e.g, chan ErrorOr[int]
so we don't need to redefine new struct types for this use case like ValueOrError
(or a generic equivalent of that) because we can reuse ErrorOr
instead.
We could also store ErrorOr[T]
in collections such as maps, slices, etc. For example, spawn 10 goroutines and have each store their result in []ErrorOr[int]
. Another example, implement a cache with positive and negative caching, e.g., map[K]ErrorOr[V]
.
Naming problem with err
When we use the pattern (T, error)
, we need to define the err
many times:
x, err := myfunc1()
if err != nil { return ... }
y, err := myfunc2()
if err != nil { return ... }
In some cases, there are no new names on the left side of :=
so we need a few tricks there, either by writing var err error
:
x := ...
y := ...
var err error
x, err = myfunc1()
...
y, err = myfunc2()
or by defining different names for the error:
x := ...
y := ...
x, err1 := myfunc1()
y, err2 := myfunc2()
With ErrorOr
we don't need tricks:
x := myfunc1()
if x.Err() != nil { return ... }
y := myfunc2()
if y.Err() != nil { return ... }
return x.Val() + y.Val(), nil
Inconsistent use of (T, error)
There are APIs that are exception to 4 states of (T, error)
rule, such as, the io.Reader.Read
. But they are the minority.
The documentation for this interface method requires 4 paragraphs just to explain that this API can actually return a value and an error at the same time. If it were common practice in Go to return a value and an error at the same time, it would not be necessary 4 paragraphs of documentation to explain this notable exception.
Furthermore, the fact that it requires such as careful explanation is evidence in itself that this is a pitfall for API callers. And reading through the details it sounds very error prone and confusing.
A new type like ErrorOr
could also be useful for a new API like Read(...)ErrorOr[int]
because it would mean more consistency regarding error handling across APIs, we could also remove those 4 paragraphs of documentation, less pitfalls for developers, and also better type safety. API callers would need to either handle the value or the error, and there would be no ambiguity in that.
Design ideas
One possible definition of ErrorOr
is the following:
package erroror
import ...
type ErrorOr[T any] struct {
value T
err error
}
func New[T any](value T) ErrorOr[T] { return ErrorOr[T]{value: value} }
func Error[T any](err error) ErrorOr[T] { return ErrorOr[T]{err: err} }
func (e ErrorOr[T]) Val() T { return e.value }
func (e ErrorOr[T]) Err() error { return e.err }
The New
and Error
constructors only allow for either a value or error, they don't allow for both an error and a proper value.
Related work
The ErrorOr
has different names in other languages but in essence it's the same idea:
- C++ ABSL absl::StatusOr
- Haskell's Data.Either
- Ocaml Either
- F# Result
Known issues
new(ErrorOr[T])
can create a state that is neither error not a proper value. Perhaps we would need special tooling or compiler support to prevent or produce a warning if a developer wrote this code, since it would be desirable to always use either erroror.New
or erroror.Error
.
Future ideas
This is out of scope for this proposal but I think it's an interesting, possible future extension of the ErrorOr
idea, that can help with Go exceptions without actually requiring exceptions.
Let's say we have a new operator (I will use <-
but other syntactic choices are possible), then we could do the following:
func MyFunc(...) ErrorOr[T] ...
func OtherFunc(...) ErrorOr[T] {
x := <- MyFunc(...)
...
}
func OtherFunc2(...) ErrorOr[T] {
x := MyFunc(...)
if x.Err() != nil {
return x
}
...
}
In this example, OtherFunc
and OtherFunc2
are equivalent but in OtherFunc
we use this new operator to avoid having to explicitly write the error handling code. The new operator <-
does the automatic error handling for us by checking if the ErrorOr
contains an error and if so, return that error to the caller. This may be an alternative to introducing exceptions without actually requiring full support for exceptions.
Alternatives considered
An alternative to ErrorOr
would be to treat (T, error)
like a tuple type so that a type like func()(T, error)
could be used to instantiate generic functions like func()T
. But this would be a much bigger change than ErrorOr
with far reaching implications for the language. The ErrorOr
is just a new type.
More resources
Initial discussion was #48287 (comment)