Description
Proposal Details
In Go, a map lookup, a type assertion and a channel receive operation will report success or failure in a second result value of type bool
, if requested so for by assignment. This value is false
if the operation failed and true
if succeeded.
Functions and methods report failure in a in similar way by returning a last (and possibly only) value of type error
. If this value is not nil
, the function failed and the caller should handle the failure appropriately by logging, returning a decorated error, panicking, ignoring, etc. There are exceptions, such as fmt.Errorf
, where returning an error is not a failure, but these are mostly obvious.
As such the following constructs are ubiquitous in Go code:
// Current syntax for detecting/handling failure
// map key lookup
val, ok := m[key]
if !ok {
// key is not found in the map
}
// type assertions
rw, ok := r.(io.ReadWriter)
if !ok {
// r does not satisfy io.ReadWriter
}
// channel receiving
val, ok := <-ch
if !ok {
// channel closed
}
// file opening
f, err := os.Open("readme")
if err != nil {
// ...
}
This proposal contains an idea to syntactically handle these kinds of failure reporting using the else
keyword.
This is would works as follows.
Map lookup, type assertion and channel receive operations can optionally be followed by the else
keyword and a code block. If so, the ensuing code block is executed if the operation failed (ie. ok
in the examples above would be false). When used in an assignment, the assignment (or short variable declaration) takes place before executing the else
block.
Example:
// Proposed new syntax
// map lookup failure handling
x := m["domain"] else {
x = "localhost"
}
// type assertion failure handling
rw := r.(io.ReadWriter) else {
return fmt.Errorf(...)
}
// channel receive failure handling
val := <- ch else {
panic("channel unexpectally closed")
}
For sake of clarity, the map lookup the example above is identical to following current syntax
var x string
{
var ok bool
x, ok = m["domain"]
if !ok {
x = "localhost"
}
}
and in a similar way for type assertions and channel receive operations.
For function and method calls, if the last return argument of the function is of type error
, the call can also optionally be followed by the else
keyword. In this case else
must be followed by both an identifier and a code block. The identifier is used to create a variable in the code block capturing the error value which is removed from the call's returned argument list. The else-block is executed if the error
value is not nil
. The identifier cannot be omitted but is allowed to be _
.
This would look as follows:
// Proposed new syntax
f := os.Open("readme.txt") else err {
return fmt.Errorf("uhoh: %w", err)
}
f.Close() else err {
log.Println("warning: error closing file: %s", err.Error())
return
}
// ignore error by assigning it to `_`
f.Close() else _ {
panic("how checks closing errors anyway")
}
Note that the err
variable is lifted to a separate scope (and ok
variables are elided completely).
If the result of the call is used in an assignment, the assignment (or short variable declaration) of all other values take place before executing the ensuing else
code block.
// set a default value on error
// (notice that val is visible in else-block because
// assignment happense before the else-block is executed)
val := parse(input) else err {
log.Printf("...")
val = "Go" // ok
}
// here val is not visible because the short variable
// declaration is not part of the else construct.
val := toUpper(parse(input) else err {
log.Printf("...")
val = "Go" // error: "val" is not defined
})
This is the full proposal. What follows is some motivation for this proposal and a discussion of some open ends.
Motivation
In current Go, variables that are needed to handle failures, often named err
and ok
, are almost always part of the same scope as the result variables. As a consequence, they are visible long after their (intended) usage even though they are no longer needed. A typical symptom of this situation is having to change err :=
into err =
(or back again) when reorganising code, or having to use err =
instead of err :=
because err
was used earlier in the scope somewhere already, handling an error that is no longer relevant.
This is resolved using the proposed syntax (if the programmer chooses so), because ok
variables are now elided completely and err
variables are now scoped to the block that handles the error. As a result, the "happy path" of the code stays free from the variables that deal with the "therapy path" (failure / error path).
Some bugs in Go code appear to be a result of how Go currently deals with error handling syntactically:
str := "Go"
println(str)
if foo() {
str, err := parse(...)
if err != nil {
return fmt.Errorf("...", err)
}
log.Println("found str: ", str)
}
// BUG: str is here still "Go", while the result of
// parse() is expected if foo() is true
It is likely that this type of bug occurs less with the new syntax, because :=
is no longer used with a mix of existing and new (error) variables:
str := "Go"
println(str)
if foo() {
str = parse(...) else err {
return fmt.Errorf("...", err)
}
log.Println("found str: ", str)
}
Notice that error handling syntax becomes smoother with the proposal. The overwhelmingly common error handling case of the form
// current Go
f, err := SomeFunc()
if err != nil {
// handle error (do nothing, return, decorate, panic, log, etc)
}
can now be written as
// proposed Go
f := SomeFunc() else err {
// ...
}
while still allowing handling the error exactly as before (i.e. by means of logging, returning, panicking, ignoring etc). This is a reduction 25% in terms of lines, but also reduces err != nil
to err
which saves some additional typing. Of course, the current way of handling errors is still perfectly valid.
Finally, using this construct it becomes possible to use functions that return both a value and error (T, error)
in chain calls and as argument to other calls without using intermediate variables. Of course, readability should always prevail, but this allows "inline" error handling just like it is allowed to e.g. "inline" define a function:
func foo(r io.Reader) (string, error) {}
// "inline" error handling
s := foo(io.Open("readme") else err {
panic(err)
})
type Bar struct{}
func (b Bar) DoA() (Bar, error)
func (b Bar) DoB() (Bar, error)
func (b Bar) DoS() (string, error)
var b Bar
// Chain calling functions that return (T, error)
s := b.DoA() else err {
panic("a")
}.DoB() else err {
panic("b")
} else err {
panic("s")
}
Discussion
I do not expect that this proposal is water-tight on arrival. What follows are some open ends. Also, it might turn out that the proposal is completely unusable because I oversaw some syntax ambiguities that arises when using else
this way. However, it seemed to me that using the else
keyword to handle failure is worth investigating (maybe even in a different form if this proposal doesn't work) as it does not introduce new keywords and feels somewhat natural to use in this regard.
Custom error types
In this proposal, handling errors with else
the function must return a last argument of type error
and not a type that merely satisfies error
. I chose this for simplicity but the idea could possibly be extended to allow for custom error types requiring only that the error
interface is satisfied.
type MyError struct{}
func (m *MyError) Error {}
func foo() *MyError {
// ...
}
foo() else err { // allowed?
}
Custom map types
Likewise, it can be expected that container types will mimic map behaviour by exposing methods such as:
type CustomMap[Key comparable, Value Any] struct{}
func (c *CustomMap[Key, Value]) Lookup(k Key) (Value, bool) {
}
These methods cannot be used with the syntax proposed here, although it could possibly be extended to work with function having a last argument of bool
instead of error
as well. However, it is unclear if this does not interfere with if
syntax too much so I left it out.
Interference with if
Finally, it is easy to come up with examples that look a little weird when combined with if
. For example:
// possibly funny looking construct on first sight
if strconv.ParseBool(input) else err {
return fmt.Errorf("expected 'true' or 'false'")
} {
println("got true")
}
Of course, handling errors using else
is complimentary to current failure handling so if confusion arises it might be better to use the current way of error handling.
Activity
seankhliao commentedon Feb 19, 2024
doesn't seem very different from #41908 #56895
seankhliao commentedon Feb 19, 2024
Please fill out https://github.com/golang/proposal/blob/master/go2-language-changes.md when proposing language changes
[-]proposal: syntax for failure handling use the "else" keyword[/-][+]proposal: Go 2: allow else err after function calls[/+]seankhliao commentedon Feb 19, 2024
also #32848 #37243
adonovan commentedon Feb 19, 2024
What happens if the else block completes normally?
The scope of variable x starts after this complete statement, so you can't assign to it from the 'else' block. Are you proposing to change the scope rules too?
This doesn't seem to add anything not already rejected in prior proposals (thanks @seankhliao for the links).
markusheukelom commentedon Feb 19, 2024
Nothing special.
In my proposal any assignment happens just before the else scope is excuted (if so). So x can indeed be accessed in the else block. I've included an example of how it would translate to current syntax in the proposal.
earthboundkid commentedon Feb 19, 2024
I would say see #31442, but I think you're familiar with it. 😄
adonovan commentedon Feb 19, 2024
Well, something must happen because the postfix
else {...}
operator changed the type of its operand (the call) so that it no longer has an error. So where did the error go? Was it silently discarded, causing the program to press on into uncharted states?nirui commentedon Feb 20, 2024
I think this proposal, as well as many others were based on the assumption that the error returning format
v, err := call()
is something special to the language. But maybe it's not the case in reality.Currently, returning the error by
err, v := call()
worked equally well. To the language, the returnederr
is just another return value, nothing special.So I guess in order for proposal like this to be successful, the
v, err := call()
format must be "formalized" to the language spec. But then that may in turn introduce some inconvenient limit to the language.apparentlymart commentedon Feb 20, 2024
The original writeup compares an existing supported pattern of an declaration/assignment followed by an
if
statement to a new proposal that combines the two into a single statement.I think it's also interesting to compare the new proposal to the other currently-supported form where the declaration/assignment is embedded in the
if
statement:This shorthand has a similar "weight on the page" as the proposed new grammar. In this case the
if
condition must still be written out explicitly -- there is no assumption about what the final return value might represent -- but the ability to bundle it into theif
statement header makes it collapse visually into that header, where it's easier to ignore while scanning. (The fact that it's harder to scan is actually why I often don't use this style, but for the sake of this comparison I'm assuming that having fewer lines is more important, since that seems to be a goal of the proposal.)Comparing these two, the most significant difference seems to be that of scoping: when embedding a declaration in an
if
statement header, all of the declarations attach to the nested block rather than to the containing block. That means thatval
,rw
,val
, andf
in the above are dropped once theif
statement is done, which tends to encourage a less idiomatic style where both cases are indented:If the primary concerns of this proposal are conciseness and scoping, I wonder if there's a smaller change in finding a way to place
f
in the containing block while keepingerr
limited to the nested block, while otherwise keeping this still as a familiarif
statement that doesn't make any assumptions about what the condition might be.A strawman follows. I don't particularly love this specific syntax but I'm including it to illustrate what I mean by making only a small adjustment to the existing construct:
Strawman specification: For declarations that appear in the header of an
if
,for
, orswitch
statement (and no others), the symbol^
before a symbol declares that it should be exported into the block that contains the statement.I don't really like the non-orthogonality of it being limited only to those three contexts, but I might justify it by noting that these are three situations where the declarations are sitting on a boundary between two scopes: visually, these declarations are neither inside the nested block nor directly in the parent block. In all other contexts a declaration is clearly either inside or outside of each block.
I realize this is essentially a competing proposal, but for the moment I'm just mentioning it to see what @markusheukelom thinks about whether this still achieves similar goals despite being a smaller language change.
(I also have a feeling that something like this was already proposed before, but I wasn't able to guess what to search for to find it, if so.)
3 remaining items
apparentlymart commentedon Feb 22, 2024
Hah... I guess it was your earlier proposal that I was remembering. 🤦♂️ Sorry for proposing your own idea back to you.
FWIW, I prefer the smaller change of just allowing some more flexibility in how these symbols are scoped when using the existing constructs over adding an entirely new construct, especially because the smaller change is also more general and doesn't make any assumptions about error handling in particular. But perhaps I'm in the minority on that viewpoint. 🤷🏻♂️
markusheukelom commentedon Feb 22, 2024
(Just for completeness, that was not my assumption; I did realise that it is mere a convention and not a language feature. Of course,
error
itself is special to the language.)@nirui
Yes, this is a drawback of this proposal, but note that in e.g. the range-iterator proposal, functions with special signature are also becoming part of the language specification. Is that significantly different?
Note that the syntax could be made non-
error
aware by requiring a condition:Would that make such a proposal more likely to be of interest? I decided to keep the proposal as simple as possible, but I do understand your point.
nirui commentedon Feb 23, 2024
Hi @markusheukelom, what if a function returned multiple errors?
In my program, I have a function which does
v, err1, err2 := call()
.err1
anderr2
leads to different handling pathways, similar to:Note: the pathway re-joins after the handling of
err1
, and if it's not the case, the code then can simply be written as:I can't write it in
v, err := function(...)
becauseerr1
anderr2
might return the same error value (io.EOF etc), and creating a type-identifier-wrapper error is too expensive for the use case.It could be helpful if your proposal can cover this use case too.
markusheukelom commentedon Feb 26, 2024
@nirui
I am afraid my proposal cannot (or even should) handle your case elegantly, as the proposal is meant for situations that have a single error variable. I am certainly not saying that your case isn't valid of course, it's just a use case that is a bit rare and for which the current way of returning multiple values can be used. I am not sure it's worthwhile to try an support these cases as for example Go also does not have a
do {} while
construct even though its easy to think of use-cases fordo-while
.There are other error-reporting constructs that cannot be handled by my proposal, for example see my note on custom error types (although an extension can be imagined). If custom error types are to be supported and you are able to change the function signature then of course you could do something like
This would work in the current proposal as well if the function returns
function() (Value, error)
but then you need a type assertion in your error handling block (which I assumed is too expensive as you mentioned).ianlancetaylor commentedon Mar 6, 2024
This is a lot like the recent #65579, with different syntax.
ianlancetaylor commentedon Mar 6, 2024
Based on the discussion, this is a likely decline. Leaving open for four weeks for final commens.
markusheukelom commentedon Mar 7, 2024
@ianlancetaylor
Yes there is certainly some resemblance. However, this proposal does not introduce a new operator or keyword and is more general in that is also handles map lookup, type assertion, channel receive failure. (There are other proposals using the
else
keyword, btw).Understand/agree it is a likely decline.
I hope that you've read my "Motivation" section. It outlines an issue with current error handling besides verbosity of syntax which I think is not so explicitly expressed elsewhere (i.e. having
err
, okok
in scope long after they are needed), at least to my knowledge. Maybe this can add to other ideas/proposals wrt error handling.ianlancetaylor commentedon Apr 3, 2024
No change in consensus.