Skip to content

proposal: spec: handle errors with select #67316

Closed
@chad-bekmezian-snap

Description

@chad-bekmezian-snap

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:

  1. Doesn't fit into the way Go looks/feels today
  2. Adds a special keyword, and therefore isn't backwards compatible
  3. The proposal only cuts down on lines of code and does nothing to increase the likelihood of properly handling errors.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions