error interface — Junior Level¶
Table of Contents¶
- Introduction
- Prerequisites
- Glossary
- The error Interface
- Your First Custom Error
- Why an Interface and Not a Struct
- Code Examples
- Pros & Cons
- Use Cases
- Coding Patterns
- Clean Code
- Common Mistakes
- Edge Cases
- Tricky Points
- Cheat Sheet
- Self-Assessment
- Summary
- Further Reading
- Diagrams
Introduction¶
Focus: "What is the error interface?" and "How do I make my own error type?"
In 01-error-handling-basics you learned that an error is a value you return from functions. But what is an error, exactly?
Go answers: an error is anything with an Error() string method.
That is the entire definition. Not a struct, not a class — an interface. Any type — yours, mine, the standard library's — can be an error if it has that one method.
This file is about the rules, mechanics, and idioms of writing your own error types using this interface. We'll start with errors.New (which is what you've used so far), explain what it actually is under the hood, and build up to writing your own error types with extra fields like a status code or a path.
Prerequisites¶
- Required: You can write and call functions that return
error. - Required: You understand structs and methods.
- Required: You know what an interface is at a high level.
- Helpful: You've read 01-error-handling-basics.
Glossary¶
| Term | Definition |
|---|---|
| interface | A Go type that lists method signatures. Anything with those methods is the interface. |
| method set | The set of methods declared on a type (and its pointer, with caveats). |
| method | A function with a receiver, e.g. func (e *MyErr) Error() string. |
| receiver | The thing the method is attached to: value (func (e MyErr) ...) or pointer (func (e *MyErr) ...). |
| satisfy | A type satisfies an interface when it has all the interface's methods. |
| dynamic type | The actual concrete type stored in an interface value at runtime. |
| predeclared | Built into the language. error, int, bool are predeclared. |
The error Interface¶
Go has only one predeclared interface: error. Its definition is conceptually:
Three observations:
- One method. Just
Error(). NotError(),Code(),Unwrap()— onlyError(). - Returns a string. A human-readable description of the failure.
- The interface lives in the universe block. You do not import it; you cannot redefine it.
So when you write errors.New("oops"), you get back something that has an Error() string method. When you write if err != nil { fmt.Println(err) }, the fmt package calls err.Error() to get the text.
Your First Custom Error¶
package main
import "fmt"
type DivisionError struct {
Dividend, Divisor float64
}
func (e *DivisionError) Error() string {
return fmt.Sprintf("cannot divide %g by %g", e.Dividend, e.Divisor)
}
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, &DivisionError{Dividend: a, Divisor: b}
}
return a / b, nil
}
func main() {
_, err := Divide(10, 0)
if err != nil {
fmt.Println(err) // calls err.Error()
}
}
What happened: 1. We defined a struct DivisionError with two fields. 2. We attached an Error() string method to *DivisionError. Now *DivisionError satisfies the error interface. 3. Divide returns a *DivisionError when b == 0. The compiler accepts this because *DivisionError is an error. 4. The caller treats it like any other error.
Why a pointer receiver (*DivisionError)? - It avoids copying the struct on every method call. - It's the convention for error types in the standard library (*os.PathError, *net.OpError, *json.SyntaxError). - Pointer comparisons work cleanly with errors.Is.
You can use a value receiver, but unless your error has no fields (or you have a strong reason), use pointer receivers.
Why an Interface and Not a Struct¶
If error were a struct, every error would have the same fields. Want to add a status code? Now everyone in the world has to.
If error were a class hierarchy (Java-style), you would need to extend a base class. Cross-package inheritance is awkward.
By making error an interface, Go says: "show me an Error() string method and I will accept your value." Each package, each application, can define error types with whatever extra fields they need. They all interoperate because they all satisfy the same one-method interface.
This is the structural typing philosophy: types are not labels you stick on; they emerge from what the type can do.
Code Examples¶
Example 1: errorString — what errors.New returns¶
The standard library's implementation of errors.New:
package main
import "fmt"
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}
func New(text string) error {
return &errorString{s: text}
}
func main() {
err := New("hello")
fmt.Println(err.Error())
}
That's it. errors.New is literally four lines. You could write it yourself.
Example 2: An error with a code¶
type APIError struct {
Code int
Message string
}
func (e *APIError) Error() string {
return fmt.Sprintf("API %d: %s", e.Code, e.Message)
}
func getUser(id int) (*User, error) {
if id < 0 {
return nil, &APIError{Code: 400, Message: "negative id"}
}
return nil, &APIError{Code: 404, Message: "user not found"}
}
The caller can either ignore the structure (just print err) or extract the code:
err := getUser(-1)
var apiErr *APIError
if errors.As(err, &apiErr) {
fmt.Println("status:", apiErr.Code)
}
Example 3: An error with a path¶
type PathError struct {
Op string // "open", "read", "write"
Path string
Err error // underlying cause
}
func (e *PathError) Error() string {
return e.Op + " " + e.Path + ": " + e.Err.Error()
}
// usage
return &PathError{Op: "read", Path: "/etc/foo", Err: io.ErrUnexpectedEOF}
This is essentially how os.PathError works in the standard library.
Example 4: A value receiver (small, immutable error)¶
type StaticError string
func (e StaticError) Error() string { return string(e) }
const ErrShutdown StaticError = "system is shutting down"
func canStart() error {
if isShuttingDown {
return ErrShutdown
}
return nil
}
Here the error has no fields except a string. A value receiver works fine and avoids the heap allocation a pointer would imply.
Example 5: Multiple methods (preview)¶
type RetryableError struct {
Inner error
}
func (e *RetryableError) Error() string { return "retryable: " + e.Inner.Error() }
func (e *RetryableError) Unwrap() error { return e.Inner }
Here we add a second method, Unwrap(). This makes errors.Is and errors.As look through the wrapper. Detail in 05-wrapping-unwrapping-errors.
Pros & Cons¶
| Pros | Cons |
|---|---|
| Anyone can define an error type. | Easy to make typos like Errorr() — the wrong name silently fails to satisfy. |
| Custom errors carry structured data. | Custom types = more code than errors.New. |
| Interface-based — flexible and decoupled. | Pointer vs value receiver trips up beginners. |
| Standard library uses the same pattern. | Need to remember to export struct fields if other packages should read them. |
Use Cases¶
- API errors — carry HTTP status codes alongside messages.
- Validation errors — carry the offending field name.
- Path/IO errors — carry the file path and operation.
- Database errors — carry the SQL state, query, parameters.
- HTTP transport errors — carry the URL, status, and response body snippet.
Coding Patterns¶
Pattern A: Pointer receiver, allocate per failure¶
type FieldError struct{ Field string }
func (e *FieldError) Error() string { return "field " + e.Field }
return &FieldError{Field: "email"}
Default for any error with fields.
Pattern B: String-typed error (sentinel-friendly)¶
type ErrCode string
func (e ErrCode) Error() string { return string(e) }
const ErrNotFound = ErrCode("not found")
The constant comparison err == ErrNotFound works and the type itself satisfies error. Useful for enum-like sentinels.
Pattern C: Embedded error¶
Embedding the error interface gives you the Error() method "for free" (delegated to the inner error). Combine with extra fields. Detail in middle.md.
Pattern D: Behavior-bearing error¶
A separate, behavioral interface. Errors that "implement" it can be detected via type assertion: if t, ok := err.(Temporary); ok && t.Temporary() { /* retry */ }. The standard net package used to use this exactly.
Clean Code¶
- Name your error types
XxxError—ParseError,NetworkError. Convention from the standard library. Error() stringis your only required method — do not stuff every diagnostic into it. Use fields instead.- Lowercase, no trailing punctuation in the message —
"open foo.txt: no such file"not"Open foo.txt: No such file." - Pointer receivers for any error with fields. Value receivers only for empty types or single-string wrappers.
- Export the struct and its fields if other packages will inspect them via
errors.As.
Common Mistakes¶
- Misspelling the method name —
func (e *MyErr) Errorr() string. Compiler does not warn until you try to assign toerror. Even then, the compiler error can be cryptic. - Wrong receiver type — defining
Error()onMyErr(value) but returning&MyErr{}(pointer) — works, but the value type does not satisfyerror, only*MyErrdoes. - Recursive
Error()— callingfmt.Errorf("...%v...", e)insidee.Error(). Creates infinite recursion because%vcallsError(). - Forgetting to export fields — if you want the caller to inspect
Code,Path,Field, those fields need to start with a capital letter. - Returning a typed nil pointer through an interface — already covered in 5.1; particularly easy to trigger with custom error types.
Edge Cases¶
- Empty error type:
type ErrFoo struct{}withError()returning"foo"— useful but rarely necessary; a sentinelvar ErrFoo = errors.New("foo")is simpler. - Error with non-comparable fields (a slice or map):
errors.Is(a, b)will fail with a runtime panic if both are the same dynamic type — interface equality requires comparable dynamic types. - Method on the wrong receiver:
Error()on the value works for value calls;Error()on the pointer means only*Tis an error, notT.
Tricky Points¶
- A type can have both an
Error()method and other methods. The error satisfieserrorand whatever other interfaces those methods imply. - The compiler resolves
err.Error()at runtime via the itab, so callingError()on a nil interface panics — but on aniltyped pointer (interface is non-nil), it depends on howError()handles nil receivers. - A method declared on
Tis in the method set of bothTand*T. A method declared on*Tis only in the method set of*T. So a valueTdoes not satisfy an interface whose method requires*T.
Cheat Sheet¶
// Define an error type
type MyError struct {
Code int
Msg string
}
func (e *MyError) Error() string {
return fmt.Sprintf("err %d: %s", e.Code, e.Msg)
}
// Return it
return &MyError{Code: 42, Msg: "boom"}
// Inspect at the caller (preview)
var me *MyError
if errors.As(err, &me) {
use(me.Code)
}
Self-Assessment¶
- I can write a struct with an
Error() stringmethod. - I know why pointer receivers are conventional for error types.
- I can name three pitfalls when implementing custom error types.
- I understand
erroris an interface, not a base class. - I can recognize when a type satisfies
errorby inspecting its method set.
Summary¶
The error interface is one method, Error() string, and that simplicity is the entire foundation of Go's error system. Custom error types are just structs (or other types) with that method attached. Use a pointer receiver, export fields you want callers to inspect, and you have all the building blocks for any error API you can imagine.
Further Reading¶
- Effective Go: Errors
- Go blog: Error handling and Go
$GOROOT/src/os/error.go—*PathError,*LinkError,*SyscallError.$GOROOT/src/net/net.go—*OpError.$GOROOT/src/errors/errors.go—errorString.