Quick Review: Types, Interfaces, and Errors That Survive Production
Learning Objectives
- Choose value or pointer semantics deliberately, and explain why a copy can break a lock.
- Design small interfaces that live next to the code that consumes them.
- Wrap errors with
%wand inspect them witherrors.Isanderrors.As. - Spot the typed-nil interface trap before it reaches production.
Quick Review
You already know that Go passes everything by value, that interfaces are satisfied implicitly, and that error is just an interface with one method, Error() string. This lesson tightens three spots where intermediate Go developers lose the most time in a real service: accidental copies, interface bloat, and errors that lose their identity as they travel up the call stack. Nothing here is new syntax. It is recall plus the pitfalls that matter once concurrent request handling is on the table.
Theory
Value and pointer semantics
A Go value is copied on assignment, when passed as an argument, when returned, and when a value-receiver method is called. That is cheap for small structs and expensive or outright broken for others.
Use a pointer receiver or pointer parameter when you need to mutate, when the struct is large, when you want a nil sentinel, or when the struct contains a lock, a sync.WaitGroup, or a bytes.Buffer. Copying a struct that contains a sync.Mutex produces two independent mutexes guarding one shared value, which is a data race waiting for a load spike. go vet catches some of these, not all.
type Store struct {
mu sync.RWMutex
data map[string]string
}
func (s *Store) Get(k string) (string, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
v, ok := s.data[k]
return v, ok
}
// func (s Store) Bad() {} // go vet: Bad passes lock by value
Maps, slices, and channels are not exceptions. They are small headers, so copying them is cheap, but the copy still points at the same backing array. Passing a slice by value does not protect it.
Keep receivers consistent: if one method on a type uses a pointer receiver, use a pointer receiver on all of them. The rule of thumb is that the method set of *T includes the {{c4::pointer}} receiver methods, while the method set of T does not, so T may silently stop satisfying an interface that *T satisfies.
Interfaces: small, consumer-owned
Interfaces describe behavior the caller needs, so they belong in the package that consumes them, not the package that implements them. A one-method interface is rarely wrong. A ten-method interface usually means you wanted a concrete type.
Functions should accept {{c5::interfaces}} and return structs. Return concrete types so callers keep access to all the fields and methods, and accept interfaces so callers can pass a fake in tests.
// in package cache, the consumer
type Fetcher interface {
Fetch(ctx context.Context, key string) ([]byte, error)
}
[!WARNING] A
nilpointer stored in an interface does not make the interfacenil. An interface value is a(type, value)pair, and the type is set even when the pointer is nil. A function that returnserrorand ends withreturn vewhereveis a nil*ValidationErrorreturns a non-nil error.
type ValidationError struct{ Field string }
func (e *ValidationError) Error() string { return "invalid " + e.Field }
func validate() error {
var ve *ValidationError // nil
return ve // err == nil is false here
}
Return a literal nil when nothing went wrong.
Errors that keep their identity
Use a sentinel when callers need to branch on a specific condition, and a typed error when they need structured detail. Wrap with %w whenever you add context and want the original error reachable.
Wrapping an error with fmt.Errorf requires the {{c1::%w}} verb. %v produces a readable message and cuts the chain.
To test whether any error in the chain is a specific sentinel value, call {{c2::errors.Is}}. To extract a concrete error type from the chain, call {{c3::errors.As}}.
| Need | Tool |
|---|---|
| Add context, keep the chain | fmt.Errorf("loading user %d: %w", id, err) |
| Match a sentinel anywhere in the chain | errors.Is(err, ErrNotFound) |
| Pull out a typed error | errors.As(err, &queryErr) |
| Inspect one level manually | errors.Unwrap(err) |
| Combine several errors | errors.Join(a, b) (Go 1.20+) |
Worked Examples
Example 1: a wrapped chain you can inspect
var ErrNotFound = errors.New("not found")
type QueryError struct {
Query string
Err error
}
func (e *QueryError) Error() string { return "query failed: " + e.Err.Error() }
func (e *QueryError) Unwrap() error { return e.Err }
func loadUser(id int) error {
return fmt.Errorf("loadUser(%d): %w",
id, &QueryError{Query: "select ... where id = $1", Err: ErrNotFound})
}
func main() {
err := loadUser(7)
fmt.Println(err) // loadUser(7): query failed: not found
fmt.Println(errors.Is(err, ErrNotFound)) // true
var qe *QueryError
if errors.As(err, &qe) {
fmt.Println("failed query:", qe.Query)
}
}
Trace it: fmt.Errorf builds the outer wrap, QueryError implements Unwrap, and errors.Is walks outer -> QueryError -> ErrNotFound. Note that errors.As takes a pointer to the target, and the target is *QueryError, so you pass &qe.
Example 2: the typed-nil trap in a handler
func findUser(id int) (*User, error) {
u, err := db.Lookup(id)
if err != nil {
return nil, &NotFoundError{ID: id}
}
if u == nil {
var nf *NotFoundError
return nil, nf // BUG: non-nil error interface
}
return u, nil
}
The fix is return nil, nil or an explicit non-nil error. Test it with a table test that asserts err == nil, not just err != nil in the happy path.
Discussion & Reflection Prompts
- Which structs in your current code contain a mutex, and are any of their methods value receivers?
- Where do you declare interfaces today? Is it the producing or the consuming package?
- Do your handlers branch on error strings with
strings.Contains? What woulderrors.Islook like there? - Which of your errors get logged twice, once deep and once at the handler?
Expert Corner
- Log once, at the boundary that handles the error, and wrap everywhere else. Double logging is the most common noise source in production logs.
- Error strings stay lowercase with no trailing punctuation, because they get embedded into longer wrapped messages.
- Do not wrap and then discard with
_ = err. If you must swallow, comment why. - Prefer
errors.Isover string matching.err.Error() == "not found"breaks the day someone adds context. - Return
nilexplicitly, never a nil pointer typed as an error. - Resist interface pollution. If an interface has one implementation and one caller, you probably wanted a function parameter or a struct field.
go vetandstaticcheckcatch many of these. Run them in CI.
Summary & Next Steps
Value semantics decide whether you mutate or copy. Small interfaces keep the seams in the right place. Wrapped errors plus errors.Is and errors.As keep failure information intact all the way up. These three habits are what make the next lesson possible: once a goroutine is running concurrently, a copied mutex or a swallowed error becomes a race or a silent failure that is far harder to debug.
Next up: Goroutines, Channels, and Context for Request-Scoped Work, where we put these types and errors into concurrent fan-out code that respects cancellation.
1. Wrapping an error with `fmt.Errorf` requires the {{c1::%w}} verb.
2. `errors.Is` walks the chain to match a {{c2::sentinel}} error value.
3. `errors.As` extracts a {{c3::typed}} error into a target.
4. A nil pointer stored in an interface makes the interface {{c4::non-nil}}.
5. Accept interfaces, return {{c5::structs}}.
[
{ "front": "When is a Go value copied?", "back": "On assignment, argument passing, return, and value-receiver method calls." },
{ "front": "Why is a value receiver dangerous on a struct containing sync.Mutex?", "back": "It copies the mutex, so two locks guard one value, and go vet flags it." },
{ "front": "What does the method set of *T include that T's does not?", "back": "The pointer receiver methods, so *T can satisfy interfaces that T cannot." },
{ "front": "Where should an interface be declared?", "back": "In the package that consumes it, not the package that implements it." },
{ "front": "Complete the rule: accept interfaces, return ___", "back": "structs (concrete types)." },
{ "front": "Why is a nil pointer stored in an error interface not equal to nil?", "back": "The interface holds a (type, value) pair, and the type is set even when the pointer is nil." },
{ "front": "Which verb wraps an error in fmt.Errorf?", "back": "%w, which keeps the original reachable through Unwrap." },
{ "front": "errors.Is versus errors.As?", "back": "Is matches a sentinel value in the chain; As extracts a typed error into a target." },
{ "front": "What does errors.Unwrap return?", "back": "The next error in the chain, or nil when there is none." },
{ "front": "How do you declare a sentinel error?", "back": "var ErrNotFound = errors.New(\"not found\")" },
{ "front": "What is the Go error string convention?", "back": "Lowercase, no trailing punctuation, no newline, since it gets embedded in wrapped messages." },
{ "front": "How many times should one error be logged?", "back": "Once, at the boundary that handles it; everything below wraps instead." },
{ "front": "Which Go version added multiple %w verbs and errors.Join?", "back": "Go 1.20." }
]