July 7, 2026

Go Interfaces and Errors – Polymorphism and Idiomatic Error Handling

Updated July 2, 2026. Refreshed for Go 1.22+ and current SEO best practices.

In this lesson we cover Go interfaces and errors — two features that shape how production Go code is structured. Interfaces provide implicit polymorphism: a type satisfies an interface by implementing its methods, with no implements keyword. The error type is a built-in interface that every Go developer uses daily. This lesson focuses especially on Go error handling — the idiomatic patterns that replace exceptions in other languages.

Prerequisites: Lessons 1–9 completed, Go 1.22 or newer installed. Estimated time: 50–60 minutes.

1. The error Type and Basic Handling

In Go, functions signal failure by returning an error as the last return value. The error interface is minimal — any type with an Error() string method satisfies it:

type error interface {
	Error() string
}

The standard pattern is to check the error immediately after the call:

package main

import (
	"fmt"
	"os"
)

func main() {
	data, err := os.ReadFile("config.json")
	if err != nil {
		fmt.Println("failed to read config:", err)
		return
	}
	fmt.Printf("read %d bytes\n", len(data))
}

There are no try/catch blocks. Errors are explicit values that flow through your program. This verbosity is intentional — it forces you to decide what to do at each failure point: retry, log, return upstream, or abort.

2. Creating and Returning Errors

Use errors.New for simple messages and fmt.Errorf for formatted messages. Return nil when there is no error:

package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("book not found")

func findBook(id int) (string, error) {
	books := map[int]string{1: "The Go Programming Language", 2: "Concurrency in Go"}
	title, ok := books[id]
	if !ok {
		return "", ErrNotFound
	}
	return title, nil
}

func divide(a, b float64) (float64, error) {
	if b == 0 {
		return 0, fmt.Errorf("divide: cannot divide %g by zero", a)
	}
	return a / b, nil
}

func main() {
	title, err := findBook(99)
	if err != nil {
		fmt.Println(err) // book not found
		return
	}
	fmt.Println(title)
}

Export sentinel errors like ErrNotFound (uppercase) so callers can compare them with errors.Is. Keep error messages lowercase and without trailing punctuation — they are often wrapped and concatenated.

3. Wrapping Errors with %w

When a lower-level error bubbles up through your code, wrap it with context using fmt.Errorf and the %w verb. Wrapping preserves the original error for inspection while adding a human-readable layer:

package main

import (
	"errors"
	"fmt"
	"os"
)

func loadConfig(path string) error {
	_, err := os.ReadFile(path)
	if err != nil {
		return fmt.Errorf("loadConfig(%q): %w", path, err)
	}
	return nil
}

func main() {
	err := loadConfig("/etc/app/config.json")
	if err != nil {
		fmt.Println(err)
		// loadConfig("/etc/app/config.json"): open /etc/app/config.json: no such file or directory

		if errors.Is(err, os.ErrNotExist) {
			fmt.Println("→ config file is missing — create one or check the path")
		}
	}
}

errors.Is(err, target) walks the unwrap chain to find a matching error. errors.As(err, &target) extracts a specific error type from the chain. These functions replaced fragile string matching and are essential in modern Go error handling.

4. Interfaces — Implicit Satisfaction

An interface defines a set of method signatures. A type satisfies an interface by implementing those methods — no declaration required. The empty interface interface{} (or any since Go 1.18) is satisfied by every type.

package main

import "fmt"

type Speaker interface {
	Speak() string
}

type Dog struct{ Name string }
type Cat struct{ Name string }

func (d Dog) Speak() string  { return d.Name + " says Woof!" }
func (c Cat) Speak() string  { return c.Name + " says Meow!" }

func announce(s Speaker) {
	fmt.Println(s.Speak())
}

func main() {
	announce(Dog{Name: "Rex"})
	announce(Cat{Name: "Luna"})
}

Interfaces enable polymorphism and dependency injection. The announce function accepts any Speaker — it does not care whether the concrete type is a Dog or Cat. Small interfaces (one or two methods) are preferred — “the bigger the interface, the weaker the abstraction.”

5. Standard Library Interfaces — io.Reader and io.Writer

The most important interfaces in Go live in the io package. io.Reader and io.Writer abstract reading and writing from files, network connections, buffers, and more:

package main

import (
	"fmt"
	"io"
	"strings"
)

func copyAndUppercase(w io.Writer, r io.Reader) error {
	data, err := io.ReadAll(r)
	if err != nil {
		return fmt.Errorf("copyAndUppercase: read: %w", err)
	}
	_, err = w.Write([]byte(strings.ToUpper(string(data))))
	if err != nil {
		return fmt.Errorf("copyAndUppercase: write: %w", err)
	}
	return nil
}

func main() {
	var buf strings.Builder
	input := strings.NewReader("hello, interfaces")
	if err := copyAndUppercase(&buf, input); err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(buf.String()) // HELLO, INTERFACES
}

Design your own packages around small interfaces. Accept io.Reader instead of a file path when possible — callers can pass strings, HTTP bodies, or compressed streams without changing your function.

6. Custom Error Types

For errors that carry structured data — HTTP status codes, field names, retry hints — define a custom type with an Error() method:

package main

import (
	"errors"
	"fmt"
	"strings"
)

type ValidationError struct {
	Field   string
	Message string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("validation failed on %q: %s", e.Field, e.Message)
}

func validateEmail(email string) error {
	if email == "" {
		return &ValidationError{Field: "email", Message: "required"}
	}
	if !strings.Contains(email, "@") {
		return &ValidationError{Field: "email", Message: "must contain @"}
	}
	return nil
}

func main() {
	err := validateEmail("")
	var ve *ValidationError
	if errors.As(err, &ve) {
		fmt.Printf("field=%s message=%s\n", ve.Field, ve.Message)
	}
}

Use errors.As to extract *ValidationError from a wrapped error chain. This pattern powers HTTP handlers that map validation failures to 400 responses and database errors to 500 responses — exactly what you will build in the Lesson 12 REST API capstone.

7. panic, recover, and When Not to Use Them

panic stops normal execution and unwinds the stack. Use it for truly unrecoverable programmer errors — not for expected failures like a missing file or invalid user input. Those belong in error returns.

package main

import "fmt"

func safeDivide(a, b int) (result int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("recovered from panic: %v", r)
		}
	}()
	return a / b, nil // panics if b == 0
}

func main() {
	result, err := safeDivide(10, 0)
	if err != nil {
		fmt.Println(err) // recovered from panic: runtime error: integer divide by zero
		return
	}
	fmt.Println(result)
}

recover() only works inside deferred functions. HTTP servers use it in middleware to catch panics and return 500 instead of crashing the process. For application logic, prefer explicit error returns — they are easier to test and reason about.

8. Next Steps

You now understand Go interfaces and errors — the error interface, wrapping with %w, errors.Is and errors.As, implicit interface satisfaction, and when to use panic. In the next lesson we explore goroutines and channels — Go’s built-in concurrency model that makes it a top choice for networked services.

Continue the series: Lesson 9 – Pointers · Lesson 11 – Goroutines and Channels

Frequently Asked Questions

Why does Go not have exceptions?
Go treats errors as values returned from functions. This makes failure paths explicit and visible at every call site, which improves reliability in large codebases and concurrent programs.

What is the difference between errors.Is and errors.As?
errors.Is checks whether any error in the unwrap chain equals a target sentinel error. errors.As extracts a specific error type from the chain into a variable you provide.

How do interfaces work in Go?
A type satisfies an interface by implementing its methods — no implements keyword needed. Interfaces are satisfied implicitly, enabling flexible, decoupled designs.

Want live Go or backend classes? Join Alkademy for instructor-led programming courses with hands-on projects.


0 0 votes
Article Rating
Subscribe
Notify of
0 Comments
Oldest
Newest Most Voted
0
Would love your thoughts, please comment.x
()
x