Skip to content
academia.sh

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; any is this interface’s name in code.
  • When the same value is carried by three separate static types (its own interface, then any, then directly as any), 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 nil values 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close