Skip to content

proposal: spec: cleaner error handling happy path via limited pattern matching #65266

Open
@DeedleFake

Description

@DeedleFake

Go Programming Experience

Experienced

Other Languages Experience

JavaScript, Elixir, Kotlin, Dart, Ruby

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?

I don't think so, but considering how many error handling proposals there have been over the years it is possible.

Does this affect error handling?

Yes. It differs in that it doesn't attempt to handle errors in a magical way, but instead introduces a new syntax that can be used for several different types of common data handling.

Is this about generics?

No.

Proposal

Prior Art

In Elixir, pattern matching is often used as a way to deal with errors. Functions that can return error values usually return a tuple of the form {:error, error_string}, and will usually return {:ok, result} when they don't fail. This results in code that looks like

{:ok, result} = thing_that_can_fail()

That code checks to make sure that the first element is :ok and assigns the second element to a new variable, result. If the pattern does not match, it will crash. When you want to handle the error, however, you generally use something like a case expression, which allows checking multiple possible patterns:

case thing_that_can_fail() do
  {:ok, result} -> use_result(result)
  {:error, err} -> handle_error(err)
end

This is fine until you need to do a bunch of things in a row and several of them can fail:

case thing_that_can_fail() do
  {:ok, result} -> case other_thing_that_can_fail() do
                              {:ok, result2} -> use_results(result, result2)
                              {:error, err} -> handle_error(err)
                            end
  {:error, err} -> handle_error(err)
end

To help deal with this, Elixir has a with expression which allows handling multiple pattern matches in a row. The following code is functionally identical to the previous snippet:

with {:ok, result} <- thing_that_can_fail(),
         {:ok, result2} <- other_thing_that_can_fail() do
           use_results(result, result2)
else
  {:error, err} -> handle_error(err)
end

Proposal

My proposal is to adopt a variant of the second syntax above, with, and make it a bit more Go-like, and use it to allow deduplication of repeated, nearly identical error handling. This would work by adding a new keyword, though what exactly I'm not sure. To illustrate, I'll use with to match the Elixir code, but it doesn't really need to be. Normally a new keyword would be a problem in terms of backwards compatibility, but it should be possible to tell from context if the keyword should be treated as such here. It would only be legal to use the keyword if it was immediately followed by a { and was the first part of a statement. In all other cases, the keyword would be treated as an identifier. I don't know if this is too complicated in and of itself, but if so there may be alternative syntaxes for this that wouldn't cause such issues. I haven't thought of them, though.

Inside of a with block and only inside of a with block, very limited pattern matching would be possible. It would use a new assignment operator which I shall assume to be ~= for illustration purposes. Pattern matching would only work on direct results of functions, not on the internals of values, and would be simple == checks.

Here's an example:

with {
  result, nil ~= thingThatCanFail() // This line fails the pattern match if the second return value is not nil.
  val, true ~= result.(T) // Works for all comparable types, not just errors, and even works with type assertions.
  return useValue(val) // Other lines of code are possible, too.
} else {
  case _, error(err):
    return nil, fmt.Errorf("failed to do thing: %w", err)
}

The else block would be a list of cases looking similar to a switch or a select. Each case would be a comma-separated list of identifiers, with each being the same as a pattern match above. If any pattern match in the with block fails, the same values are run against the cases of the else block in top-to-bottom order. If one succeeds, it is run instead and then the whole block exits.

Pattern matches would be very limited in terms of what they could do. Along with being able to check against literals and non-shadowed predefined values, such as true, nil, etc., they could each be wrapped in what looks like a type conversion. If they are, they are constrained to that specific type. This is demonstrated in the error handling case above. If no cases in the else block match, the entire block panics. Or does nothing. I'm not sure which makes more sense.

Comparison Example

As another example, here's a program that opens two files and copies the contents of one into the other. Here it is without with:

src, err := os.Open("input.txt")
if err != nil {
  return err
}
defer src.Close()

dst, err := os.Create("output.txt")
if err != nil {
  return err
}
defer dst.Close()

_, err := io.Copy(dst, src)
if err != nil {
  return err
}

And with with:

with {
  src, nil ~= os.Open("input.txt")
  defer src.Close()

  dst, nil ~= os.Create("output.txt")
  defer dst.Close()

  _, nil ~= io.Copy(dst, src)
} else {
  case _, error(err):
    return err
}

Primary Concerns

I have three primary issues with my own proposal. I think both of them are solvable, but I'm not quite sure at the moment how to do so.

One is the specific definition for the rules surrounding variable creation. In the above examples, I just kind of assumed that src and dst were being created by the ~= operator, but maybe that assumption doesn't make sense. Should it just work exactly like := in terms of variable creation, shadowing, etc? Or should it never create new variables? What are the rules surrounding what is a value on the left-hand side and what is not? Should it only be predeclared values, or should it be possible to match against values stored in variables themselves? Elixir uses the pin operator to declare that a variable should not be declared during a match, i.e. {^v, r} = something() meaning that r is a new variable but ^v should just match against the value already stored in v. Maybe something like that makes sense? I'm not sure, and I could probably be persuaded either way.

Similarly, I'm worried about ways to differentiate between similar cases that should be handled separately. For example, what if I wanted to add custom error messages to the above example? If all I wanted to add it to was io.Copy(), it would be easy enough to just not use pattern matching for that call and handle it the old-fashioned way there. Another option would be to add a third value to indicate, something like

with {
  _, err := io.Copy(dst, src)
  nil, err ~= err, fmt.Errorf("copy failed: %w", err)
} else {
  case _, error(err):
    return err
}

And finally, how should overlapping types be handled in else cases? For example, if I used the above code in the example before, the _, error(err) case would have two different possible types for _, *os.File and int64. Is that a problem? Maybe it could work similarly to #65031. That could get kind of messy, though.

Conclusion

As you can probably tell from the concerns section, I'm not entirely sold on my own proposal, but I think it's a possibility for a completely different approach to cleaning up error handling code. It doesn't solve every problem people have with error handling, but I think that it leverages multiple returns as part of handling errors more than most proposals, many of which are trying to come up with ways to make functions with multiple returns including an error act as though they are not. That being said, even if this proposal is rejected, maybe it'll inspire a better one.

Language Spec Changes

Two main changes: Add a with block and add the ~= operator and associated rules.

Informal Change

No response

Is this change backward compatible?

I think so, but it should be possible to create an alternative that is if it is not. The parts that are potentially no backwards compatible are basically just implementation details.

Orthogonality: How does this change interact or overlap with existing features?

It enables separation of repeated error handling from the happy path of the code.

Would this change make Go easier or harder to learn, and why?

Slightly harder, but I don't think that it's overly complicated. It adds a new type of control flow, but I think that the main complication would probably come from the rules surrounding how the pattern matching works.

Cost Description

Some extra complexity. Possible runtime cost, but very minimal if it exists at all. It's mostly just syntax sugar. Most possible cost could come from some potentially unnecessary type switches, but it might be possible to optimize those away at compile-time in most cases.

Changes to Go ToolChain

Everything that parses Go code would be affected.

Performance Costs

Likely minimal in both cases.

Prototype

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    LanguageChangeSuggested changes to the Go languageLanguageChangeReviewDiscussed by language change review committeeProposalerror-handlingLanguage & library change proposals that are about error handling.

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions