Errors are values, not control flow
error is a one-method interface:
type error interface{ Error() string }
That is the entire mechanism. There are no exceptions, no throws, no implicit unwinding, no finally. A function
that can fail returns (T, error) and the caller decides.
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found") // sentinel error: a package-level value
func Find(id string) (string, error) {
if id == "" {
return "", fmt.Errorf("find %q: %w", id, ErrNotFound) // %w = wrap, keeps the cause
}
return "vlad", nil
}
func main() {
if _, err := Find(""); err != nil {
fmt.Println("err:", err) // find "": not found
fmt.Println("cause:", errors.Is(err, ErrNotFound)) // true — the wrap is inspected, not string-matched
}
name, err := Find("42")
fmt.Println(name, err == nil)
}
class NotFound extends RuntimeException { }
record User(String name) { }
User find(String id) {
if (id.isEmpty()) throw new NotFound(); // control flow is a jump
return new User("vlad");
}
try {
find("");
} catch (NotFound e) {
// unwound to here; anything not caught crashes the thread
}
class NotFound extends Error { }
function find(id: string): string {
if (!id) throw new NotFound(); // same unwinding model as Java
return "vlad";
}
try { find(""); } catch (e) { if (e instanceof NotFound) { } }
The idiomatic shape
v, err := doThing()
if err != nil {
return fmt.Errorf("context: %w", err)
}
Three rules that carry 90% of code review weight:
- Handle it or return it. No empty
if err != nil {}. If you cannot act, wrap with context and return. - Wrap with
%w, annotate with%v.%wpreserves the chain forerrors.Is/As;%vflattens it. Wrapping is not the same as logging — never do both. - Errors go last.
(T, error)always,errornever first, never mixed into a struct you return by value.
Sentinels, types, and Is/As
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
type ValidationError struct{ Field string }
func (e *ValidationError) Error() string { return "invalid field: " + e.Field }
func save(field string) error {
if field == "" {
return fmt.Errorf("save: %w", &ValidationError{Field: "email"})
}
return fmt.Errorf("save: %w", ErrNotFound)
}
func main() {
err := save("")
var ve *ValidationError
if errors.As(err, &ve) { // walks the chain, matches by TYPE
fmt.Println("bad input on", ve.Field)
}
if errors.Is(err, ErrNotFound) { // matches by VALUE
fmt.Println("not found")
}
}
class ValidationException extends RuntimeException {
final String field;
ValidationException(String f) { super("invalid " + f); this.field = f; }
}
try { save(""); }
catch (ValidationException e) { e.field } // exact type, or instanceof for hierarchies
class ValidationError extends Error { constructor(public field: string) { super("invalid"); } }
// `instanceof` only; there is no cause-chain walking unless you build it
Java has class hierarchies for errors; Go has values plus wrapping. errors.Is is "is this exact sentinel
anywhere in the chain", errors.As is "is there a value of this type anywhere in the chain". Both beat string
matching, which is the anti-pattern you will see in junior Go code (strings.Contains(err.Error(), "not found")).
defer is try-with-resources, and better
package main
import "fmt"
func main() {
defer fmt.Println("3: runs last, on ANY return path")
for i := 0; i < 3; i++ {
defer fmt.Println("defer", i) // args evaluated NOW, body runs LIFO
}
fmt.Println("1: body first")
}
try (var conn = pool.getConnection()) { // one resource, closed at scope exit
work(conn);
} catch (SQLException e) { }
// multiple resources: declare them in the try(...) list, closed in reverse order
try { work(); } finally { cleanup(); }
defer runs on panic too — that is the entire "finally" story, and it is per-call, not per-scope.
Trap:
deferinside a loop does not run per iteration; it stacks until the function returns. For file handles in a loop, extract a function (ordefera closure that captures the current variable explicitly).
Panic is not an exception. Use it for programmer errors (nil map write you caused, invariant violated at startup),
never for input validation. At a request boundary, one recover in middleware turns a panic into a 500 and keeps the
process alive. Everywhere else, returning an error is the language telling you the failure is part of the contract.
Proof checkpoint
- Wrap the same sentinel twice (
fmt.Errorf("a: %w", fmt.Errorf("b: %w", ErrX))) and proveerrors.Isstill finds it. - Write a custom error type, then extract it from two levels of wrapping with
errors.As. - Rewrite a Java-style "throw on every validation failure" function as Go, returning the first error with field context.