Shorten your Go code by using exceptions

Go secretly favors exceptions. Use them.

published 2021-Nov-20, updated 2023-Oct-31

This post is informed by many years of Go, and months of Go with exceptions. I am well aware of many arguments for error values. Some of them are addressed below.

Reddit discussion: https://www.reddit.com/r/golang/comments/r2h31i/shorten_your_go_code_by_using_exceptions/

Update 2023-10-23. The original version of this post referred to https://github.com/mitranim/try. The updated post refers to https://github.com/mitranim/gg, which subsumes the previous library and offers more features.

Myths to debunk

"Go doesn't have exceptions".

Go has panics, which are exceptions.

"Errors-as-values is simpler than exceptions".

Decent argument that doesn't apply to Go. Go already has both. We don't get to choose to use just one.

"All errors are in function signatures".

The stdlib has many documented panics. New releases frequently add more. Panics are not in function signatures.

"Panics are reserved for unrecoverable errors".

Untrue in Go. Panics are recoverable and actionable. For example, HTTP servers respond with 500 and error details instead of crashing.

"Explicit errors lead to more reliable code."

Decent argument that doesn't apply to Go. Go has panics. Reliable code must handle panics in addition to error values. Code that assumes "no panics" or "panics always crash the process" will have leaks, data corruption, and other unexpected states.

"Panics are expensive".

Panics are cheap. Stack traces have a minor cost.

Observations

Combination of defer panic recover allows terse and flexible exception handling.

Brevity:

import "github.com/mitranim/gg"

func outer() {
  defer gg.Detail(`failed to do X`)
  someFunc()
  anotherFunc()
  moreFunc()
}

Same without panics:

func outer() (err error) {
  defer ErrWrapf(&err, `failed to do X`)

  err = someFunc()
  if err != nil {
    return
  }

  err = anotherFunc()
  if err != nil {
    return
  }

  err = moreFunc()
  if err != nil {
    return
  }

  return
}

// Suboptimal implementation, only for example purposes.
func ErrWrapf(out *error, pat string, msg ...any) {
  if out != nil && *out != nil {
    *out = fmt.Errorf(fmt.Sprintf(pat, msg...)+`: %w`, *out)
  }
}

Performance

In modern Go (1.17 and higher), there is barely any difference. Defer/panic/recover is usable even in CPU-heavy hotspot code.

Generating stack traces has a far larger cost. The examples in this post use github.com/mitranim/gg which automatically adds stack traces. If you're using stack traces with error values, that cost is already dominant, compared to the cost of defer/panic/recover.

Stack traces

Stack traces are essential to debugging, with or without exceptions.

Some real Go code, written by experienced developers, has errors annotated with function names, like this:

func someFunc() error {
  err := anotherFunc()
  if err != nil {
    return fmt.Errorf(`someFunc: anotherFunc: %w`, err)
  }

  err = moreFunc()
  if err != nil {
    return fmt.Errorf(`someFunc: moreFunc: %w`, err)
  }

  return nil
}

You can simplify this with defer, as shown above:

func someFunc() (err error) {
  defer ErrWrapf(&err, `someFunc`)

  err = anotherFunc()
  if err != nil {
    return
  }

  err = moreFunc()
  if err != nil {
    return
  }

  return
}

func anotherFunc() (err error) {
  defer ErrWrapf(&err, `anotherFunc`)
  return someErroringOperation()
}

func moreFunc() (err error) {
  defer ErrWrapf(&err, `moreFunc`)
  return anotherErroringOperation()
}

// Suboptimal implementation, only for example purposes.
func ErrWrapf(out *error, pat string, msg ...any) {
  if out != nil && *out != nil {
    *out = fmt.Errorf(fmt.Sprintf(pat, msg...)+`: %w`, *out)
  }
}

🔔 Alarm bells should be ringing in your head. This emulates a stack trace, doing manually what other languages have automated decades ago.

So stop doing that. Automate your stack traces, and shorten your code:

import "github.com/mitranim/gg"

func someFunc() {
  defer gg.Detail(`failed to do X`)
  anotherFunc()
  moreFunc()
}

func anotherFunc() {
  gg.Try(someErroringOperation())
}

func moreFunc() {
  gg.Try(anothrErroringOperation())
}