Lesson 06 / 13
The Error Interface
An error is an ordinary value in Go and implicitly satisfies the error interface: any type that writes Error() string becomes an error without ever saying so in its source. Three types confirm this, their dynamic types read with %T; a value-receiver error's structural equality is shown apart from a pointer-receiver error's identity equality. The limiting measure: when a nil pointer is placed into the error interface, the interface itself is not nil.
Published Updated
In this lesson
- Measures that three separate types implicitly satisfy the error interface and that their dynamic types are read with %T
- Distinguishes by running it the difference in comparison between a value-receiver and a pointer-receiver error
- Shows by measurement why the interface is not nil when a nil pointer is placed into the error interface
Contents
The previous topic’s last lesson measured that a consumer can define an interface the
producing side never writes in its source at all — a natural consequence of the binding
never being written in the source. This lesson looks at where that same model is used most in
Go: the standard library itself is built around a single interface, error, and every error
value satisfies it in exactly the same way, without writing any declaration.
error is an ordinary return value in Go and an ordinary interface — it has a single method:
Error() string. This lesson’s question is the same one carried over from the previous
topic: where is the binding that satisfies this interface established? To find the answer,
three separate types will be returned as errors, with the word error never appearing in any
of their source.
The Functions lesson (M09/K01) already measured error as an ordinary return value and
showed that a caller ignoring it without ever catching it is rejected in no way — that
observation is not rebuilt here. This lesson looks at what that return value is, and when
a type counts as “an error.” The question asked will stay the same for the rest of the
course: is a binding written explicitly in the source, established at the use site the
compiler looks at, or carried only inside the value at run time?
This topic advances this question in four steps. This lesson measures why an error is a value and how it satisfies the interface; the next lesson measures where an error’s identity stands, the third measures how context added to an error is preserved through a chain, and the fourth measures where the binding of an exit path never visible in the signature — panic — is established (or not).
Three Types, Zero Declarations
The previous topic measured implicit satisfaction on a single type in the Interface Concept
lesson; this lesson applies the same mechanism to error values. The three types below were
written independently of each other: one a value-receiver struct, one a pointer-receiver
struct, one a named type built on an integer. What all three have in common is a single line:
a method matching the Error() string signature.
Choosing three types in deliberately different forms is not arbitrary. The error interface
is used so widely in the standard library that almost every type shape — value, pointer,
named base type, even an empty struct — becomes able to satisfy it. The measurement’s goal is
to show that this diversity does not affect satisfaction at all: the interface wants a single
method, and does not care which type carries that method or how.
// binding.go — is the error binding established at the use site, or inside the value
package main
import "fmt"
var useSite, value int
func observe(name, location, evidence string) {
switch location {
case "use":
useSite++
default:
value++
}
fmt.Printf("%-46s %-9s %s\n", name, location, evidence)
}
func summary() {
fmt.Printf("binding: %d use site, %d value\n", useSite, value)
}
// measurement.go — an error is a value: three types write Error() string without ever mentioning it, and become errors
package main
import "fmt"
type FormatError struct{ Field string }
func (b FormatError) Error() string { return "invalid format: " + b.Field }
type AccessError struct{ Code int }
func (e *AccessError) Error() string { return fmt.Sprintf("access denied: code %d", e.Code) }
type CounterError int
func (s CounterError) Error() string { return fmt.Sprintf("counter overflow: %d", int(s)) }
func main() {
var h1 error = FormatError{Field: "name"}
var h2 error = &AccessError{Code: 403}
var h3 error = CounterError(9)
observe("three types satisfying the error interface", "use",
"the word 'error' never appears in their source")
observe("error value's dynamic type", "value", fmt.Sprintf("%T, %T, %T", h1, h2, h3))
var a error = FormatError{Field: "name"}
var b error = FormatError{Field: "name"}
observe("equality of two value-receiver errors", "value", fmt.Sprint(a == b))
var c error = &AccessError{Code: 403}
var d error = &AccessError{Code: 403}
observe("equality of two pointer-receiver errors", "value", fmt.Sprint(c == d))
summary()
}
three types satisfying the error interface use the word 'error' never appears in their source error value's dynamic type value main.FormatError, *main.AccessError, main.CounterError equality of two value-receiver errors value true equality of two pointer-receiver errors value false binding: 1 use site, 3 value
EH1. None of the three types ever mentions the word error in its source; FormatError,
AccessError and CounterError only write a method matching the Error() string signature,
and this is enough to make them errors. The binding is established at the line
var h1 error = FormatError{...}, the moment the value is placed into an error
variable — nowhere in the source does it say “this type satisfies the error interface.” The
dynamic type read with %T confirms that all three actually complete satisfaction: the value
type appears as main.FormatError, the pointer type as *main.AccessError, the named type as
main.CounterError.
EH2. The last two lines produce and compare two independent copies from the same
FormatError value, and two independent pointers from the same AccessError value. A
value-receiver error is compared structurally: two separate variables come out equal
because their fields are the same. A pointer-receiver error, on the other hand, is compared by
identity: two separate &AccessError{...} calls produce two separate addresses, and
equality returns false, even when their fields are exactly the same. This difference is not
arbitrary — the next lesson builds a package-level error value’s identity exactly on this
distinction.
These two lines also carry a small warning: the error interface supporting the comparison
operator (==) does not mean two errors are “the same error” — it only means the pair the
interface carries (dynamic type, dynamic value) is equal on both sides. For a value-receiver
error, this reduces to the equality of the fields; if even one field differs, the comparison
returns false. This distinction joins, in the section below, the question of which type’s
method set the same method enters.
Method Set: It Is the Type That Satisfies, Not the Value
The Go Fundamentals course’s Pointers lesson measured that whether a method has a value
receiver or a pointer receiver comes from the receiver’s definition, not from the call
notation — that mechanic is not rebuilt here. But for this lesson’s question, one single
consequence matters: when AccessError‘s Error method is written with a pointer receiver,
which type actually satisfies the error interface — AccessError itself, or only
*AccessError? Asking the compiler for the answer requires borrowing K01’s compiles tool: a
piece of source is written to a temporary package, compiled, and only whether it passed is
read.
This tool only reads whether it passed, it never reads the error text — what is measured is
not “why was it rejected,” it is “was it rejected.” Both pieces of source share the same
AccessError definition and the same empty function body; the only difference between them is
whether the value placed into the error variable carries a . mark. This also shows how
small a distinction interface satisfaction can rest on, at the level of syntax.
// methodset.go — a pointer-receiver method only puts the pointer type into the error interface
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
func compiles(source string) bool {
dir, err := os.MkdirTemp("", "trial")
if err != nil {
return false
}
defer os.RemoveAll(dir)
os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module trial\n\ngo 1.24\n"), 0o644)
os.WriteFile(filepath.Join(dir, "code.go"), []byte(source), 0o644)
cmd := exec.Command("go", "build", "./...")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
return cmd.Run() == nil
}
const types = `
package trial
type AccessError struct{ Code int }
func (e *AccessError) Error() string { return "access denied" }
`
func main() {
valueAssignment := types + "func F() { var _ error = AccessError{Code: 1} }"
pointerAssignment := types + "func F() { var _ error = &AccessError{Code: 1} }"
observe("assigning a value type to an error variable", "use", fmt.Sprint(compiles(valueAssignment)))
observe("assigning a pointer type to an error variable", "use", fmt.Sprint(compiles(pointerAssignment)))
summary()
}
assigning a value type to an error variable use false assigning a pointer type to an error variable use true binding: 2 use site, 0 value
EH3. The AccessError value stops compilation when it tries to be assigned to error;
the *AccessError pointer passes the same line without a problem. Both carry the same
fields, both can have the same Code value — the distinction is not in the data itself, it is
in which type has the Error() string method. When a method is written with a pointer
receiver, that method enters only the pointer type’s method set; the value type’s method
set never contains it.
EH4. This clarifies even further where implicit satisfaction is established: the binding
looks not only at “is there an Error() string method,” but at “does this exact type’s
method set have this method.” The compiler checks this at every var _ error = ... line, at
the use site where the assignment is made — even though a declaration like
AccessError implements error never appears anywhere in the source, the check is real and
produces two different results.
This distinction has a practical consequence: writing a type’s error method with a pointer
receiver means only that type’s pointer form will count as an error. If a function tries to
return the value AccessError{...} directly as error, compilation stops right there; the
function needs to return &AccessError{...}. This choice is not arbitrary — a design that
wants to prevent a large struct from being copied on every return has, at the same time, also
chosen that only that struct’s pointer form enters the error interface.
Limiting Measure: A Full Interface Does Not Look nil
What a path that does not return an error returns is also an error value — usually nil.
But where this nil comes from matters. The validate function below returns a concrete
pointer type (*FieldError), and when the field is filled in, this pointer really is nil.
The problem starts on the line where this concrete value is placed into an error
variable — and this line looks as ordinary as the check function’s return statement.
This example combines two observations measured in the previous two sections into a single
line: FieldError’s Error method has a pointer receiver, so only *FieldError satisfies
error — just like AccessError. And an interface value is a pair carrying the dynamic type
and the dynamic value together — just like the three types measured in the first section. The
difference this time is that the dynamic value itself is nil, and this does not affect the
interface as a whole.
// trap.go — a nil-pointer concrete error is no longer nil once placed into the error interface
package main
import "fmt"
type FieldError struct{ Field string }
func (a *FieldError) Error() string { return "invalid field: " + a.Field }
func validate(field string) *FieldError {
if field == "" {
return &FieldError{Field: "required"}
}
return nil
}
func check(field string) error {
return validate(field)
}
func main() {
concrete := validate("name")
err := check("name")
observe("the concrete pointer itself", "value", fmt.Sprint(concrete == nil))
observe("error interface carrying the same value", "value", fmt.Sprint(err == nil))
observe("error interface's dynamic type", "value", fmt.Sprintf("%T", err))
summary()
}
the concrete pointer itself value true error interface carrying the same value value false error interface's dynamic type value *main.FieldError binding: 0 use site, 3 value
EH5. The call validate("name") really returns a nil pointer — the third line confirms
this. But the moment check returns this pointer in a variable of type error, the
interface value becomes two-part: the dynamic type (*FieldError) and the dynamic value
(nil pointer). The comparison err == nil looks at whether the interface itself is
empty; because the dynamic type is filled in, the interface is counted as full too, even
though the pointer it carries is nil.
EH6. This is the topic’s common measure’s strangest counterpart among error values: eight
of the sixteen bindings were carried inside the value, and this line shows exactly why those
eight are not free. The place the binding breaks is neither compilation nor the moment of the
call — it surfaces only when == nil is asked of the value. The practical consequence is
clear: if a function is going to return error, its signature should use the error
interface directly, not a concrete pointer type; otherwise, a nil return from a function
like validate can turn into a full error in the caller’s hands.
The insidiousness of this trap is not in the error staying hidden, it is quite the
opposite: the check err != nil works perfectly honestly and gives the correct answer — the
interface really is full, because it carries the dynamic type. What is mistaken is the
expectation of the person who wrote the code: assuming that in every case validate returns
nil, check will also return nil. This assumption comes from thinking that the pointer
itself and the interface carrying the pointer are the same thing; but as the third line
shows, the two answer different questions.
The four observations measured throughout this lesson settle into a single frame. The
implicit satisfaction binding is never written in the source, and two different mechanisms
fill its place: the method-set match the compiler checks at an assignment line (use site), and
the dynamic type and dynamic value the interface value carries within itself (value). These
two are not independent of each other — the binding established at the use site determines
what the pair later carried inside the value will carry. The nil trap is born exactly in
this transition: once the match is established, the interface is full from then on, even if
the carried value itself is nil.
Summary
erroris an ordinary single-method interface (Error() string); a type satisfying it is written nowhere in the source, it is established only by the method’s presence.- A value-receiver error is compared structurally (the value is equal if the fields are equal); a pointer-receiver error is compared by identity (the value is not equal if the addresses are not equal).
- The dynamic type read with
%Tshows which concrete type an error value satisfies the interface with; this information stands not in the source, but in the value itself. - Even when a concrete pointer type is
nil, once that pointer is placed into anerrorvariable, the interface value is counted as full — the comparison returnsfalsebecause the dynamic type is carried. - This behavior is the reason functions that will return
errorshould use theerrorinterface directly in their signature, not a concrete type. - The binding is established in two places, in neither as a declaration: method-set matching is the use site the compiler checks at an assignment line, and the pair of dynamic type and dynamic value is the value itself.
Next Step
The two errors measured in this lesson (FormatError, AccessError) were each written
individually and reproduced on every call; neither was shared as a single package-level value.
But when a caller wants to recognize a specific error type — wants to ask “is this exactly
that error?” — it needs to rely on the error’s identity, not its text. The comparison
difference between value-receiver and pointer-receiver errors measured in this lesson
prepares exactly the ground for this question: making an identity reliable requires the
comparison to be identity-based, not structural. Just as this lesson measured that the type
satisfying the error interface never mentions the word error, the next lesson measures
where this identity stands: why can an error value defined once at the package level still be
told apart from another error carrying the same text?
To keep your progress and take notes, Log in
My notes
Log in to take notes.