Description
Go Programming Experience
Intermediate
Other Languages Experience
Go, Python, JS, C#, Java
Related Idea
- Has this idea, or one like it, been proposed before?
- Does this affect error handling?
- Is this about generics?
- Is this change backward compatible? Breaking the Go 1 compatibility guarantee is a large cost and requires a large benefit
Has this idea, or one like it, been proposed before?
There have been some that are probably similar, but I don't believe there have been any quite like this
Does this affect error handling?
Yes. It is different than other error handling proposal because it doesn't really cut down a ton on the "make error handling take less lines of code", but I do think it improves error handling.
Is this about generics?
No
Proposal
There have been many language changes proposed to improve error handling. Many of these have had syntax changes to hopefully get rid of the so frequently used
if err != nil {
return err
}
by adding some special syntax to quickly return. Such as err?
. There have been a few common issues risen with these proposals:
- Doesn't fit into the way Go looks/feels today
- Adds a special keyword, and therefore isn't backwards compatible
- The proposal only cuts down on lines of code and does nothing to increase the likelihood of properly handling errors.
- A change of control flow, or at least on obfuscated control flow.
With this proposal, I aim to avoid all 4 of the above pitfalls. The proposal has three primary parts, all involving the enhancement of select
statements. In my opinion, one of the issues with error handling in Go is that it is strictly a package level feature, not actually a language feature. This proposal would change that.
I propose the select
statement be expanded to accept one parameter which, if provided, must be a nil or non-nil value of a type which implements the error
interface. The select
statement block is only entered if the error argument is non-nil. Its body syntax will be essentially identical to that of the switch
statement, but behavior is a bit different for errors. Each case
can be one of two format, except the default
case which will have the same behavior and syntax as you would typically expect. The syntax for a case would either be case ErrIsValue: // case body
or case varName as typedErr
where as is a new keyword, but only recognized in this specific context, and therefore still backwards compatible. Each case is evaluated in the order it is provided. If the as
keyword is absent, the behavior of matching the case is equivalent to checking whether errors.Is(err, ErrIsValue)
== true. Similarly, if the as
keyword is present, it is functionally equivalent to errors.As(err, &typedErr{})
Additionally, I propose a special "abbreviated" select
syntax for errors that looks like this select err return "template string: %w"
, which behaves the same as select err { default: return fmt.Errorf("detailed message: %w", err) }
.
Okay, that's enough talk with no examples! Here's what it looks like before and after the change:
Before Option 1
func handler(w http.ResponseWriter, r *http.Request) {
if err := validateHeaders(r.Header); err != nil {
if errors.Is(err, ErrUnprocessableEntity) {
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
var badRequestErr InvalidFieldsErr
if errors.As(err, &badRequestErr) {
slog.Error("invalid headers", "fields", badRequestErr.Fields())
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusInternalServerError)
return
}
// handle happy path
}
func validateHeaders(header http.Header) error {
for key, values := range header {
if err := validateHeader(key, values); err != nil {
return fmt.Errorf("specific error message: %w", err)
}
}
}
Before Option 2
func handler(w http.ResponseWriter, r *http.Request) {
if err := validateHeaders(r.Header); err != nil {
var badRequestErr InvalidFieldsErr
switch {
case errors.Is(err, ErrUnprocessableEntity):
w.WriteHeader(http.StatusUnprocessableEntity)
return
case errors.As(err, &badRequestErr):
slog.Error("invalid headers", "fields", badRequestErr.Fields())
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusInternalServerError)
return
}
// handle happy path
}
func validateHeaders(header http.Header) error {
for key, values := range header {
if err := validateHeader(key, values); err != nil {
return fmt.Errorf("specific error message: %w", err)
}
}
}
After
func handler(w http.ResponseWriter, r *http.Request) {
err := validateHeaders(r.Header)
select err {
case ErrUnprocessableEntity:
w.WriteHeader(http.StatusUnprocessableEntity)
return
case badRequestErr as InvalidFieldsErr:
slog.Error("invalid headers", "fields", badRequestErr.Fields())
w.WriteHeader(http.StatusBadRequest)
return
default:
w.WriteHeader(http.StatusInternalServerError)
return
}
// handle happy path
}
func validateHeaders(header http.Header) error {
for key, values := range header {
err := validateHeader(key, values)
select err return "specific error message: %w"
// Alternatively
/// select err := validateHeader(key, values); err return "specific error message: %w"
}
}
It can be argued that "Before Option 2" and "After" are not all that different. However, I think the code paths and cases are far easier in my proposal as each case is very simple and clear. Additionally, this proposal does more to make errors and error handling first class citizens in the Go language, rather than merely a package level feature with a built in interface. I believe cutting out the boiler plate will lead to people reaching more often to handle errors properly. Additionally, this proposal removes the need for if err != nil {}
that bothers people so often, and provides a common syntax for handling errors in all cases.
Is this change backward compatible?
I believe it is!
Cost Description
Implementation is probably somewhat costly. Runtime cost should be no different than what we have today.
Performance Costs
Compile time cost should be negligible. Runtime cost should be no different than today.