Skip to content
7 min read·Lesson 3 of 8

Errors, Interfaces, and Composition

How Go handles errors (no exceptions), the interface model (implicit, duck-typed), and composition via struct embedding.

Go's two most distinctive language design choices are error handling (no exceptions, errors are values) and the interface model (implicit, structural). Master these and the language clicks.

Errors Are Values

error is a built-in interface:

type error interface {
    Error() string
}

Any type with an Error() string method is an error. Functions return errors as their last return value:

func ReadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("read config %s: %w", path, err)
    }
    cfg := &Config{}
    if err := json.Unmarshal(data, cfg); err != nil {
        return nil, fmt.Errorf("parse config: %w", err)
    }
    return cfg, nil
}

Three idioms in those eight lines:

  1. Check immediately. if err != nil { ... } right after each call.
  2. Wrap with context. fmt.Errorf("...: %w", err) preserves the original (the %w verb) while adding context. The error message reads as a chain.
  3. Return early. No else branch; the happy path stays unindented.

Custom Error Types

type NotFoundError struct {
    Resource string
    ID       string
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s %q not found", e.Resource, e.ID)
}

// Sentinel error — package-level value
var ErrUnauthorised = errors.New("unauthorised")

Checking Error Identity

Two functions in the errors package do almost all the work:

// errors.Is — does the error chain contain a specific sentinel?
if errors.Is(err, ErrUnauthorised) {
    return http.StatusUnauthorized
}

// errors.As — extract a specific error type from the chain
var nfe *NotFoundError
if errors.As(err, &nfe) {
    log.Printf("not found: %s/%s", nfe.Resource, nfe.ID)
    return http.StatusNotFound
}

Never compare error strings with == or substring matches — fragile. Use errors.Is or errors.As.

When to Panic

Almost never. panic aborts the goroutine unwinding the stack. Use it for:

  • Programmer mistakes that cannot continue (impossible-to-reach code paths)
  • init() failures where the program cannot proceed
  • Recovering from a third-party library that panics — wrap with recover at the HTTP middleware level

Never panic on user input, network failures, or expected error conditions. Return errors.

Interfaces

An interface is a set of method signatures. Any type that has those methods satisfies the interface — implicitly. There is no implements keyword.

type Storer interface {
    Get(key string) ([]byte, error)
    Put(key string, value []byte) error
    Delete(key string) error
}

// MemoryStore satisfies Storer simply by having those methods
type MemoryStore struct {
    data map[string][]byte
}

func (m *MemoryStore) Get(key string) ([]byte, error) { ... }
func (m *MemoryStore) Put(key string, value []byte) error { ... }
func (m *MemoryStore) Delete(key string) error { ... }

// Use anywhere a Storer is expected
func writeReport(s Storer, name string, content []byte) error {
    return s.Put("reports/"+name, content)
}

The implicit satisfaction is a big deal. You can write code against an interface defined in your package, then pass it a type defined in someone else's package that you have never met — as long as the method signatures match.

Small Interfaces

Idiom: the smaller the interface, the more useful. The standard library's most-used interfaces are single-method:

InterfaceMethodUsed by
io.ReaderRead(p []byte) (int, error)Files, HTTP bodies, network connections, buffers, decompressors
io.WriterWrite(p []byte) (int, error)Files, HTTP responses, log targets, compressors
fmt.StringerString() stringCustom %v formatting
errorError() stringEvery error
http.HandlerServeHTTP(w, r)Every HTTP route

"Accept io.Reader" lets your function read from a file, a network connection, or a test fixture without caring which.

The Empty Interface and any

The empty interface (interface{}, or any from 1.18) is satisfied by every type. Use it for genuinely untyped data — like JSON decoding into arbitrary structures. Outside those cases, prefer specific interfaces or generics.

Composition via Embedding

Go has no inheritance. The way to share behaviour is to embed one type inside another:

type Logger struct{}

func (l Logger) Log(msg string) { fmt.Println(msg) }

type Service struct {
    Logger        // embedded; Service now has a Log method
    Name string
}

s := Service{Name: "checkout"}
s.Log("started")        // calls Logger.Log
fmt.Println(s.Name)

The embedded type's methods are "promoted" to the embedding struct. There is no super, no overriding — if you define a Log method on Service it simply shadows.

This is the standard pattern for what other languages call mixins. Used heavily in the standard library — bufio.Reader embeds io.Reader and adds buffering, etc.

The Nil-Interface Gotcha

An interface value is two pointers: type and value. An interface is nil only when both are nil. This trips up everyone once:

func mightFail() error {
    var e *MyError = nil      // typed nil pointer
    return e                  // returns interface{type=*MyError, value=nil} — NOT nil!
}

if err := mightFail(); err != nil {
    // This branch fires! err is not the nil interface.
    fmt.Println("oops")
}

Rule: never declare var e *MyError and return it. Return nil directly, or use a different approach.

Practical Patterns You Will Use Daily

  • Functional options. NewServer(WithTimeout(5*time.Second), WithLogger(log)) instead of long parameter lists.
  • Defer cleanup. f, err := os.Open(p); defer f.Close(). defer runs when the surrounding function returns, in LIFO order.
  • Return early. Validate inputs and error on top; happy path at the bottom.
  • Constructor convention. NewX returns a pointer to a configured X plus an error.

These three ideas — errors as values, implicit interfaces, composition via embedding — are most of what makes Go feel different. The rest is syntactic sugar.

Key Takeaways

  • Errors are values returned alongside results — handle each one explicitly.
  • errors.Is / errors.As are the idiomatic ways to check error chains.
  • Interfaces are implicit — any type with the right methods satisfies them.
  • Accept interfaces (small), return structs (concrete).
  • Composition via embedding replaces inheritance.

Test your knowledge

Try exam-style practice questions to reinforce what you've learned.

Practice Questions →