A protoc plugin to generate idiomatic Go error types inspired by thiserror.
Also includes an optional Result type (see util) inspired by Rust's Result.
You've probably written code like this:
type UserRepository interface {
CreateUser(ctx context.Context, r *CreateUserRequest) (*CreateUserResponse, error)
// ...
}Looking at this, can you tell what possible errors CreateUser returns? Neither can I.
If you have a good development and review process, you can expect to see something like this:
// CreateUser persists a user in the repository.
+// Returns *InvalidArgument if the user details are invalid.
+// Returns *AlreadyExists if the user already exists.
func (db *DB) CreateUser(ctx context.Context, r *CreateUserRequest) (*CreateUserResponse, error) {
// ...
}Good, but still not confidence inspiring. You need to look at the code and hope that the developer documented the possible errors and that the documentation is relevant.
You would then use the errors like this:
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type UserService struct {
repo UserRepository
}
func (s *UserService) CreateUser(ctx context.Context, r *userpb.CreateUserRequest) (*userpb.CreateUserResponse, error) {
// ...
user, err := s.repo.CreateUser(ctx, &CreateUserRequest{...})
if err != nil {
+ var invalidArgErr *InvalidArgument
+ if errors.As(err, &invalidArgErr) {
+ return nil, status.Error(codes.InvalidArgument, invalidArgErr.Error())
+ }
+
+ var alreadyExistsErr *AlreadyExists
+ if errors.As(err, &alreadyExistsErr) {
+ return nil, status.Error(codes.AlreadyExists, alreadyExistsErr.Error())
+ }
+
+ return nil, status.Error(codes.Internal, "something went wrong")
}
}And then somebody comes along and makes this change:
// CreateUser persists a user in the repository.
// Returns *InvalidArgument if the user details are invalid.
// Returns *AlreadyExists if the user already exists.
func (db *DB) CreateUser(ctx context.Context, r *CreateUserRequest) (*CreateUserResponse, error) {
// ...
+ // Check preconditions.
+ if err := checkPreconditions(); err != nil {
+ return fmt.Errorf("user cannot be created because preconditions were not satisfied: %w", err)
+ }
}Now, a bunch of clients are unhappy because they received a generic "something went wrong" error message instead of an error telling them that some preconditions were not satisfied.
What if you could write code like this:
type UserRepository interface {
- CreateUser(ctx context.Context, r *CreateUserRequest) (*CreateUserResponse, error)
+ CreateUser(ctx context.Context, r *CreateUserRequest) Result[*CreateUserResponse, *userpb.UserRepositoryError]
// ...
}Here, userpb.UserRepositoryError is a Go error type generated by the protoc-gen-go-errors protoc plugin and looks something like this:
type UserRepositoryError struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Kind:
//
// *UserRepositoryError_Invalid
// *UserRepositoryError_AlreadyExists
// *UserRepositoryError_DependenciesNotMet
// *UserRepositoryError_Other
Kind isUserRepositoryError_Kind `protobuf_oneof:"kind"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}Your gRPC handler would look like this:
func (s *UserService) CreateUser(ctx context.Context, r *userpb.CreateUserRequest) (*userpb.CreateUserResponse, error) {
// ...
- user, err := s.repo.CreateUser(ctx, &CreateUserRequest{...})
- if err != nil {
- var invalidArgErr *InvalidArgument
- if errors.As(err, &invalidArgErr) {
- return nil, status.Error(codes.InvalidArgument, invalidArgErr.Error())
- }
-
- var alreadyExistsErr *AlreadyExists
- if errors.As(err, &alreadyExistsErr) {
- return nil, status.Error(codes.AlreadyExists, alreadyExistsErr.Error())
- }
-
- return nil, status.Error(codes.Internal, "something went wrong")
- }
+ resp := s.repo.CreateUser(ctx, &CreateUserRequest{...})
+ if resp.IsErr() {
+ switch v := resp.Err().Kind.(type) {
+ case *UserRepositoryError_Invalid:
+ return nil, status.Error(codes.InvalidArgument, v.Invalid.Error())
+ case *UserRepositoryError_AlreadyExists:
+ return nil, status.Error(codes.AlreadyExists, v.AlreadyExists.Error())
+ case *UserRepositoryError_DependenciesNotMet:
+ return nil, status.Error(codes.FailedPrecondition, v.DependenciesNotMet.Error())
+ case *UserRepositoryError_Other:
+ return nil, status.Error(codes.Internal, v.Other.Error())
+ }
+ }
+ return resp.MustGet(), nil
}where the repository would be implemented as:
func (db *DB) CreateUser(ctx context.Context, r *CreateUserRequest) Result[*CreateUserResponse, *userpb.UserRepositoryError] {
// ...
// Check preconditions.
if err := checkPreconditions(); err != nil {
- return fmt.Errorf("user cannot be created because preconditions were not satisfied: %w", err)
+ return util.Err[*CreateUserResponse](
+ new(userpb.UserRepositoryError).From(err),
+ )
}
+ // All good. Return the response.
+ return util.Ok[*CreateUserResponse, *userpb.UserRepositoryError](
+ &CreateUserResponse{...},
+ )
}
func checkPreconditions() *userpb.DependenciesNotMetError {
// ...
}Arguably, the returns are more verbose, but this is Go we're talking about. The increased verbosity might be worth it at least for key interfaces like the domain interface above. Domain interfaces are the contract with the rest of the application and errors should be part of that contract.
You don't have to use the Result type wrapper if you don't like it. The error types generated by this
protoc plugin are still valid and idiomatic Go errors and you can use them anywhere you use errors.
Note
Only messages whose Go name ends with the string "Error" are considered as errors by the protoc-gen-go-errors plugin.
Leaf errors must also set the (errors.display) option; code generation fails otherwise.
The (errors.display) extension is defined once in
protos/protoc-gen-go-errors/options.proto.
It uses extension tag 51234, which is currently fixed and cannot be changed.
All protos in this repo import options.proto
(import "protoc-gen-go-errors/options.proto") via the buf workspace defined
by the root buf.yaml — the file is not copied anywhere.
buf generate produces the Go code for all three modules (protos/, test/,
example/proto).
protoc-gen-go-errors/options.proto is published to the
Buf Schema Registry as a module, so you don't need to
copy it into your repo. Add it as a dependency in your buf.yaml:
# buf.yaml
version: v2
modules:
- path: proto
deps:
- buf.build/varunbpatil-oss/protoc-gen-go-errors@v0.1.0then run buf dep update and import it from your protos. The file's path
within the module mirrors this repo, so the import is:
import "protoc-gen-go-errors/options.proto";
message CreateUserError {
option (errors.display) = "could not create user: {cause}";
string cause = 1;
}Because options.proto declares
option go_package = "github.com/varunbpatil/protoc-gen-go-errors/errors",
your Go module also needs the plugin as a dependency so the generated
options.pb.go compiles:
go get github.com/varunbpatil/protoc-gen-go-errors@v0.1.1For the example above, you would define errors like this:
// Top level domain error. Can be one of a fixed set of error types.
message UserRepositoryError {
oneof kind {
InvalidArgumentError invalid = 1;
AlreadyExistsError already_exists = 2;
DependenciesNotMetError dependencies_not_met = 3;
OtherError other = 4;
}
}
message InvalidArgumentError {
option (errors.display) = "invalid argument: {key} = {value}";
string key = 1;
string value = 2;
}
message AlreadyExistsError {
option (errors.display) = "id {id} already exists";
string id = 1;
}
message DependenciesNotMetError {
option (errors.display) = "the following dependencies were not met: {messages}";
repeated string messages = 1;
}
message OtherError {
option (errors.display) = "{message}";
string message = 1;
}All errors in a single place makes it easy to audit errors to make sure we're not leaking sensitive information via errors.
There is also support for nested errors. Unwrap() can be used to get the nested error just as if
the error was created with fmt.Errorf("... %w", err).
message IOError {
option (errors.display) = "could not read {path}: {cause}";
string path = 1;
NotFoundError cause = 2;
}
message NotFoundError {
option (errors.display) = "not found: {entity}";
string entity = 1;
}The protoc-gen-go plugin takes care of generating the Go structs.
The protoc-gen-go-errors plugin generates the Error() and Unwrap()
methods that convert those Go structs into valid Go errors. For sum errors, it
also generates a uniform From() constructor that wraps any of the sum error's
leaf errors, plus a typed From<LeafName>() constructor for each individual
leaf.
From() is compile-time checked: its argument must be one of the sum error's
own leaf errors (new(UserRepositoryError).From(configErr)), so wrapping a
leaf of a different sum error is a compile error rather than a silent runtime
failure.
The example directory contains a sample Go project with errors generated
using this plugin. It shows how you can install and use the protoc-gen-go-errors
plugin.
This repository uses mise to manage its toolchain
(Go, buf, protoc-gen-go, golangci-lint). Everything is defined in the root
mise.toml:
mise trust # first time only
mise install # install the pinned tools
mise run generate # regenerate protos/, test/ and example/ generated code
mise run test # run all tests (root and example modules)
mise run lint # run golangci-lint
mise run publish # push the protos module to the BSR (create the org and module on buf.build once first)mise run generate builds protoc-gen-go-errors from the current source tree
before running buf, so the generated code always matches the checked-in
generator. CI runs the same commands — any drift in generated code fails the
build.
- thiserror crate for Rust.
- Go Result type defined by samber/mo.
This project is licensed under the terms of the MIT license.