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.
| Receiver | Useful starting point | What it does not promise |
|---|---|---|
func (v T) Read() | Small values with meaningful copy semantics | Deep copying or immutable referenced data |
func (v *T) Update() | Mutating an existing object or preserving its identity | Exclusive 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:
| Type | Methods in its method set |
|---|---|
T | Methods declared with receiver T |
*T | Methods 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
| Situation | Reasonable design | Why it matters |
|---|---|---|
| A helper needs one concrete value type | Accept the concrete type | The full contract is already known |
| A service needs one storage operation | Define a small consumer-side interface | Implementations need only supply that operation |
| An adapter must update shared state | Use a pointer implementation with an explicit ownership policy | Callers observe the intended object |
| A value is intended to behave as an independent snapshot | Consider value semantics | Check reference-containing fields before promising independence |
| A dependency is optional | Define an explicit absence policy | An 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:
- What is the concrete type, and is the receiver a value or pointer?
- Is a direct call using address-taking shorthand?
- Which type is actually assigned to the interface:
Tor*T? - Does an apparent nil retain a dynamic type?
- 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.