Skip to main content
Go for JVM Engineers

Errors as Values

Ravinder··5 min read
GoJVMJavaErrorHandlingErrors
Share:
Errors as Values

The most common complaint from JVM engineers picking up Go is the repetitive if err != nil blocks. That reaction is understandable but misses what the design achieves: errors are ordinary values that travel through the call stack as explicitly as any other return value. There is no hidden control flow, no checked-exception list that grows with every signature change, and no runtime surprise when an unchecked exception escapes a thread pool.

Once you accept the model, the tooling around it — wrapping, unwrapping, errors.Is, and errors.As — turns out to be more composable than Java's exception hierarchy.

The Basic Contract

A function that can fail returns (result, error) as its last two values. The caller decides what to do with the error immediately. There is no throws clause and no implicit propagation.

// Java — exception, implicit propagation
public User findUser(String id) throws UserNotFoundException {
    // caller may or may not handle this
}
// Go — error is an explicit return value
func findUser(id string) (User, error) {
    u, ok := store[id]
    if !ok {
        return User{}, fmt.Errorf("user %q not found", id)
    }
    return u, nil
}
 
u, err := findUser("42")
if err != nil {
    log.Printf("lookup failed: %v", err)
    return
}
// u is safe to use here

The error type is a built-in interface with a single method: Error() string. Any type that implements that method is an error.

Sentinel Errors and errors.Is

A sentinel error is a package-level variable used as a well-known error value — the Go equivalent of a specific exception class.

// sql package
var ErrNoRows = errors.New("sql: no rows in result set")
 
// caller
row, err := db.QueryRowContext(ctx, query, id)
if errors.Is(err, sql.ErrNoRows) {
    return nil, ErrNotFound
}

errors.Is walks the error chain. Even if the error was wrapped three layers deep, Is finds it. This is why you should use errors.Is rather than == for sentinel comparison.

// Java equivalent pattern
try {
    return repo.findById(id);
} catch (EmptyResultDataAccessException e) {
    throw new NotFoundException(id);
}

Wrapping Errors for Context

fmt.Errorf with the %w verb wraps an error, preserving the original for later unwrapping.

func loadConfig(path string) (Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return Config{}, fmt.Errorf("loadConfig: reading %s: %w", path, err)
    }
    // ...
}

The resulting error message reads as a chain: loadConfig: reading /etc/app.yaml: open /etc/app.yaml: no such file or directory. This is the Go equivalent of a chained exception message, but it costs nothing unless you print it.

flowchart TB A["os.ReadFile error\n(syscall.ENOENT)"] -->|wrapped by %w| B["loadConfig error\n'reading /etc/app.yaml: ...'"] B -->|wrapped by %w| C["startup error\n'loadConfig: ...'"] C --> D{{"errors.Is(err, fs.ErrNotExist)\n→ true, unwraps chain"}} C --> E{{"errors.As(err, &pathErr)\n→ true, extracts *fs.PathError"}}

errors.As — Extracting Typed Error Information

errors.As is the replacement for instanceof casting in Java catch blocks. It walks the chain and, if a matching type is found, populates the target pointer.

// Java
try {
    parseConfig(input);
} catch (ParseException e) {
    System.err.println("at offset " + e.getErrorOffset());
}
// Go
type ParseError struct {
    Offset int
    Msg    string
}
 
func (e *ParseError) Error() string {
    return fmt.Sprintf("parse error at offset %d: %s", e.Offset, e.Msg)
}
 
_, err := parseConfig(input)
var pe *ParseError
if errors.As(err, &pe) {
    fmt.Printf("at offset %d\n", pe.Offset)
}

Custom Error Types

For errors that carry structured data beyond a string, define a struct that implements the error interface. Add an Unwrap() error method to participate in chain traversal.

type NotFoundError struct {
    Resource string
    ID       string
    Cause    error
}
 
func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s with id %q not found", e.Resource, e.ID)
}
 
func (e *NotFoundError) Unwrap() error { return e.Cause }

Callers can then match with errors.As(err, &NotFoundError{}) regardless of how many wrapping layers sit above it.

Panic Is Not for Ordinary Errors

Go has panic and recover, but they are reserved for programmer errors — index out of bounds, nil dereference, type assertion failures — not for business logic failures. Using panic for flow control is the Go equivalent of throwing RuntimeException for a "user not found" case. It is not idiomatic and it breaks composability.

The rule of thumb: if a caller can reasonably be expected to handle the failure, return an error. If the program has reached an impossible state, panic.

// Correct: return error for recoverable failure
func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}
 
// Panic only for precondition violations in constructors/init
func mustPositive(n int) int {
    if n <= 0 {
        panic(fmt.Sprintf("mustPositive: got %d", n))
    }
    return n
}

Handling Multiple Errors in a Pipeline

A common pattern is accumulating errors across a set of operations, similar to a validation step that collects all field errors rather than stopping at the first.

import "errors"
 
func validateUser(u User) error {
    var errs []error
    if u.Name == "" {
        errs = append(errs, errors.New("name is required"))
    }
    if u.Age < 0 {
        errs = append(errs, errors.New("age must be non-negative"))
    }
    return errors.Join(errs...) // Go 1.20+
}

errors.Join returns nil if the slice is empty, and a multi-error otherwise. errors.Is and errors.As both traverse multi-errors.

Key Takeaways

  • Errors are values; treat them as first-class return values and handle them at the call site rather than propagating exceptions implicitly.
  • Use errors.Is — not == — for sentinel comparison so wrapped errors are still matched correctly.
  • Wrap with fmt.Errorf("context: %w", err) to add caller context without losing the original error for inspection.
  • Use errors.As to extract typed error data, replacing Java's catch (SpecificException e) pattern.
  • Reserve panic for programmer errors and impossible states, not for domain failures a caller can recover from.
  • errors.Join (Go 1.20+) handles multi-error accumulation cleanly, useful for validators and batch operations.
Share: