Skip to content

proposal: ErrorOr type wrapping (T, error) #51931

@jabolopes

Description

@jabolopes

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 like func()T cannot be instantiated with (T, error) or func()(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:

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions