Lesson 02 / 13
The Empty Interface and `any`
The empty interface wants zero methods and every type satisfies zero methods: which type will arrive is never written in the source, and type information is carried inside the value. When the same value is carried by three separate static types, the dynamic type does not change; the cost is that anything placed in the value turns into a pair and cannot be called directly. Limiting measurement: when a nil pointer is placed in the empty interface, the interface itself is not nil.
Published Updated
In this lesson
- Measures that the empty interface wants zero methods and is therefore satisfied by every type from every side
- Runs proof that the dynamic type does not change when the same value is carried by three separate static types
- Proves to the compiler that a value placed in the empty interface cannot be called directly and requires a type assertion
- Measures that when a nil pointer is placed in the empty interface the interface itself is not nil, and ties this back to the Go Fundamentals course's zero-values lesson
Contents
The previous lesson read an interface value’s dynamic type once — main.File, with %T —
and did not dwell on it. The empty interface is exactly that line grown large: an interface
wanting no method at all, defined not by a count of members but by the number zero. Since the
source never writes which type will be placed in this interface, someone somewhere has to
carry the placed type’s information; the previous lesson’s binding split already named that
place — value. This lesson grows that line.
Interfaces like the previous lesson’s Writer and Closer wanted one method each; Embedded
wanted both together. The empty interface stands at the far end of that scale: it wants no
method at all. The relationship between how little an interface asks for and how many types
can satisfy it was measured with a count in the previous lesson; this lesson focuses on that
curve’s zero point and measures what is gained and lost there.
An Interface Wanting Zero Methods
In the previous lesson, as method count climbed from one to two to three, the number of the
same four types able to satisfy it fell from three to two to one. The empty interface is the
zero end of that same curve: interface{} wants no method at all, and a condition with no
methods is satisfied by every type — an empty set is a subset of every set. The language’s own
type name is any, an alias for interface{}; this lesson calls the concept the empty
interface, its name in code any.
Because the empty interface wants no method, the source places no limit at all on which type
can go into it: var v any = 42, var v any = "text", var v any = Unit{} all three compile,
because all three have zero methods to satisfy. But an interface wanting zero methods does not
mean the placed value keeps no information at all. Type information is not lost, only its
visibility changes — it stops being a type name readable from the source and turns into a
piece of information that has to be asked for at runtime.
Every fmt.Println call written throughout this lesson is already using this same mechanism:
the standard library’s function takes its parameters as ...any. Whether given an integer, a
string, or a struct, each is placed into the empty interface at the moment of the call and read
back inside Println with a query similar to %T. The empty interface is therefore not a
rare edge case — every output line in this lesson has already passed through it.
The Same Value, Three Static Types
A value’s dynamic type is independent of which static type carries that value. The following
measurement passes the same Unit value through three separate static types — its own
interface, then the empty interface, then the empty interface directly — reading the dynamic
type each time with %T.
// same-value.go — the same value carried through three static types, the dynamic type does not change
package main
import "fmt"
type Unit struct{ Name string }
func (u Unit) Identity() string { return u.Name }
type Identifier interface{ Identity() string }
func main() {
u := Unit{Name: "unit-42"}
var t1 Identifier = u
var t2 any = t1
var t3 any = u
fmt.Println("as Identifier :", fmt.Sprintf("%T", t1))
fmt.Println("after moving to any :", fmt.Sprintf("%T", t2))
fmt.Println("directly as any :", fmt.Sprintf("%T", t3))
}
as Identifier : main.Unit after moving to any : main.Unit directly as any : main.Unit
Three variables carry three separate static types — Identifier, then any, then any again
but arriving by a different path — and all three give the same answer: main.Unit. The move
from t1 to t2 is not a conversion, it is the same pair (type, value) binding to a new
variable once more; type information is not lost because there was never anything to lose at
any point — the interface value already carried this pair from the very start. Identifier‘s
single method and the empty interface’s zero methods both display the same underlying
structure; only the visible method count differs.
IN4. t3 any = u was written instead of t3 := u in order to also show a path where
Unit is placed directly into the empty interface — a third line was deliberately added to
tell apart the t1 and t2 chain, which passes through Identifier, from t3, which does
not.
The Same Container, Four Separate Dynamic Types
The previous measurement passed a single value through three separate static types and showed
the dynamic type staying fixed. This measurement does the reverse: it places four separate
dynamic types into a single static type ([]any) and shows each keeping its own identity.
// mixed.go — the same any slice carries four different dynamic types side by side
package main
import "fmt"
type Unit struct{ Name string }
func main() {
values := []any{42, "text", Unit{Name: "x"}, 3.14}
for _, v := range values {
fmt.Printf("value=%v dynamic type=%T\n", v, v)
}
fmt.Println("slice's static type: []any, length:", len(values))
}
value=42 dynamic type=int
value=text dynamic type=string
value={x} dynamic type=main.Unit
value=3.14 dynamic type=float64
slice's static type: []any, length: 4
IN5. The array was deliberately chosen from four separate basic categories — an integer, a string, a struct, a floating-point number — so that the demonstration is visible as the empty interface’s general rule and not a behavior specific to a single type family (numeric types alone, for instance).
The slice itself carries a single static type: []any. But its four elements carry four
separate dynamic types — int, string, main.Unit, float64 — and the range loop never
mixes any of them up, because each element brings its own pair along with it. This mirrors the
previous measurement: there the same type stayed fixed across three static forms, here the
same static form carries four separate types. Together the two make clear where type
information really sits — not in the static type, but in the pair each value carries on its
own.
A heterogeneous container like this — holding values of different types in a single slice — could only be built with the empty interface before generic data structures written with type parameters existed. Where the two ways of writing this diverge will be measured in the course’s generics section; what matters here is only how the empty interface pulls this off: by storing each element together with its own pair.
The Cost: Being a Pair, Not Callable Directly
Carrying type information inside the value comes with a cost: a value placed in the empty
interface cannot be called directly through its own methods. Since any wants zero
methods, it offers zero methods — no method is defined on it, even if the placed value has
methods of its own. Calling it first requires returning to the real type (or an interface the
real type satisfies) with a type assertion.
This is the symmetric counterpart of the previous lesson’s small-interface measurement. There,
as method count grew, the number of types able to satisfy it fell; here, when method count
drops to zero, access also drops to zero. The empty interface accepts every type because it
wants nothing, and because it wants nothing it also offers nothing — acceptance breadth and
direct access are inversely related. The compiler makes no guess here: as long as v’s static
type is any, it has no way of knowing which methods exist on v, because that information
can only be learned at runtime by looking at the value itself.
// compiler.go — tests from inside the program whether a piece of source compiles
package main
import (
"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
}
This block is only written to disk; the real measurement, producing two pieces of source and calling the compiler, follows below.
// cost.go — a direct call over any is rejected, a call after a type assertion is valid
package main
import "fmt"
func main() {
common := `package trial
type Unit struct{ Name string }
func (u Unit) Identity() string { return u.Name }
type Identifier interface{ Identity() string }
func F() string {
var v any = Unit{Name: "x"}
`
directCall := common + ` return v.Identity()
}
`
assertedCall := common + ` return v.(Identifier).Identity()
}
`
fmt.Println("direct call, over any :", compiles(directCall))
fmt.Println("call after a type assertion :", compiles(assertedCall))
}
direct call, over any : false call after a type assertion : true
v.Identity() does not compile, because v’s static type is any and any’s method set is
empty; the compiler does not even search for a method named Identity on any, because there
is no method defined there to search for. v.(Identifier).Identity() compiles, because the
type assertion first converts the value to Identifier, and a call can be made through
Identifier‘s (and therefore Identity‘s) method set. The cost is this: any accepts a
value from every type, but in exchange it closes off direct access to that value’s own
behavior. The type assertion itself is the next lesson’s topic; here only its necessity is
measured.
This rejection is not a gap, it is a deliberate boundary. When the compiler looks at the line
v.Identity(), all it has is v’s static type (any); which concrete value will sit there is
not yet known at compile time, it is determined only the first time it runs. The compiler does
not fill this gap by guessing “Identity is probably there” — such a guess would only blow up
at runtime, possibly in production, if it turned out wrong. Instead, the compiler never steps
outside the method set the static type offers; since that set is empty for the empty
interface, the call stops right there. The type assertion is the only way to close this gap
explicitly.
Limiting Measurement: An Interface Carrying a Nil Pointer Is Not Nil
The strangest line involving the empty interface shows that carrying type information does not
come free. A pointer can be nil; when that same pointer is placed into the empty interface,
the interface itself does not become nil.
// nil-interface.go — even if the pointer is nil, the interface carrying it is not nil
package main
import "fmt"
type Server struct{}
func main() {
var s *Server
var v1 any = s
var v2 any
fmt.Println("is the pointer itself nil :", s == nil)
fmt.Println("is v1 carrying the pointer nil:", v1 == nil)
fmt.Println("is v2, given no value at all, nil:", v2 == nil)
}
is the pointer itself nil : true is v1 carrying the pointer nil: false is v2, given no value at all, nil: true
The three lines were placed side by side deliberately. s really is nil — it points to no
Server instance. v1 carries this nil pointer, but v1 == nil gives false: the
interface value is a pair, and this pair’s type slot is filled — *main.Server sits there —
only the value slot is nil. The pair only counts as nil when both of its slots are
empty; v2 never received any value, so both its type and value slots stay empty, and it
really is nil.
The type slot of the pair carrying genuinely separate information becomes even clearer when two different pointer types are placed side by side.
// two-nils.go — the nil values of two separate pointer types are not counted equal
package main
import "fmt"
type A struct{}
type B struct{}
func main() {
var a *A
var b *B
var v1 any = a
var v2 any = b
fmt.Println("is v1 nil :", v1 == nil)
fmt.Println("is v2 nil :", v2 == nil)
fmt.Println("is v1 equal to v2 :", v1 == v2)
fmt.Printf("v1 dynamic type=%T, v2 dynamic type=%T\n", v1, v2)
}
is v1 nil : false is v2 nil : false is v1 equal to v2 : false v1 dynamic type=*main.A, v2 dynamic type=*main.B
a and b are both nil pointers and both, on the surface, point to a “empty” struct with no
fields. But v1 and v2 are not equal to each other, because an interface comparison first
compares the type slot, and *main.A and *main.B are not the same type. Even though both
value slots are nil, the two are not counted equal as pairs because their type slots differ.
This is one more proof that the type slot carries real information independent of the value
slot — not just “is it empty or not,” it shows the “which type” question also enters the
comparison.
IN6. This is the direct consequence of the previous lesson’s first claim: if eight of the
bindings are carried inside the value, a value being empty does not mean the pair is empty.
The Go Fundamentals course’s Zero Values lesson measured that nil can be appended to a slice
but cannot be written to a nil map; this line is a third example from the same family — the
word nil stays the same, the meaning it carries changes from type to type.
The practical consequence of this line is this: if a function returns error (itself an
interface) and internally uses a concrete pointer type, the returned error value may not be
nil even if that pointer is nil. The caller asking err == nil gets false, because what
it is asking about is not the pointer itself but the pair carrying it. This trap’s full
measurement will be done in the lesson devoted to error values; here only its origin is shown.
There is a reason for seeing this origin so early: an error is itself an interface, and
everything that is an interface is bound to the same pair rule. error is not the empty
interface — it wants the Error() string method — but what it shares with the empty interface
is exactly this lesson’s subject: both are a pair carrying a type slot and a value slot. In the
empty interface this pair is easy to observe directly, because no method stands in the way; in
the error interface the same pair hides behind a method signature, but its behavior does not
change.
Summary
- The empty interface wants zero methods and every type satisfies zero methods;
anyis this interface’s name in code. - When the same value is carried by three separate static types (its own interface, then
any, then directly asany), the dynamic type does not change — type information is not lost, only its visibility changes. - A value placed in the empty interface cannot be called directly; since
any’s method set is empty, a call first requires a type assertion. - When a nil pointer is placed in the empty interface, the interface itself is not nil — the pair is only counted nil when both its type and value slots are empty together.
- The
nilvalues of two separate pointer types are not counted equal when placed in the empty interface; the comparison looks at the type slot before the value slot.
Next Step
In this lesson, type information was accessed only with %T, for reading purposes alone — no
line ever recovered v‘s real value, only read which type it was. But the question a program
actually needs to ask is usually not “what is this value’s type“ but “does this value satisfy
this type,” and if it does, “give me the real value.” This was already seen once on the way
out of the empty interface: v.(Identifier) recovered the value but without checking the
answer. The next lesson measures these two questions separately: a type assertion’s two-value
form and the branch a type switch selects — two separate ways of asking the value.
To keep your progress and take notes, Log in
My notes
Log in to take notes.