goatlang/goat

errors...

Opened this issue · 10 comments

Imagine the following syntax for errors

// this function will return an error or int32 (zig handles errors this way)
// I think zig uses a union?
func add(a, b int32) (int32, error) {
     return errors.New("This function failed")
}

func main() error {
    //
    result := add(0, 1) catch (err) {
        return err
    }

    log.Println("result:", result)
}

Idea:
If a function returns an error you must have to "catch" it. No more if err != nil

functions in C, C#, C++, Rust, Javascript, and Zig can only return 1 value. The problem was "how can you stick an error in there?" Go did the most obvious (and least creative thing) and just allows you to return multiple types. It's simple, and boring, just like go. I want to keep that spirit. It also forces "one way" of handling errors. Not sure of this syntax' implications but I like it.

It is not a "try catch" but uses the "catch" keyword which should be very familiar to developers.

Rust's approach is not pretty, but it works. It wraps errors and values in a Result type. I don't really like this as much as Zig's approach.

You could also ignore errors by the following

func add(a, b int32) (int32, error) {
     return errors.New("This function failed")
}

func main() error {
    //
    result := add(0, 1) catch () {}

    log.Println("result:", result)
}

This makes the code ugly if the developer does NOT handle the error (which would encourage error handling)... whereas currently golang discorages it thanks to if err != nil

I'd like us to tackle error handling, and I like the catch block idea, but I do like rusts ? operator a bit better:

func add(a, b int32) (int32, error) {
     return errors.New("This function failed")
}

func main() error {
    result := add(0, 1)?
    log.Println("result:", result)
}

I think the main reason is the fact that Zig's solution doesn't prevent the excessive return err all over the place, where the ? solution still allows you to handle everything, but also have a very clean code in case of default handling.
One more example is when you want to use the result directly in something else. for example:

x := []int32{
  1,
  2,
  add(1, 2)?,
}

or

result, err := add(3, add(1, 2)?)

WDYT?

func main() error {
    result := add(0, 1)?
    log.Println("result:", result)
}

From this code, what would be printed out if result was the error type?

I just don't fully understand it tbh haha

And in this example:

x := []int32{
  1,
  2,
  add(1, 2)?,
}

what would be stored at x[2] if add fails? Will the error be stored? Or maybe I misunderstand the function of ?

I really love the conversing and thought provoking ideas going on here

I think the main reason is the fact that Zig's solution doesn't prevent the excessive return err all over the place, where the ? solution still allows you to handle everything, but also have a very clean code in case of default handling.

Looking back at what you said, I thought about always having to return err and your right. So maybe by default catch just catches any errors and returns them:

func main() error {
    // if error caught then it gets returned immediately
    result := add(0, 1) catch

    log.Println("result:", result)
}

WDYT?

That's exactly the ? operator 👍 in case of an error return it immediately.

func main() error {
    result := add(0, 1)?
    log.Println("result:", result)
}

Is identical to

func main() error {
    result, err := add(0, 1)
    if err != nil {
        return err
    }
    log.Println("result:", result)
}

In the other example

x := []int32{
  1,
  2,
  add(1, 2)?,
}

I guess it transpiles down to this:

temp, err := add(1, 2)
if err != nil {
    return err
}
x := []int32{
  1,
  2,
  temp,
}

If you agree we can push it to the spec readme

How would this work if I change the signature of the add() function

func add(a, b int32) (int32) {
     return a+b
}

 result := add(0, 1) catch (err) {
        return err
}

If the catch part ignored, or does it give a compile error?

Compiler error for sure.
Again, this line result := add(0, 1) catch (or this one result := add(0, 1)?, depending on the syntax we choose) would actually transpile to if result, err := add(0, 1); err != nil { return err }. if add does not return an error, it wont compile.
More formally:
Inside a function func1, the expression var1, var2, ..., varN := func2(...)? is first validated so that:

  • func1 has error as its last return value
  • func2 must have N+1 return values, and the last one must be an error.
    Then it should be transpiled to:
if var1, var2, ..., varN, err := callExpression(...); err != nil {
  return zero1, zero2, ..., zeroM, err
}

where zero1-zeroM represent the zero values of the return types of func1.

Could the transpiler be "that intelligent" when there is no error in the signature, it will not generate that if statement?
(but perhaps only give a warning about unnecessary catch instructions present?)

For sure, it's for us to implement so we can definitely go either way.
However, i do think that compilation error is more strict, and aligns well with how go behaves in general (for instance, compilation error for unused variables, instead of a warning).

I think it's mostly important during refactors and changes. For example, if i have the following code:

func fetchAndPrint() error {
	value := fetchData()?
	fmt.Println(value)
}

func fetchData() (string, error) {
	// do some IO operation
	return "", nil
}

And I want to refactor so that fetchData does not return error anymore:

func fetchAndPrint() error {
	value := fetchData()?
	fmt.Println(value)
}

func fetchData() string {
	// non-IO operation
	return ""
}

now fetchAndPrint would still compile, only give a warning on redundant ? operator. I think it's important to give an error saying "hey, this thing does not return error anymore, please make sure to address it as well". Note that now fetchAndPrint can and should be further simplified to:

func fetchAndPrint() {
	fmt.Println(fetchData())
}

func fetchData() string {
	// non-IO operation
	return ""
}