Module 2 · Lesson 1 · ~25 min read

Errors Are Values

Go has no exceptions. Functions that can fail return an error as their last value, callers check it explicitly, and the rest of the language flows from that single decision.

The shape

f, err := os.Open("config.yaml")
if err != nil {
    return nil, err  // or fmt.Errorf("config: %w", err) — see wrapping below
}
defer f.Close()

Every line of that pattern matters. Multiple returns. err is the conventional name. Check immediately. Defer cleanup. You will write this exact shape thousands of times in real Go.

The error type is just a one-method interface:

type error interface {
    Error() string
}

Anything with that method satisfies it. No exception class hierarchies, no try/catch.

Why no exceptions?

Two reasons that hold up well in practice:

  1. Visible control flow. Every error path is right there in the code. You can't be surprised by a callee throwing across three layers. Reviewers can audit error handling line by line.
  2. Errors as data. Errors can be wrapped, inspected, compared, type-switched. They participate in the language like any other value, instead of being a parallel control system.

Trade-off: it's verbose. Real Go has a lot of if err != nil { return ... }. You learn to skim past it the same way Java reviewers skim past braces.

Constructing errors

import "errors"
import "fmt"

errors.New("connection refused")              // plain error with a message
fmt.Errorf("connect to %s: %v", host, err)   // formatted
fmt.Errorf("connect to %s: %w", host, err)   // formatted AND wrapped (note %w)

%w is the wrap verb. It packs the inner error inside the new one so callers can later unpack it with errors.Is / errors.As. Use it whenever you're adding context to an underlying error.

Use %v when you want the message but the inner error shouldn't be inspectable — e.g. to deliberately hide implementation details at an API boundary.

Sentinel errors

A sentinel is a package-level error variable that callers can compare against:

// In package storage
var ErrNotFound = errors.New("storage: contract not found")

func (s *Store) Get(id string) (*Contract, error) {
    // ...
    return nil, ErrNotFound
}

// In a caller
c, err := store.Get(id)
if errors.Is(err, storage.ErrNotFound) {
    // 404 path
}

errors.Is walks the wrap chain, so even if a downstream wrapper wrapped ErrNotFound with extra context using %w, you'll still find it.

Custom error types

When the caller needs more than just identity — e.g. a status code, a parameter, a stack of fields — define a struct that satisfies error:

type SubmissionError struct {
    CommandID string
    Status    int
    Cause     error
}

func (e *SubmissionError) Error() string {
    return fmt.Sprintf("submission %s failed (status %d): %v", e.CommandID, e.Status, e.Cause)
}

// Implement Unwrap to participate in errors.Is/As walking
func (e *SubmissionError) Unwrap() error { return e.Cause }

Callers extract via errors.As:

var serr *SubmissionError
if errors.As(err, &serr) {
    log.Printf("failed cmd %s with status %d", serr.CommandID, serr.Status)
}

errors.As walks the wrap chain looking for a value of the target type, and binds it.

Rule of thumb

errors.Is for "is this that specific error?" (sentinel comparison).
errors.As for "does the chain contain an error of that type?" (struct extraction).

Wrapping idiom

Each layer adds context. The error becomes a chain of "what we were trying to do."

func handler() error {
    if err := submitToCanton(); err != nil {
        return fmt.Errorf("handler: %w", err)
    }
    return nil
}

func submitToCanton() error {
    if err := connect(); err != nil {
        return fmt.Errorf("submit: %w", err)
    }
    // ...
}

// At the top, error.Error() reads:
// "handler: submit: connect: dial tcp 127.0.0.1:5011: connection refused"

You get a free traceback, structurally inspectable, and human-readable. No stack required.

Common antipatterns

Don't

Discard errors with _. If you genuinely don't care, write a comment explaining why — usually you do care. vet won't catch this; humans must.

Don't

Swallow then re-construct. If you do return errors.New(err.Error()) instead of wrapping, you've turned a typed, inspectable error into a string. Every caller now has to parse strings to know what happened.

Don't

Log AND return. Logging at every layer of the call stack produces five copies of the same error in your logs. Log at the topmost handler that actually decides what to do; return at every layer below.

Panics — what they're for

Go does have panic/recover. They are not exceptions. Panics signal "the program is in an impossible state — abort." Reach for panic in two cases:

Almost everything else is an error return. recover is mostly used at goroutine boundaries (net/http recovers per request, gRPC servers similarly) so a single bad request doesn't take down the process.

What this looks like in Canton-adjacent code

func (c *Client) SubmitAndWait(ctx context.Context, cmd Command) (*Completion, error) {
    payload, err := c.marshal(cmd)
    if err != nil {
        return nil, fmt.Errorf("submit %s: marshal: %w", cmd.ID, err)
    }
    resp, err := c.grpcCall(ctx, payload)
    if err != nil {
        return nil, fmt.Errorf("submit %s: rpc: %w", cmd.ID, err)
    }
    return resp, nil
}

Layered context, sentinel-friendly via %w. The caller knows the command ID, knows whether it was a marshal error or an RPC error, and can still errors.Is against gRPC-specific sentinels deeper in the chain.

Takeaways