Errors as Values
Errors as Values
In Go an error is a value: a value that implements a one-method interface. Functions return it. Callers check it. Nothing unwinds the stack in secret. The boring default is if err != nil { return fmt.Errorf("...", err) } at every call that can fail.
Mental model
type error interface {
Error() string
}
nil means success. A non-nil error means failure. The string from Error() is for humans and logs. Programs that need to branch on a failure use a sentinel (var ErrClosed = errors.New(...)) or a dedicated type (TableError), not string matching.
Check every error. Discarding one with _ is how a closed desk looks open until Friday.
Worked examples
Case 1: Return error, check it
Opening a table fails when the number is not positive. main prints the error and exits 1. The success path is the path with no err.
Save as open_table.go:
// open_table.go
package main
import (
"fmt"
"os"
)
func openTable(n int) error {
if n <= 0 {
return fmt.Errorf("table %d: number must be positive", n)
}
fmt.Printf("opened table %d\n", n)
return nil
}
func main() {
if err := openTable(3); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if err := openTable(0); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}Run:
go run open_table.goOutput (stdout, then stderr, then exit 1):
opened table 3
table 0: number must be positive
fmt.Errorf builds an error from a format string. There is no exception type to catch. The if err != nil line is the entire control-flow story.
Case 2: A sentinel for a known condition
The desk closes at the end of the shift. Callers that care about that failure compare with == (or errors.Is, next chapter). The sentinel is a package-level var.
Save as sentinel.go:
// sentinel.go
package main
import (
"errors"
"fmt"
)
var ErrClosed = errors.New("desk closed")
func ring(open bool, table int) error {
if !open {
return ErrClosed
}
if table <= 0 {
return fmt.Errorf("table %d: number must be positive", table)
}
fmt.Printf("rang table %d\n", table)
return nil
}
func main() {
err := ring(false, 7)
if err == ErrClosed {
fmt.Println("go home:", err)
return
}
if err != nil {
fmt.Println("other:", err)
}
}Run:
go run sentinel.goOutput:
go home: desk closed
Use a sentinel when the condition is one value the whole package shares: closed, not found, already paid. Do not invent a sentinel per table number.
Case 3: A dedicated type when the error carries data
A bad table number should include the number so the caller can log it, skip it, or show it on a screen. That is a struct, not a sentinel.
Save as table_error.go:
// table_error.go
package main
import "fmt"
type TableError struct {
Table int
Msg string
}
func (e TableError) Error() string {
return fmt.Sprintf("table %d: %s", e.Table, e.Msg)
}
func openTable(n int) error {
if n <= 0 {
return TableError{Table: n, Msg: "number must be positive"}
}
fmt.Printf("opened table %d\n", n)
return nil
}
func main() {
err := openTable(-2)
te, ok := err.(TableError)
if ok {
fmt.Printf("bad table=%d (%s)\n", te.Table, te.Msg)
return
}
fmt.Println(err)
}Run:
go run table_error.goOutput:
bad table=-2 (number must be positive)
The type assertion err.(TableError) is acceptable in main of a tiny program. At work, prefer errors.As (next chapter) so wrapping still works. The point here: data lives on the type, not in a parsed string.
Case 4: Check every error, including the “cannot fail” ones
Writing a note to stdout can fail (pipe closed). The boring program checks it.
Save as note.go:
// note.go
package main
import (
"fmt"
"os"
)
func main() {
_, err := fmt.Println("desk is open")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}Run:
go run note.goOutput:
desk is open
In a CLI, checking fmt.Println is optional taste. In a server, checking Write, Close, and Encode is not optional. The rule is the same: if the signature returns error, you have a decision to make. The decision is almost never _.
The trap
Assigning the error to _ because the happy path compiled.
Save as ignored.go:
// ignored.go
package main
import "fmt"
func openTable(n int) error {
if n <= 0 {
return fmt.Errorf("table %d: number must be positive", n)
}
fmt.Printf("opened table %d\n", n)
return nil
}
func main() {
_ = openTable(0)
fmt.Println("still going")
}Run:
go run ignored.goOutput:
still going
Table 0 never opened. The program claims progress. That is the bug. Delete the _. Handle the error or return it.
The boring rule
- Return
erroras the last result. Returnnilon success. - Check
errat the call site. Do not panic for the caller’s bad input. errors.New("...")orfmt.Errorf("...")for simple failures.- A package-level sentinel for one shared condition.
- A dedicated type when the caller needs fields.
- Never match
err.Error()strings in production code. Strings are for people.
Try this
- In
open_table.go, open tables3,0, and11in a loop. Print each error and keep going instead ofos.Exit. - Add a second sentinel
ErrPaidinsentinel.go. Return it whentable == 7. Branch on both sentinels inmain. - Change
TableErrorto a pointer receiver onError()and return&TableError{...}. Keep the assertion in sync (*TableError).