error interface — Tasks¶
Hands-on exercises focused on the
errorinterface itself: building custom error types, choosing receivers, behavioral interfaces, embedding, customIs/As, and method-set rules. Difficulty: easy → hard.
Task 1 (Easy) — Implement the simplest custom error¶
Define a struct NotFoundError with a single field Resource string. Implement Error() so the message is "not found: <resource>". Write a function Find(name string) (string, error) that returns &NotFoundError{Resource: name} when name == "" and "ok" otherwise.
Hints - Use a pointer receiver. - Verify it satisfies error by assigning to var e error.
Solution
package main
import "fmt"
type NotFoundError struct {
Resource string
}
func (e *NotFoundError) Error() string {
return "not found: " + e.Resource
}
func Find(name string) (string, error) {
if name == "" {
return "", &NotFoundError{Resource: name}
}
return "ok", nil
}
func main() {
_, err := Find("")
fmt.Println(err) // "not found: "
_, err = Find("user")
fmt.Println(err == nil) // true
}
Task 2 (Easy) — Value receiver for a string-typed error¶
Define type ErrCode string so it satisfies the error interface. Define three constants (ErrInvalid, ErrTimeout, ErrShutdown). Write validate(x int) error that returns ErrInvalid if x < 0, ErrTimeout if x > 1000, nil otherwise.
Hints - A named string type can have methods. - Constants can be of named string types. - The Error() method just returns string(e).
Solution
package main
import "fmt"
type ErrCode string
func (e ErrCode) Error() string { return string(e) }
const (
ErrInvalid ErrCode = "invalid value"
ErrTimeout ErrCode = "timeout"
ErrShutdown ErrCode = "shutting down"
)
func validate(x int) error {
switch {
case x < 0:
return ErrInvalid
case x > 1000:
return ErrTimeout
}
return nil
}
func main() {
fmt.Println(validate(-1)) // invalid value
fmt.Println(validate(2000)) // timeout
fmt.Println(validate(50) == nil) // true
fmt.Println(validate(-1) == ErrInvalid) // true (value identity)
}
Task 3 (Easy → Medium) — Empty struct error vs sentinel¶
Define type ShutdownError struct{} with an Error() method on the value receiver. Write a top-level constant-style sentinel var ErrShutdown = ShutdownError{}. Write a function tryStart(running bool) error that returns ErrShutdown when not running. Verify two checks: err == ErrShutdown and errors.Is(err, ErrShutdown).
Hints - Empty struct compares equal to itself by value. - Both checks should pass.
Solution
package main
import (
"errors"
"fmt"
)
type ShutdownError struct{}
func (ShutdownError) Error() string { return "system is shutting down" }
var ErrShutdown = ShutdownError{}
func tryStart(running bool) error {
if !running {
return ErrShutdown
}
return nil
}
func main() {
err := tryStart(false)
fmt.Println(err == ErrShutdown) // true
fmt.Println(errors.Is(err, ErrShutdown)) // true
}
Task 4 (Medium) — Pointer receiver mistake — fix it¶
The following code does not compile. Identify why and fix it.
package main
import "fmt"
type Foo struct{ Msg string }
func (f *Foo) Error() string { return f.Msg }
func wrap(msg string) error {
return Foo{Msg: msg} // ?
}
Hints - The Error() method has a pointer receiver. - Method set of value Foo does not include Error().
Solution
package main
import "fmt"
type Foo struct{ Msg string }
func (f *Foo) Error() string { return f.Msg }
func wrap(msg string) error {
return &Foo{Msg: msg} // pointer
}
func main() {
err := wrap("hi")
fmt.Println(err)
}
The fix: return &Foo{...}, not Foo{...}. Only *Foo is in the method set with Error().
Task 5 (Medium) — Embed error to add a Field¶
Write a ValidationError type that embeds error and adds a Field string. Provide a constructor NewValidationError(field string, cause error) *ValidationError. Show that the outer struct satisfies error automatically (no need to write Error() yourself), and show that errors.Is(ve, cause) does not work without Unwrap(). Then add Unwrap() and show that it does work.
Hints - Embedding promotes methods. - Unwrap() must be defined explicitly.
Solution
package main
import (
"errors"
"fmt"
)
type ValidationError struct {
error
Field string
}
func NewValidationError(field string, cause error) *ValidationError {
return &ValidationError{error: cause, Field: field}
}
// Without this method, errors.Is cannot see through.
func (e *ValidationError) Unwrap() error { return e.error }
var ErrEmpty = errors.New("empty")
func main() {
ve := NewValidationError("email", ErrEmpty)
fmt.Println(ve) // "empty" (delegated)
fmt.Println(ve.Field) // "email"
fmt.Println(errors.Is(ve, ErrEmpty)) // true (via Unwrap)
}
Task 6 (Medium) — Behavioral interface Temporary¶
Define an interface Temporary { Temporary() bool }. Define a custom error *RateLimitError with Error() and Temporary() (returning true). Write process(err error) string that returns "retry" if the error implements Temporary and reports temporary, "fail" otherwise.
Hints - Use a type assertion or errors.As with a target of interface type.
Solution
package main
import (
"errors"
"fmt"
)
type Temporary interface {
Temporary() bool
}
type RateLimitError struct{ RetryAfter int }
func (e *RateLimitError) Error() string { return fmt.Sprintf("rate limited, retry after %ds", e.RetryAfter) }
func (e *RateLimitError) Temporary() bool { return true }
func process(err error) string {
var t Temporary
if errors.As(err, &t) && t.Temporary() {
return "retry"
}
return "fail"
}
func main() {
fmt.Println(process(&RateLimitError{RetryAfter: 5})) // retry
fmt.Println(process(errors.New("permanent"))) // fail
}
Task 7 (Medium) — Custom Is for value-equality¶
Define *HTTPError with field Status int. Without a custom Is, comparing two distinct instances with the same Status yields false (different pointers). Implement a custom Is(target error) bool so errors.Is(e1, e2) is true when both are *HTTPError and have the same Status.
Hints - Type-assert the target. - Compare the relevant field.
Solution
package main
import (
"errors"
"fmt"
)
type HTTPError struct {
Status int
Msg string
}
func (e *HTTPError) Error() string { return fmt.Sprintf("http %d: %s", e.Status, e.Msg) }
func (e *HTTPError) Is(target error) bool {
t, ok := target.(*HTTPError)
return ok && e.Status == t.Status
}
func main() {
e1 := &HTTPError{Status: 404, Msg: "not found"}
e2 := &HTTPError{Status: 404}
fmt.Println(e1 == e2) // false (different pointers)
fmt.Println(errors.Is(e1, e2)) // true (custom Is matches)
}
Task 8 (Medium) — Compose multiple interfaces¶
Define *APIError with fields Status int, Msg string, Cause error. Implement Error(), Unwrap(), and StatusCode() int. Define an interface StatusCoder { StatusCode() int }. Write httpStatus(err error) int that returns the status from any error chain that contains a StatusCoder, or 500 otherwise.
Hints - Use errors.As with a target of interface type StatusCoder.
Solution
package main
import (
"errors"
"fmt"
)
type APIError struct {
Status int
Msg string
Cause error
}
func (e *APIError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("api %d: %s: %v", e.Status, e.Msg, e.Cause)
}
return fmt.Sprintf("api %d: %s", e.Status, e.Msg)
}
func (e *APIError) Unwrap() error { return e.Cause }
func (e *APIError) StatusCode() int { return e.Status }
type StatusCoder interface {
StatusCode() int
}
func httpStatus(err error) int {
var sc StatusCoder
if errors.As(err, &sc) {
return sc.StatusCode()
}
return 500
}
func main() {
err := fmt.Errorf("wrapping: %w", &APIError{Status: 404, Msg: "missing"})
fmt.Println(httpStatus(err)) // 404
fmt.Println(httpStatus(errors.New("x"))) // 500
}
Task 9 (Medium → Hard) — Comparable pitfall¶
Write a small program that demonstrates the panic when comparing two error values whose dynamic type contains a slice. Then fix it by changing to a pointer receiver.
Hints - A slice is not comparable. - errors.Is falls into == and panics.
Solution
package main
import (
"errors"
"fmt"
)
// Step 1: demonstrate the panic.
type BadErr struct{ Tags []string }
func (BadErr) Error() string { return "bad" }
// Step 2: pointer receiver version is comparable by pointer identity.
type GoodErr struct{ Tags []string }
func (e *GoodErr) Error() string { return "good" }
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered from BadErr panic:", r)
}
}()
var a error = BadErr{Tags: []string{"x"}}
var b error = BadErr{Tags: []string{"x"}}
fmt.Println(errors.Is(a, b)) // panic: comparing uncomparable type
// Pointer receiver does not panic; identity is by pointer.
g := &GoodErr{Tags: []string{"x"}}
fmt.Println(errors.Is(g, g)) // true (same pointer)
}
The lesson: error structs containing slices, maps, or functions must use pointer receivers, or must avoid errors.Is. Standard library types are always comparable.
Task 10 (Hard) — Sealed error interface¶
Build a package-private "sealed" error interface so that only types in your package can satisfy it. External packages can read the interface but cannot construct it.
Hints - Add an unexported method like sealed(). - Implement it on each internal error type. - An external package cannot call or implement an unexported method.
Solution
package main
import "fmt"
// In a real package, these would be in db/errors.go.
type Error interface {
error
Code() string
sealed()
}
type notFound struct{ Resource string }
func (e *notFound) Error() string { return "not found: " + e.Resource }
func (e *notFound) Code() string { return "NOT_FOUND" }
func (e *notFound) sealed() {}
type conflict struct{ Field string }
func (e *conflict) Error() string { return "conflict: " + e.Field }
func (e *conflict) Code() string { return "CONFLICT" }
func (e *conflict) sealed() {}
func NewNotFound(r string) Error { return ¬Found{Resource: r} }
func NewConflict(f string) Error { return &conflict{Field: f} }
func main() {
var e Error = NewNotFound("user")
fmt.Println(e.Error()) // not found: user
fmt.Println(e.Code()) // NOT_FOUND
switch e.(type) {
case *notFound:
fmt.Println("kind: not found")
case *conflict:
fmt.Println("kind: conflict")
}
}
A switch over Error is exhaustive in practice because external code cannot add cases. Note: the unexported method makes the interface "external implementations forbidden" — useful for invariants.
Task 11 (Hard) — Wrap-aware FileError with Is and Unwrap¶
Define *FileError carrying Op string, Path string, Err error. Implement Error(), Unwrap(), and a custom Is(target error) bool that returns true if target is a *FileError with the same Op and Path (regardless of inner cause). Verify all three behaviors:
errors.Is(err, sentinel)traverses to find the inner sentinel.errors.Is(e1, e2)matches by Op+Path.errors.As(err, &fe)extracts the*FileError.
Solution
package main
import (
"errors"
"fmt"
"io/fs"
)
type FileError struct {
Op string
Path string
Err error
}
func (e *FileError) Error() string {
return fmt.Sprintf("%s %q: %v", e.Op, e.Path, e.Err)
}
func (e *FileError) Unwrap() error { return e.Err }
func (e *FileError) Is(target error) bool {
t, ok := target.(*FileError)
return ok && e.Op == t.Op && e.Path == t.Path
}
func main() {
err := &FileError{Op: "open", Path: "/etc/foo", Err: fs.ErrNotExist}
// 1. wrapped sentinel reachable via Unwrap.
fmt.Println(errors.Is(err, fs.ErrNotExist)) // true
// 2. structural equivalence via custom Is.
fmt.Println(errors.Is(err, &FileError{Op: "open", Path: "/etc/foo"})) // true
// 3. type extraction via errors.As.
var fe *FileError
fmt.Println(errors.As(err, &fe), fe.Path) // true /etc/foo
}
Task 12 (Hard) — Custom As to expose adapter form¶
Define *WrappedDBError that wraps a *DBError with extra metadata. Write a custom As(target any) bool so that callers expecting a *DBError get the inner one (without writing the wrapper into them).
Hints - Type-assert target as **DBError. - Assign *pp = e.Inner.
Solution
package main
import (
"errors"
"fmt"
)
type DBError struct {
Code string
Msg string
}
func (e *DBError) Error() string { return fmt.Sprintf("db %s: %s", e.Code, e.Msg) }
type WrappedDBError struct {
Inner *DBError
TraceID string
}
func (e *WrappedDBError) Error() string {
return fmt.Sprintf("trace=%s: %v", e.TraceID, e.Inner)
}
func (e *WrappedDBError) Unwrap() error { return e.Inner }
func (e *WrappedDBError) As(target any) bool {
if pp, ok := target.(**DBError); ok {
*pp = e.Inner
return true
}
return false
}
func main() {
err := &WrappedDBError{Inner: &DBError{Code: "23505", Msg: "unique violation"}, TraceID: "abc123"}
var dbErr *DBError
fmt.Println(errors.As(err, &dbErr)) // true
fmt.Println(dbErr.Code) // 23505
fmt.Println(dbErr.Msg) // unique violation
}
The custom As hands the caller the inner *DBError, not the wrapper.
Task 13 (Hard) — Behavioral retry pipeline¶
Build a retry helper RetryOnTemporary(attempts int, fn func() error) error:
- Calls
fnup toattemptstimes. - On error, if the error implements
interface { Temporary() bool }and reports temporary, retry. Otherwise return immediately. - Return the last error wrapped with attempt count.
Solution
package main
import (
"errors"
"fmt"
"time"
)
type Temporary interface {
Temporary() bool
}
func RetryOnTemporary(attempts int, fn func() error) error {
var last error
for i := 0; i < attempts; i++ {
err := fn()
if err == nil {
return nil
}
last = err
var t Temporary
if !errors.As(err, &t) || !t.Temporary() {
return fmt.Errorf("permanent failure on attempt %d: %w", i+1, err)
}
time.Sleep(time.Duration(i+1) * 10 * time.Millisecond)
}
return fmt.Errorf("exhausted %d attempts: %w", attempts, last)
}
type Flaky struct{ tries int }
func (e *Flaky) Error() string { return fmt.Sprintf("flaky try %d", e.tries) }
func (e *Flaky) Temporary() bool { return true }
func main() {
var n int
err := RetryOnTemporary(3, func() error {
n++
if n < 3 {
return &Flaky{tries: n}
}
return nil
})
fmt.Println(err) // <nil>
fmt.Println(n) // 3
}
Task 14 (Hard) — Build a kind-based error type¶
Build a single error type *Error with a Kind enum, an Op string, an Err error cause. Provide constructors and a custom Is that compares only on Kind.
Solution
package main
import (
"errors"
"fmt"
)
type Kind int
const (
KindOther Kind = iota
KindNotFound
KindConflict
KindInvalid
)
func (k Kind) String() string {
switch k {
case KindNotFound:
return "not_found"
case KindConflict:
return "conflict"
case KindInvalid:
return "invalid"
}
return "other"
}
type Error struct {
Kind Kind
Op string
Err error
}
func (e *Error) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s %s: %v", e.Op, e.Kind, e.Err)
}
return fmt.Sprintf("%s %s", e.Op, e.Kind)
}
func (e *Error) Unwrap() error { return e.Err }
func (e *Error) Is(target error) bool {
t, ok := target.(*Error)
return ok && e.Kind == t.Kind
}
func New(op string, kind Kind, err error) error {
return &Error{Op: op, Kind: kind, Err: err}
}
func main() {
err := New("user.Save", KindConflict, errors.New("duplicate email"))
fmt.Println(err)
if errors.Is(err, &Error{Kind: KindConflict}) {
fmt.Println("matched on Kind") // matched on Kind
}
}
Task 15 (Hard) — Method-set puzzle¶
Predict and explain the output:
type S struct{}
func (s S) Error() string { return "value" }
func (s *S) String() string { return "ptr-string" }
func print(e error) { fmt.Println(e) }
func main() {
var v S = S{}
print(v)
print(&v)
}
Hints - Method set of S includes Error() (value receiver). - Method set of *S includes Error() and String(). - fmt.Println calls Error() on errors.
Solution Both calls satisfy error (value or pointer). Both prints invoke Error(). Output:
String() method on *S does not affect the error formatting because Error() takes precedence in fmt. If you want different behavior, override Error() on *S so the pointer form returns a different message.
Task 16 (Boss-level) — Multi-error type with custom Is, Unwrap[]error¶
Build *MultiError that holds a slice of errors. Implement:
Error()joining with"; ".Unwrap() []errorreturning the slice (the formerrors.Is/errors.Aswalk into).Is(target error) boolreturning true if any contained error matches.
Verify errors.Is(me, sentinel) returns true when one of the contained errors is the sentinel.
Solution
package main
import (
"errors"
"fmt"
"strings"
)
type MultiError struct {
Errs []error
}
func (m *MultiError) Error() string {
parts := make([]string, len(m.Errs))
for i, e := range m.Errs {
parts[i] = e.Error()
}
return strings.Join(parts, "; ")
}
func (m *MultiError) Unwrap() []error { return m.Errs }
// With Unwrap() []error defined, errors.Is and errors.As recurse on each.
// A custom Is is therefore optional, but shown here for clarity.
func (m *MultiError) Is(target error) bool {
for _, e := range m.Errs {
if errors.Is(e, target) {
return true
}
}
return false
}
var ErrA = errors.New("a")
var ErrB = errors.New("b")
func main() {
me := &MultiError{Errs: []error{ErrA, ErrB, errors.New("c")}}
fmt.Println(me) // a; b; c
fmt.Println(errors.Is(me, ErrA)) // true
fmt.Println(errors.Is(me, ErrB)) // true
fmt.Println(errors.Is(me, errors.New("z"))) // false
}
This mirrors how errors.Join works internally (its *joinError type defines Unwrap() []error). The standard library since Go 1.20 recognizes this method as a multi-unwrap form.