Union error type in Go
Force returning a subset of error types from a function in Go.
Background
Go has a very simple error handling mechanism. A function in Go can return multiple values,
the last value can be an error
type. The error
interface is simple:
type error interface {
Error() string
}
The easiest way to create a new error is using fmt.Errorf
or errors.New
.
However, error created from string is pretty freeform and caller cannot extract
structured information out of it reliably.
A more structured way is to define a struct that implements the error
interface.
For example, we can define a UserError
and ValidationError
:
type UserError struct {
UserName string
Code int // assume this service has some registry of error code
Message string
}
func (e *UserError) Error() string {
return fmt.Sprintf("user: %s, code: %d, message: %s", e.UserName, e.Code, e.Message)
}
type ValidationError struct {
Message string
Field string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error on field: %s, message: %s", e.Field, e.Message)
}
To return one of the error types, we just write error
in the function signature, e.g.
func doSomething(v string) error {
if v == "" {
return &ValidationError{
Message: "value is empty",
Field: "v",
}
}
if v == "admin" {
return &UserError{
UserName: "admin",
Code: 403,
Message: "user is not allowed",
}
}
return nil
}
Now there are two issues we need to address:
- Let the caller know what type of error will be returned without looking at the implementation.
- Force the implementation to return one of the error types and not using
fmt.Errorf
lazily.
In Rust, you can use Result<T, E>
where E
is a union of error types, e.g.
enum MyError {
UserError(UserError),
ValidationError(ValidationError),
}
In Go, there is no union (or pattern matching...), the closest is interface and type assertion.
Define a interface as union for error types
We can define a interface that has a speical marker. e.g. APIError
type APIError interface {
error
IsAPIError()
}
func (u *UserError) IsAPIError() {}
func (u *ValidationError) IsAPIError() {}
Now we can use APIError
in the function signature, e.g.
func doSomething(v string) APIError {
if v == "" {
return &ValidationError{
Message: "value is empty",
Field: "v",
}
}
if v == "admin" {
return &UserError{
UserName: "admin",
Code: 403,
Message: "user is not allowed",
}
}
return nil
}
To inspect the error type after wrapping using fmt.Errorf
, we can use errors.As
.
func wrapError(v string) error {
err := doSomething(v)
if err == nil {
return nil
}
return fmt.Errorf("wrapped: %w", err)
}
var _ APIError = &UserError{}
var _ APIError = &ValidationError{}
func TestAPIError(t *testing.T) {
err := wrapError("admin")
var u *UserError
if errors.As(err, &u) {
t.Logf("user error: %s", u.Error())
}
err = wrapError("")
var v *ValidationError
if errors.As(err, &v) {
t.Logf("validation error: %s", v.Error())
}
}
This is not that straightfoward in the beginning, but makes the code easier to maintain in the long run. Especially if you are writing a library. The type system is more up to date than seprated documentation. Further more, for writing API server/client, these error types can be defined in schema and generated directly.
Why not using generic
Go's generic does allow you to define an interface that looks like a union, e.g.
type MyError interface {
UserError | ValidationError
}
However, you cannot return different types from a generic function, e.g.
func doSomething[T MyError](v string) T {
if v == "" {
return &ValidationError{
Message: "value is empty",
Field: "v",
}
}
if v == "admin" {
return &UserError{
UserName: "admin",
Code: 403,
Message: "user is not allowed",
}
}
return nil
}
Will lead to error:
cannot use &ValidationError{…} (value of type *ValidationError) as T value in return statement: cannot assign *ValidationError to UserError (in T)
The polymorphism is compile time, not runtime like Java's interface.
References
Stacktrace (not related to this post, but came across them ...)