Skip to content

proposal: Go 2: Error-Handling Paradigm #60720

Closed as not planned
Closed as not planned
@nixpare

Description

@nixpare

Author background

  • Would you consider yourself a novice, intermediate, or experienced Go programmer?
    I'm a student, but i'm mainly using go from the first day I started programming and now I'm using
    it for 3 years. I coded a bunch of libraries, some targeting specific needs for different
    OSes, the largest one is a TCP/HTTPs server library (Server v2).
  • What other languages do you have experience with?
    I use regularly Javascript, Java, SQL databases (HTML and CSS if you consider them programming languages).

Related proposals

  • Has this idea, or one like it, been proposed before?
    Yes, there have been a lot of proposals related to this topic.
    • If so, how does this proposal differ?
      First of all, this proposal matches the official guidelines related to this topic.
      Then it will be completely backwards compatible and will not introduce any weird
      constructs that will preserve the readability and simplicity of the Go code.
  • Does this affect error handling?
    Yes, the topic is the error handling.
    • If so, how does this differ from previous error handling proposals?
      This does not to implement anything like a try-catch, considering that this is already
      too similar to the panic-recover already present.

      This proposal has more of a macro-like behaviour, reducing the possibility of overworking
      on a new mechanic, with the risk of diverging from the Go principle of "error checking".

Error Handling Proposal

This proposal addresses the problem that affects every project written in Go
that has to deal with a lot of error handling, resulting in a very verbose
code, without removing the principle that you should always handle every
error returned by a function in the best way.

This would benefit every go programmer, considering it's about error handling, and
is a widely discussed topic among every programmer, even non-go ones.

Changes

The proposal, by adding only a new keyword to the language, the catch one, will add
to the language 2 new strictly related construct (more like 1 construct and 1 prefix):

  • the definition of an error-handler function that is relevant only for
    the function it's declared in; this construct will be presented below
  • the explicit statement that you want to trigger the previously-declared function above;
    this prefix will be presented below

The backwards compatibility will be discussed below along with code snippets showing the changes.

We will discuss also interaction between other language features, like goroutine functions ran with
the go keyword (this proposal has a similar concept, that is adding a prefix to a function call to
explicitly say that you want to tread that function differently from normal).

This proposal is not about performance improvements, but (with my knowledge) should not even affect it
in a bad way.

Introduction

Here is a good example of a code that would benefit from the introduction
of this concept:

package myPackage

import (
	"encoding/json"
	"fmt"
	"io"
	"os"
	"time"
)

type MyStruct struct {
	Message string    `json:"message"`
	Time    time.Time `json:"time"`
}

func LoadJSONFile(filePath string) (*MyStruct, error) {
	f, err := os.Open(filePath)
	if err != nil {
		return nil, fmt.Errorf("could not load json file <%s>: %w", filePath, err)
	}
        defer f.Close()

	data, err := io.ReadAll(f)
	if err != nil {
		return nil, fmt.Errorf("could not load json file <%s>: %w", filePath, err)
	}

	var result *MyStruct
	err = json.Unmarshal(data, result)
	if err != nil {
		return nil, fmt.Errorf("could not load json file <%s>: %w", filePath, err)
	}

	return result, nil
}

It's obvious that there is a lot of code that is redundant and verbose: this could be
fixed if we could tell the compiler what to do every time a function returns as the
last return-value an error and it is not nil, like a macro. Here is an example:

func LoadJSONFile2(filePath string) (*MyStruct, error) {
	// declaration of the `catch function`
	catch (err error) {
		// the return statement (if present) must match the calling function signature
		return nil, fmt.Errorf("could not load json file <%s>: %w", filePath, err)
	}

	f := catch os.Open(filePath)
        defer f.Close()

	data := catch io.ReadAll(f)

	var result *MyStruct
	catch json.Unmarshal(data, result)

	return result, nil
}

Note: in this proposal there are two different usage of the catch
keyword:

  • one is the declaration of the macro function, I will refer to
    it as catch function
  • the other one is the request to handle an error with the catch function,
    I will refer to it as catch prefix in the context of a function call

First Usage

The first usage of the catch keyword is to tell the compiler that from
this point until the end of the function, without inheritance
from other function calls inside, if you call other functions
with the catch prefix, it should implicitly call the catch function
only if err != nil.

To set the catch function you must use this syntax only:

catch (<variable_name> error) { ... }

without using func() { ... } or any pre-declared function (like with defer or go)
to underline that this catch function should be explicitly made
for this function errors and should not be handled by a function
that is used for every error in the program/package.
However once inside the catch function body, it's always possible
to call other functions as a normal function.

The catch function must not have a return signature (like a 'void'
function) because in reality it's implicitly matching the one
of the calling function (in this case (*MyStruct, error)).
This is also why the catch function must be declared inline.

Second Usage

The second use case of the catch keyword is by placing it
in front of a function call that has as the last return type an error.
By doing this you can avoid taking this last error and
tell the compiler to handle it automatically with the catch
function if the error is not nil (removing the need constantly use of the
construct if err != nil { ... }).

Function with named parameters in the return statement

The catch argument can have whatever name you desire, but it
must be an error. In this case if you named it err, the
one declared in the function signature would be shadowed.

func LoadJSONFile3(filePath string) (result *MyStruct, err error) {
	catch (catchErr error) {
		// This is what the catch function can be if the return
		// signature has variable names
		err = fmt.Errorf("could not load json file: %w", catchErr)
		return

		// Or directly
		return nil, fmt.Errorf("could not load json file: %w", err)
		// It has all the possible return statements that you
		// would have in a normal function body
	}

	// Same stuff ...

	return
}

Illegal Usage and Skip error check

func LoadJSONFile4(filePath string) (*MyStruct, error) {
	// This is illegal, because the `catch function` is not
	// declared at this point (like accessing a non-already
	// declared variable)
	fileStats := catch os.Stat()   // ILLEGAL

	catch (err error) {
		return nil, fmt.Errorf("could not load json file: %w", err)
	}

	// this is obviously illegal because i have not used the catch
	// prefix on the function call, preserving backwards compatibility
	f := os.Open(filePath)   // ILLEGAL
        defer f.Close()

	// This is how we already explicitly ignore returned errors and
	// this will be the only way to really ignore them
	data, _ := io.ReadAll(f)

	// also this is how we already ignore returned errors if the
	// function only returns one error. Not the absence of the
	// `catch` keyword before the function
	json.Unmarshal(data, result)
	return
}

Manually trigger the catch function

From now we are going to change the context a little bit
for the next sections

We will be using the same MyStruct with a message and a timestamp
and two functions, one private and one public with different
arguments but the same return signature, both used to create a new
instance of the struct.

This is widely used in packages that has a private method that exposes
more options and a public one that has less arguments and maybe also
does some sanity check first

The private method just checks if the message is not empty and asks
for an explicit timestamp that will be directly injected in the returned
struct.
The public method instead only asks for a message, the time will be
time.Now(), and also checks that the message is not on multiple lines.

var (
	MultilineError = errors.New("message is multiline")
)

func NewMyStruct(message string) (*MyStruct, error) {
	catch (err error) {
		return nil, fmt.Errorf("could not create MyStruct: %w", err)
	}

	if strings.Contains(message, "\n") {
		catch errors.New("message is multiline")
		// or
		catch MultilineError
		// or (similar)
		catch fmt.Errorf("message of length %d is multiline", len(message))
	}

	//... will be continued in the next code snippet
}

The catch function can be manually triggered by using the
catch keyword followed by an error value, this being the only
returned value of a function or a pre-existing variable.

In reality this is no so much different from what we have already
discussed above, but has a slightly different purpose, so here it is.
In fact the only thing that is doing is calling a function that returns only
an error (like the json.Unmarshal(...) before), but we surely know that the
returned error will not be nil.

Discussion

Functions returning other functions

Now let's see when a function returns in the end another function
with the same return signature. Here is the original code snippet:

func newMyStruct(message string, t time.Time) (*MyStruct, error) {
	if message == "" {
		return nil, errors.New("message can't be empty")
	}

	return &MyStruct{ Message: message, Time: t }, nil
}

func NewMyStruct(message string) (*MyStruct, error) {
	// previous stuff ...

	result, err := newMyStruct(message, time.Now())
	if err != nil {
		return nil, fmt.Errorf("could not create MyStruct: %w", err)
	}
	return result, err
}

In this case this is what the last return statement
will be equal to with the catch:

func newMyStruct(message string, t time.Time) (*MyStruct, error) {
	// Same as above ...
}

func NewMyStruct(message string) (*MyStruct, error) {
	catch (err error) {
		return nil, fmt.Errorf("could not create MyStruct: %w", err)
	}

	if strings.Contains(message, "\n") {
		catch errors.New("message is multiline")
	}

	return catch newMyStruct(message, time.Now())
}

And if you think that you should not modify the returned
values, you can always directly use

return newMyStruct(message, time.Now())

without the catch prefix: the compiler will know that you do not want
to execute the catch function on the error.

Inheritance

As previously mentioned, this is not like a defer-recover construct:
any error, even in the function called, is not handled by the most
recent version of the catch version, but only by the one inside the same
function body
. In this way it be easier for an LSP (go vet, staticcheck
or others similar) to detect an improper use of the catch function.

One thing to be pointed out is that the catch function should not
be treated as a function variable, because there could be problems with
inline-declared function, especially used with goroutines:

A function like that does not have to match the "parent" function return
statement
in which is declared, and normally doesn't (goroutines
function usually does not return anything): this breaks the principle behind the catch macro style and also goes against the reason why the
catch function must be declared for each distinct function,
that is to discourage the usage of a universal function handler for every
function in the program/package.
So:

func Foo() error {
	catch (err error) {
		return nil, fmt.Errorf("could not load json file: %w", err)
	}

	// Some stuff with error handling with the catch ...

	wg := new(sync.WaitGroup)
	wg.Add(2)

	go func() {
		defer wg.Done()
		value1, value2 := catch SomeFunctionThatReturnsAnError() // This is invalid

		// Use of the values ...
	}()

	f2 := func() {
		defer wg.Done()

		catch (err error) {
			log.Printf("error occurred in the second goroutine: %v\n", err)
			return
		}

		value1, value2 := catch SomeFunctionThatReturnsAnError() // This is completely valid

		// Use of the values ...
	}
	go f2()

	wg.Wait()
	return nil
}

The return statement inside the catch function

It was not previously mentioned, but the return statement
inside the catch function is not mandatory, in fact if you
would not use the return statement, the catch function will
be executed normally, doing everything it contains, and then
continuing the function execution.

Again, one thing is what the language lets you do, one thing
is what is actually correct to do in a program.
An example that demonstrates that this kind of logic is already
possible is the following:

func LoadJSONFileWithLogging(filePath string) *MyStruct {
	f, err := os.Open(filePath)
	if err != nil {
		log.Printf("could not load json file: %v", err)
	}
        defer f.Close()

	data, err := io.ReadAll(f)
	if err != nil {
		log.Printf("could not load json file: %v", err)
	}

	var result *MyStruct
	err = json.Unmarshal(data, result)
	if err != nil {
		log.Printf("could not load json file: %v", err)
	}

	return result
}
func LoadJSONFileWithCatchLogging(filePath string) *MyStruct {
	catch (err error) {
		log.Printf("could not load json file: %v", err)
	}

	f := catch os.Open(filePath)
        defer f.Close()
	
	data := catch io.ReadAll(f)

	var result *MyStruct
	catch json.Unmarshal(data, result)

	return result
}

Note: both functions do not return any error

Change of the catch function

One last thing that can be discussed is the possibility to change the
catch function behaviour inside the same function: honestly, I thing this
would be kind of overkill, if you have the need to constantly changing the
catch function, you would probably be better controlling each error
like we are used to. But an example would be like this:

func LoadJSONFile5(filePath string) (*MyStruct, error) {
	catch (err error) {
		return nil, fmt.Errorf("could not load json file: %w", err)
	}

	f := catch os.Open(filePath)
        defer f.Close()

	// Other stuff with more error handling with the first catch function ...

	catch (err error) {
		return nil, fmt.Errorf("another error while loading json file: %w", err)
	}
	// From now, every catch prefix will use this second catch function

	data := catch io.ReadAll(f)

	var result *MyStruct
	catch json.Unmarshal(data, result)

	return result, nil
}

How the compiler should behave (conceptually)

At compile time, considering the catch function is valid, the compiler should
literally insert that function's code in the right places as can be evinced from
the differences between the old and the new code.

Conclusions

I really think that this would make people save a lot of lines of code,
while not compromising readability of the code and backwards
compatibility. I think this could be even implemented, without considering
development time, in a go v1.21 or go v1.22.

This change will not make Go easier to learn, but not even harder. This should
make writing Go code, both for production and for personal use, more pleasing
and less tedious.

As stated before, this should not have any performance impact on programs, thanks
to the nature of this feature, that is being a like a macro. This obviously will
put more strain on the compiler and any LSP, but I don't think this will be huge.

This is my first proposal I do in my life so sorry if I missed some
aspects and I remain available for discussions.

Hope you like this idea. Thanks for the time dedicated to this proposal

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions