gopherpath
0%

Errors are values, not control flow

45 min · lesson 1 of 2 · #errors

error is a one-method interface:

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

Go
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)
}
Java
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
}
TypeScript
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

Go
v, err := doThing()
if err != nil {
	return fmt.Errorf("context: %w", err)
}

Three rules that carry 90% of code review weight:

  1. Handle it or return it. No empty if err != nil {}. If you cannot act, wrap with context and return.
  2. Wrap with %w, annotate with %v. %w preserves the chain for errors.Is/As; %v flattens it. Wrapping is not the same as logging — never do both.
  3. Errors go last. (T, error) always, error never first, never mixed into a struct you return by value.

Sentinels, types, and Is/As

Go
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")
	}
}
Java
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
TypeScript
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

Go
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")
}
Java
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
TypeScript
try { work(); } finally { cleanup(); }

defer runs on panic too — that is the entire "finally" story, and it is per-call, not per-scope.

Trap: defer inside a loop does not run per iteration; it stacks until the function returns. For file handles in a loop, extract a function (or defer a 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 prove errors.Is still 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.
stored in this browser only