Skip to content
academia.sh

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

  • error is 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 %T shows 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 an error variable, the interface value is counted as full — the comparison returns false because the dynamic type is carried.
  • This behavior is the reason functions that will return error should use the error interface 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close