Errors as Values

Updated

September 13, 2026

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.go

Output (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.go

Output:

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.go

Output:

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.go

Output:

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.go

Output:

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 error as the last result. Return nil on success.
  • Check err at the call site. Do not panic for the caller’s bad input.
  • errors.New("...") or fmt.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

  1. In open_table.go, open tables 3, 0, and 11 in a loop. Print each error and keep going instead of os.Exit.
  2. Add a second sentinel ErrPaid in sentinel.go. Return it when table == 7. Branch on both sentinels in main.
  3. Change TableError to a pointer receiver on Error() and return &TableError{...}. Keep the assertion in sync (*TableError).