Go Under the Hood · Part 7

Methods, Interfaces, and Typed Nil in Go

Understand Go method sets, value and pointer receivers, interface comparisons, and typed nil through runnable examples and practical API design decisions.

A method call compiles, but assigning the same value to an interface does not. A function returns a nil pointer, yet its caller sees err != nil. Both behaviours follow consistent rules; the confusing part is deciding which rule applies at each boundary.

Method-call convenience, interface implementation, and interface nilness are separate questions. Keeping them separate makes receiver choices and error handling easier to reason about.

This is Part 7 of Go Under the Hood, following Struct Layout and Memory Alignment. You should recognise structs, pointers, functions, and basic interfaces. The examples use only the standard library and were verified with Go 1.27.1 on macOS ARM64. No allocation or performance claims depend on interface internals.

A receiver is part of the method’s behaviour

A value receiver receives a copy of its value. A pointer receiver receives a copied pointer that can reach the original object. This follows the value semantics covered in Part 2.

ReceiverUseful starting pointWhat it does not promise
func (v T) Read()Small values with meaningful copy semanticsDeep copying or immutable referenced data
func (v *T) Update()Mutating an existing object or preserving its identityExclusive ownership or automatic concurrency safety

A value receiver containing a slice can still change shared elements. A pointer receiver that merely reassigns its local pointer does not replace the caller’s variable. Choose the semantics first; using a pointer is not proof of better performance. Effective Go: pointers and values

For types containing a used sync.Mutex, copying is prohibited by the mutex contract. A pointer receiver helps avoid copying during method calls, but does not stop a caller from copying the struct elsewhere. sync.Mutex

Experiment 1: a callable method does not imply interface implementation

Create a module with a Go 1.27 toolchain:

go version
mkdir interface-demo
cd interface-demo
go mod init example.com/interface-demo
go mod edit -go=1.27.0

Save this as main.go:

package main

import "fmt"

type Counter struct {
	N int
}

func (c Counter) Value() int {
	return c.N
}

func (c *Counter) Increment() {
	c.N++
}

type Reader interface {
	Value() int
}

type Incrementer interface {
	Increment()
}

var _ Reader = Counter{}
var _ Reader = (*Counter)(nil)
var _ Incrementer = (*Counter)(nil)

func main() {
	c := Counter{N: 1}
	c.Increment()

	var snapshot Reader = c
	var live Reader = &c
	var updater Incrementer = &c
	updater.Increment()

	fmt.Println("counter:", c.N)
	fmt.Println("readers:", snapshot.Value(), live.Value())
}

Run each complete program with:

go run .
go vet ./...

Expected output:

counter: 3
readers: 2 3

The first increment works because c is addressable: the call can use &c. The interface assignments then retain either a counter value or a pointer to the original counter. Only the pointer-backed reader sees the subsequent increment.

For a defined, non-interface type T without embedded fields, the relevant method sets are:

TypeMethods in its method set
TMethods declared with receiver T
*TMethods declared with receiver T or *T

An addressable value can use pointer-method call shorthand, but this does not add pointer methods to T’s method set. Embedded fields introduce additional promotion rules beyond this example. Go specification: method sets and calls

Change var updater Incrementer = &c to var updater Incrementer = c. Compilation fails: Counter does not implement Incrementer, because Increment has a pointer receiver. Restore the address before continuing.

The var _ declarations are compile-time implementation checks. They do not call methods or demonstrate that a nil receiver is safe. They are useful for adapters that intentionally promise a particular interface.

Choose an interface around the consumer’s requirement

An interface describes a capability. In this example, Reader needs only Value; callers accepting it do not need to know how a counter stores its state.

Go’s code-review guidance recommends defining interfaces where they are consumed, and avoiding interfaces created solely in anticipation of implementations that do not yet exist. Keep the required method set focused. Go Code Review Comments: interfaces

SituationReasonable designWhy it matters
A helper needs one concrete value typeAccept the concrete typeThe full contract is already known
A service needs one storage operationDefine a small consumer-side interfaceImplementations need only supply that operation
An adapter must update shared stateUse a pointer implementation with an explicit ownership policyCallers observe the intended object
A value is intended to behave as an independent snapshotConsider value semanticsCheck reference-containing fields before promising independence
A dependency is optionalDefine an explicit absence policyAn interface nil check alone cannot reject every typed nil

For a proposed order service, accepting only the lookup operation it uses can make a small test implementation possible. It does not require publishing a large interface that mirrors every method on the database adapter.

Changing receiver types can also change which interfaces a public type implements. Treat that as an API change, not merely a formatting choice.

Experiment 2: the nil pointer inside a non-nil error

Replace main.go with:

package main

import "fmt"

type LookupError struct {
	Key string
}

func (e *LookupError) Error() string {
	if e == nil {
		return "nil LookupError receiver"
	}
	return "missing key: " + e.Key
}

func brokenLookup() error {
	var e *LookupError
	return e
}

func lookup(found bool) error {
	if !found {
		return &LookupError{Key: "tea"}
	}
	return nil
}

func main() {
	var p *LookupError
	var err error = p
	fmt.Println("pointer nil:", p == nil)
	fmt.Println("interface nil:", err == nil)
	fmt.Printf("dynamic type: %T\n", err)
	fmt.Println("method:", err.Error())
	fmt.Println("broken success:", brokenLookup() == nil)
	fmt.Println("explicit success:", lookup(true) == nil)
	fmt.Println("failure:", lookup(false))
}

Expected output:

pointer nil: true
interface nil: false
dynamic type: *main.LookupError
method: nil LookupError receiver
broken success: false
explicit success: true
failure: missing key: tea

A useful semantic model for an interface value is its dynamic type and dynamic value. A nil interface has neither. Assigning p supplies the type *LookupError even though the pointer value is nil. Therefore err is not a nil interface. This model explains behaviour; it is not a promise about a runtime memory layout. Go FAQ: nil errors

var err error          → no dynamic type, no dynamic value → err == nil
err = (*LookupError)(nil)
                       → type *LookupError, value nil     → err != nil

The broken function accidentally reports an error on its success path. The corrected function returns the nil interface explicitly when successful and constructs an error only for failure.

That distinction can affect retries, error metrics, or HTTP responses. A caller following the ordinary if err != nil pattern is behaving correctly; fix the return boundary rather than teaching every caller to inspect private error representations.

Nil receivers need a deliberate contract

The example’s Error method explicitly handles a nil receiver, so err.Error() completes. Remove the if e == nil guard and the call panics when it accesses e.Key. A non-nil interface does not imply a usable non-nil object.

Do not generalise the successful call to all methods. A value-receiver method called through a nil *T requires a value that cannot be obtained from that pointer. A pointer-receiver method can inspect nil before dereferencing, but only if its implementation chooses to do so. Effective Go: pointers and values

For required dependencies, reject absence at construction where the concrete value is known. For optional behaviour, a documented no-op implementation can make the call path explicit. Do not automatically make every method tolerate nil: that can hide an invalid setup that should have failed early.

Likewise, reflection-based “is anything nil?” helpers can obscure the contract. They are unnecessary for the error-return bug above. Keep absence meaningful at the API boundary.

Experiment 3: interface equality also depends on dynamic types

Replace main.go with:

package main

import "fmt"

func main() {
	var a any = int(7)
	var b any = int64(7)
	fmt.Println("numeric values:", a == b)

	var p *int
	var q *string
	var x any = p
	var y any = q
	fmt.Println("typed nils:", x == nil, x == y)
	fmt.Println("same typed nil:", x == any((*int)(nil)))

	var items any = []int{1, 2}
	fmt.Println("slice interface nil:", items == nil)
}

Expected output:

numeric values: false
typed nils: false false
same typed nil: true
slice interface nil: false

Equal-looking numbers with different dynamic types compare unequal. Two interfaces with the same dynamic type compare their dynamic values; if that type is not comparable, comparison panics. Comparing an interface to nil does not require comparing two underlying slice values. Go specification: comparison operators

Add fmt.Println(items == items) at the end. It compiles but panics because the dynamic type is []int. Restore the program afterwards. This is related to the interface-key trap in Part 5: an interface can hold more types than a particular operation supports.

For a domain API, prefer a concrete identifier or an explicit equality operation when that is what the caller needs. Wrapping values in any does not create universal equality, numeric conversion, or a deep comparison.

A practical review checklist

When a method or interface behaves unexpectedly, trace these questions in order:

  1. What is the concrete type, and is the receiver a value or pointer?
  2. Is a direct call using address-taking shorthand?
  3. Which type is actually assigned to the interface: T or *T?
  4. Does an apparent nil retain a dynamic type?
  5. Does the operation require a non-nil receiver or a comparable dynamic value?

These checks separate compilation rules from runtime behaviour. They also provide useful test cases: an ordinary value, a pointer, the nil interface, and a typed nil need not behave alike.

Exercise: predict the boundary change

In Experiment 1, change var snapshot Reader = c to var snapshot Reader = &c. Both readers now print 3, because both reach the original counter.

In Experiment 2, replace the body of brokenLookup with return nil. Its success check becomes true. Then remove the nil guard from Error and confirm the documented panic; restore the guard before moving on.

In Experiment 3, change int64(7) to int(7). The numeric comparison becomes true. Explain why this changes equality even though the printed numeric values were already the same.

The next planned part, Closures, Defer, and Panic, examines captured values, evaluation order, cleanup, and recovery boundaries.

Sources

Back to the journal