Chapter 20

Interface Internals: iface, eface and Type Assertion

Interface Internals: iface, eface and Type Assertion

There is a saying in the Go community: "If you don't understand interface internals, you don't truly understand Go." That's a slight exaggeration — but it's not without merit. Go's interface is the central mechanism of the entire type system. Nearly every standard library API, middleware design, and dependency injection framework is built on top of it. Yet precisely because interfaces are so easy to use, thousands of Go engineers hit the classic nil interface trap in production, write inefficient interface boxing code, or carry wrong intuitions about the cost of type assertions.

The goal of this chapter is to open the interface black box completely. We want to know not just what an interface can do, but what it looks like in memory, how method dispatch works, how type assertions operate, and how the itab cache functions. Only with this understanding can you make genuinely informed trade-offs when writing code.

Level 1: What You Need to Know

What Problem Interfaces Solve

In the world of statically typed languages, there are two mainstream approaches to polymorphism:

Nominal Typing: Used by Java, C#, and similar languages. A type must explicitly declare that it implements an interface (implements SomeInterface) for the compiler to recognize the relationship. The advantage is clarity of intent and stronger compiler checks. The disadvantage is tight coupling between interface and implementation — you must decide at authorship time which interfaces a class implements, and you cannot retroactively add interface relationships.

Structural Typing (Duck Typing): Go's approach. A type automatically satisfies an interface if it possesses all the methods the interface requires, with no explicit declaration needed. This is the essence of "if it walks like a duck and quacks like a duck, it's a duck."

Go's implicit interface satisfaction has several far-reaching consequences:

  1. Decoupled package dependencies: An implementor does not need to import the package that defines the interface. io.Reader is defined in the io package, and os.File implements it — but the os package does not depend on io. This keeps the dependency graph clean in large systems.

  2. Retroactive adaptation: You can define an interface for an existing third-party type without modifying any third-party code. This is invaluable in testing — define a small interface for a third-party HTTP client, and you can substitute a mock.

  3. Minimal interface principle: The Go standard library is filled with single-method interfaces (io.Reader, io.Writer, fmt.Stringer). Small interfaces are easier to satisfy, easier to compose, and easier to test. This is the philosophy of "the smaller the interface, the more powerful the abstraction" in practice.

Why Interfaces Have a Cost

There is no free lunch. Go's interfaces introduce the following costs alongside their flexibility:

These costs are negligible in most business code, but they can become bottlenecks in hot paths, high-frequency allocation scenarios, or systems with extreme latency sensitivity (such as network packet processing). Understanding the costs tells you when it's worth paying them for design flexibility, and when a more direct implementation is warranted.

Level 2: Principles — iface, eface, and itab

Two Memory Layouts for Interface

Go's interface has two different memory layouts at runtime, depending on whether the interface has methods:

Empty interface interface{} (a.k.a. any):
┌──────────────────────────────────────────┐
│  eface                                   │
│  ┌────────────┬────────────┐             │
│  │  _type     │  data      │             │
│  │ *_type     │ unsafe.Ptr │             │
│  └────────────┴────────────┘             │
│    type metadata   data pointer          │
└──────────────────────────────────────────┘

Non-empty interface interface { Method() }:
┌──────────────────────────────────────────┐
│  iface                                   │
│  ┌────────────┬────────────┐             │
│  │  tab       │  data      │             │
│  │  *itab     │ unsafe.Ptr │             │
│  └────────────┴────────────┘             │
│    interface table    data pointer       │
└──────────────────────────────────────────┘

Both are two machine words (8 bytes each on 64-bit systems), but the meaning of the first word is completely different:

In Go's runtime source (runtime/iface.go, runtime/type.go), these two structures are defined as:

// eface: empty interface
type eface struct {
    _type *_type
    data  unsafe.Pointer
}

// iface: non-empty interface
type iface struct {
    tab  *itab
    data unsafe.Pointer
}

_type: Type Metadata

_type is the foundation descriptor for all types in the Go runtime. It contains the core information about a type:

type _type struct {
    size       uintptr  // size of the type in bytes
    ptrdata    uintptr  // prefix bytes containing pointers (for GC)
    hash       uint32   // type hash (for fast comparison)
    tflag      tflag    // type flags (comparable, etc.)
    align      uint8    // alignment of variables of this type
    fieldAlign uint8    // alignment of struct fields of this type
    kind_      uint8    // kind of type (int, struct, slice, ...)
    equal      func(unsafe.Pointer, unsafe.Pointer) bool
    gcdata     *byte    // GC bitmap
    str        nameOff  // type name (relative offset)
    ptrToThis  typeOff  // pointer to this type (relative offset)
}

Every concrete type ([]int, map[string]int, a struct) has a statically-generated _type instance that is stored in the read-only data segment at compile time.

itab: The Heart of iface

The itab binds a specific combination of "interface type" and "concrete type" together:

type itab struct {
    inter *interfacetype  // interface type descriptor
    _type *_type          // concrete type descriptor
    hash  uint32          // copy of _type.hash (for type assertions)
    _     [4]byte         // padding for alignment
    fun   [1]uintptr      // method pointer array (actual length = interface method count)
}

The fun field is variable-length — though the struct definition shows [1]uintptr, the allocated memory includes function pointers for all methods required by the interface, ordered alphabetically by method name.

An example to make this concrete. Given:

type Stringer interface {
    String() string
}

type MyType struct { value int }
func (m MyType) String() string { return fmt.Sprintf("%d", m.value) }

When MyType is assigned to a Stringer interface, the runtime creates (or retrieves from cache) an itab where fun[0] points to the concrete implementation of MyType.String.

itab for (Stringer, MyType):
┌──────────────┬──────────────┬──────────┬──────┬────────────────────┐
│ inter        │ _type        │ hash     │ pad  │ fun[0]             │
│ *Stringer    │ *MyType      │ 0x...    │ 0000 │ → MyType.String()  │
└──────────────┴──────────────┴──────────┴──────┴────────────────────┘

The itab Cache: A Global Hash Table

Creating an itab has a cost: the runtime must traverse the method set of the concrete type, match it against the interface's required methods, and build the method pointer array. To avoid repeating this work, the Go runtime maintains a global itab cache.

The cache is a hash table keyed by (interface type pointer, concrete type pointer):

// runtime/iface.go
var itabTable = &itabTableType{size: itabInitSize}

type itabTableType struct {
    size    uintptr             // number of buckets (power of 2)
    count   uintptr             // number of stored itabs
    entries [itabInitSize]*itab // hash buckets (open addressing)
}

Lookup flow:

getitab(inter, typ):
  1. Compute hash = inter.hash ^ typ.hash (XOR of the two pointer values)
  2. Search itabTable using open addressing
  3. Found → return existing itab (lock-free read, since itab is immutable)
  4. Not found → lock, create new itab, store in cache, return

Key design insight: once an itab is created it is never modified, so reads require no lock (leveraging Go's memory model guarantees). A lock is taken only briefly when writing a new itab.

Type Assertion Mechanics

The type assertion v, ok := i.(T) operates differently depending on whether i is eface or iface:

eface → concrete type assertion:

i.(T) steps:
1. Load i._type
2. Compare it with T's *_type pointer (or compare hash + full type name)
3. Equal → return i.data cast to *T
4. Not equal → return zero value, ok=false (or panic)

iface → concrete type assertion:

i.(T) steps:
1. Load i.tab._type
2. Compare its hash with T's hash (quick filter)
3. If hash matches, do full type pointer comparison
4. Equal → return i.data cast to *T
5. Not equal → return zero value, ok=false (or panic)

iface → interface type assertion (asserting to another interface):

i.(SomeInterface) steps:
1. Call getitab(SomeInterface, i.tab._type)
2. If nil returned (type does not satisfy interface) → ok=false
3. Otherwise construct new iface{tab: itab, data: i.data} and return

i.(type) — the type switch — causes the compiler to emit comparisons for each case in order.

Level 3: Code Practice

Interface Boxing and Heap Escape

When a concrete type is assigned to an interface variable, if the type's size exceeds one pointer (or escape analysis cannot prove it doesn't escape), Go allocates heap memory to hold the actual data, and the interface's data field points to that heap memory.

package main

import "fmt"

type SmallStruct struct{ x int }
type LargeStruct struct{ a, b, c, d, e [128]byte }

func useInterface(i interface{}) {
    fmt.Println(i)
}

func main() {
    s := SmallStruct{42}
    useInterface(s) // s may or may not escape

    l := LargeStruct{}
    useInterface(l) // l almost certainly escapes to heap
}

Verify with escape analysis:

go build -gcflags='-m -m' main.go 2>&1 | grep -E "escapes|does not escape"
# SmallStruct{...} does not escape   (small struct, may be optimized to stack)
# LargeStruct{...} escapes to heap   (large struct triggers heap allocation)

Special handling for scalar types: For bool and small integers (0-255 for int), the Go runtime pre-allocates a static memory region (staticuint64s) in runtime/iface.go to avoid repeated heap allocations:

// runtime/iface.go
var staticuint64s = [256]uint64{0, 1, 2, ...} // pre-allocated storage for 0-255

This means var i interface{} = 42 allocates no heap memory — i.data points into staticuint64s[42].

nil interface vs nil pointer in interface

This is Go's most classic trap, confusing thousands of engineers every year:

package main

import "fmt"

type MyError struct{ msg string }
func (e *MyError) Error() string { return e.msg }

// Anti-pattern: returning *MyError instead of error
func getError(fail bool) *MyError {
    if fail {
        return &MyError{"something went wrong"}
    }
    return nil
}

// Incorrect call site
func main() {
    var err error = getError(false) // implicit conversion to interface
    if err != nil {
        fmt.Println("got error:", err) // THIS EXECUTES!
    }
}

Why does this happen? Let's examine the memory layout:

getError(false) returns (*MyError)(nil)
                           ↓
err = (error)((*MyError)(nil))
           ↓
iface{
    tab:  itab(error, *MyError)  ← non-nil! points to a valid itab
    data: nil                    ← data pointer is nil
}

err != nil compares the whole iface struct; tab is non-nil → not equal to nil

An interface variable is only equal to nil when both tab and data are nil.

The correct patterns:

// Correct: return the error interface type
func getError(fail bool) error {
    if fail {
        return &MyError{"something went wrong"}
    }
    return nil // this returns a true nil interface
}

// Or check explicitly at the call site
func isNilPointerInInterface(err error) bool {
    if err == nil {
        return true
    }
    v := reflect.ValueOf(err)
    return v.Kind() == reflect.Ptr && v.IsNil()
}

Interface Comparison

Two interface variables are equal if and only if:

  1. Both are nil, or
  2. They have the same dynamic type and their dynamic values are equal (using that type's == operator)
package main

import "fmt"

func main() {
    var a, b interface{}

    // Case 1: both nil
    fmt.Println(a == b) // true

    // Case 2: same type, same value
    a = 42
    b = 42
    fmt.Println(a == b) // true

    // Case 3: same type, different value
    a = 42
    b = 43
    fmt.Println(a == b) // false

    // Case 4: different types
    a = 42        // int
    b = int64(42) // int64
    fmt.Println(a == b) // false (different types)

    // Case 5: non-comparable dynamic type → panic
    a = []int{1, 2, 3}
    b = []int{1, 2, 3}
    // fmt.Println(a == b) // panic: runtime error: comparing uncomparable type []int
}

Comparability check: When you're unsure if the dynamic type is comparable, use reflect or a recover-based wrapper:

func safeEqual(a, b interface{}) (equal bool, comparable bool) {
    defer func() {
        if r := recover(); r != nil {
            comparable = false
        }
    }()
    return a == b, true
}

Type Switch Idioms

Type switch is the most idiomatic way to handle multiple dynamic types:

package main

import "fmt"

type Animal interface{ Sound() string }
type Dog struct{ Name string }
type Cat struct{ Name string }
type Bird struct{ Name string }

func (d Dog) Sound() string  { return "Woof" }
func (c Cat) Sound() string  { return "Meow" }
func (b Bird) Sound() string { return "Tweet" }

// Basic type switch
func describe(a Animal) string {
    switch v := a.(type) {
    case Dog:
        return fmt.Sprintf("Dog named %s says %s", v.Name, v.Sound())
    case Cat:
        return fmt.Sprintf("Cat named %s says %s", v.Name, v.Sound())
    case Bird:
        return fmt.Sprintf("Bird named %s says %s", v.Name, v.Sound())
    default:
        return fmt.Sprintf("Unknown animal: %T", v)
    }
}

// Type switch on interface{} — common in JSON parsing
func parseValue(v interface{}) string {
    switch val := v.(type) {
    case nil:
        return "null"
    case bool:
        return fmt.Sprintf("bool: %t", val)
    case int, int8, int16, int32, int64:
        return fmt.Sprintf("integer: %v", val)
    case float32, float64:
        return fmt.Sprintf("float: %v", val)
    case string:
        return fmt.Sprintf("string: %q", val)
    case []interface{}:
        return fmt.Sprintf("array of %d elements", len(val))
    case map[string]interface{}:
        return fmt.Sprintf("object with %d keys", len(val))
    default:
        return fmt.Sprintf("unknown: %T", val)
    }
}

func main() {
    fmt.Println(describe(Dog{"Rex"}))
    fmt.Println(describe(Cat{"Whiskers"}))
    fmt.Println(parseValue(nil))
    fmt.Println(parseValue(true))
    fmt.Println(parseValue(42))
    fmt.Println(parseValue("hello"))
    fmt.Println(parseValue([]interface{}{1, 2, 3}))
}

Minimal Interfaces, Maximum Composition

The Go standard library shows how to design small interfaces and achieve great capability through composition:

// The core interface family in io
type Reader interface {
    Read(p []byte) (n int, err error)
}
type Writer interface {
    Write(p []byte) (n int, err error)
}
type Closer interface {
    Close() error
}

// Composed interfaces
type ReadWriter interface {
    Reader
    Writer
}
type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}

Apply the same principle in your own code:

// Poor design: fat interface
type UserRepository interface {
    CreateUser(u User) error
    GetUser(id int) (User, error)
    UpdateUser(u User) error
    DeleteUser(id int) error
    ListUsers(page, size int) ([]User, error)
    SearchUsers(query string) ([]User, error)
    CountUsers() (int, error)
}

// Better design: role-based separation
type UserCreator interface {
    CreateUser(u User) error
}
type UserReader interface {
    GetUser(id int) (User, error)
}
type UserLister interface {
    ListUsers(page, size int) ([]User, error)
}

// Compose where necessary
type UserService interface {
    UserCreator
    UserReader
    UserLister
}

Level 4: Advanced Topics and Edge Cases

The itab Cache Lookup Algorithm in Detail

The itab cache uses an open-addressing hash table — an important implementation detail. Understanding it helps you anticipate when interface assignment (which requires a getitab call) will have higher latency.

// Simplified getitab logic (from runtime/iface.go)
func getitab(inter *interfacetype, typ *_type, canfail bool) *itab {
    // Compute hash: XOR of interface type and concrete type hashes
    h := itabHashFunc(inter, typ)

    // Lock-free read path (hot path)
    t := (*itabTableType)(atomic.Loadp(unsafe.Pointer(&itabTable)))
    if m := t.find(inter, typ, h); m != nil {
        if m.fun[0] != 0 {
            return m
        }
        if canfail {
            return nil
        }
        panic(&TypeAssertionError{...})
    }

    // Slow path: create a new itab
    lock(&itabLock)
    // Double-checked locking
    if m := itabTable.find(inter, typ, h); m != nil {
        unlock(&itabLock)
        return m
    }
    // Allocate the itab
    m := (*itab)(persistentalloc(unsafe.Sizeof(itab{})+
        uintptr(len(inter.mhdr)-1)*goarch.PtrSize, 0, &memstats.other_sys))
    m.inter = inter
    m._type = typ
    m.init() // fill the fun array
    itabTable.add(m)
    unlock(&itabLock)
    return m
}

itab.init() uses a two-pointer merge traversal: both the interface method set and the concrete type's method set are sorted alphabetically. The O(m+n) merge fills fun[i] with the address of each matching concrete method. If an interface method is not found in the concrete type, fun[0] is set to 0 (signaling failure for the canfail path).

Dynamic Dispatch vs Static Dispatch: Performance

package bench_test

import "testing"

type Adder interface {
    Add(a, b int) int
}

type ConcreteAdder struct{}

func (c ConcreteAdder) Add(a, b int) int { return a + b }

func directAdd(a, b int) int { return a + b }

var globalResult int

// Direct (static) call
func BenchmarkDirect(b *testing.B) {
    c := ConcreteAdder{}
    var r int
    for i := 0; i < b.N; i++ {
        r = c.Add(i, i+1)
    }
    globalResult = r
}

// Interface call (dynamic dispatch)
func BenchmarkInterface(b *testing.B) {
    var a Adder = ConcreteAdder{}
    var r int
    for i := 0; i < b.N; i++ {
        r = a.Add(i, i+1)
    }
    globalResult = r
}

// Function pointer call
func BenchmarkFuncPtr(b *testing.B) {
    fn := directAdd
    var r int
    for i := 0; i < b.N; i++ {
        r = fn(i, i+1)
    }
    globalResult = r
}

Typical results (Apple M1, Go 1.21):

BenchmarkDirect-8      1000000000    0.31 ns/op
BenchmarkInterface-8    500000000    2.4  ns/op   ← ~8x slower
BenchmarkFuncPtr-8      800000000    1.5  ns/op

Sources of the gap:

  1. Indirect jump: The CPU branch predictor has a low success rate for indirect jumps, causing pipeline flushes (~15-20 cycle penalty).
  2. Cache misses: The function pointer in the itab, and the body of the called function, may not be in L1 cache.
  3. Inlining blocked: Go's compiler (through 1.21) cannot inline methods called through an interface, while a direct call to a simple method is inlined. The 0.31ns for BenchmarkDirect reflects the benefit of inlining.

Accessing itab with go:linkname (For Learning Only)

In extreme scenarios (building framework tooling), go:linkname can be used to access the runtime's internal itab structures. This is a private API with no stability guarantees across versions — use it only to understand the internals:

//go:build ignore

package main

import (
    "fmt"
    "unsafe"
    _ "unsafe" // required for go:linkname
)

// Mirror of the runtime iface structure
type iface struct {
    tab  uintptr
    data unsafe.Pointer
}

type itabMirror struct {
    inter uintptr
    typ   uintptr
    hash  uint32
    _     [4]byte
    fun   [1]uintptr
}

func getItab(i interface{ String() string }) *itabMirror {
    f := (*iface)(unsafe.Pointer(&i))
    return (*itabMirror)(unsafe.Pointer(f.tab))
}

type MyStr struct{}
func (m MyStr) String() string { return "hello" }

func main() {
    var s interface{ String() string } = MyStr{}
    tab := getItab(s)
    fmt.Printf("itab.hash   = 0x%x\n", tab.hash)
    fmt.Printf("method addr = 0x%x\n", tab.fun[0])
}

Assembly-Level View of Method Dispatch

The most direct way to understand method dispatch is to read the assembly. For:

type Greeter interface { Greet() string }
type English struct{}
func (e English) Greet() string { return "Hello" }

func callGreet(g Greeter) string { return g.Greet() }

Key assembly for callGreet (AMD64, simplified):

MOVQ  8(AX), CX      // load iface.data (second word)
MOVQ  (AX), AX       // load iface.tab (first word = *itab)
MOVQ  24(AX), AX     // load itab.fun[0] (offset 24 = inter + _type + hash + pad)
CALL  AX             // indirect call

This is the essence of dynamic dispatch: one extra memory load (MOVQ 24(AX), AX) and one indirect call (CALL AX).

Performance Optimization Strategies

  1. Avoid interface boxing in hot paths: If a function is called millions of times per second in an inner loop, consider providing concrete-type variants for common types:
// Inefficient: dynamic dispatch overhead on every call
func processAll(items []Processor) {
    for _, item := range items {
        item.Process()
    }
}

// Optimized: fast path for the most common concrete type
func processAll(items []Processor) {
    for _, item := range items {
        if concrete, ok := item.(*FastProcessor); ok {
            concrete.processDirect() // inlineable
        } else {
            item.Process() // dynamic dispatch fallback
        }
    }
}
  1. Reuse interface variables to avoid repeated boxing:
// Inefficient: boxing on every iteration
for _, v := range values {
    var i interface{} = v // may allocate heap memory each time
    process(i)
}

// Optimized: reuse the interface variable
var i interface{}
for _, v := range values {
    i = v // reuse, reduces allocations
    process(i)
}
  1. Use sync.Pool to reduce GC pressure from interface boxing: For scenarios with frequent boxing of large structs:
var pool = sync.Pool{
    New: func() interface{} { return new(LargeStruct) },
}

func processWithPool(data []byte) {
    ls := pool.Get().(*LargeStruct)
    defer pool.Put(ls)
    ls.populate(data)
    var i interface{} = ls
    process(i)
}

Summary: When to Use (and Not Use) Interfaces

Use interfaces when:

Avoid interfaces when:

Understanding the memory layout of iface and eface, the itab cache, and the cost of dynamic dispatch gives you the grounding to make genuinely reasoned architectural decisions in Go — rather than relying on intuition or blindly following "best practices."

Rate this chapter
4.8  / 5  (11 ratings)

💬 Comments