Skip to content

Sealed Interfaces — Optimize

This file focuses on performance and cleaner code concerns related to sealed interfaces — interfaces with a closed, package-private set of implementations (typically enforced through an unexported marker method).

The reference type used throughout is an algebraic data type (ADT) for arithmetic expressions:

package expr

type Expr interface {
    isExpr()       // unexported marker — seals the interface
    Eval() float64
}

type Num struct{ V float64 }
type Add struct{ L, R Expr }
type Mul struct{ L, R Expr }
type Neg struct{ X Expr }

func (Num) isExpr() {}
func (Add) isExpr() {}
func (Mul) isExpr() {}
func (Neg) isExpr() {}

A second running example is a JSON value ADT:

type JSON interface{ isJSON() }

type JNull   struct{}
type JBool   struct{ V bool }
type JNum    struct{ V float64 }
type JStr    struct{ V string }
type JArr    struct{ V []JSON }
type JObj    struct{ V map[string]JSON }

func (JNull) isJSON() {}
func (JBool) isJSON() {}
func (JNum)  isJSON() {}
func (JStr)  isJSON() {}
func (JArr)  isJSON() {}
func (JObj)  isJSON() {}

1. Type-switch dispatch perf — the jump-table threshold

The Go compiler lowers a switch x.(type) over interface values into one of two shapes:

  1. Linear chain of comparisons — each case checks the itab pointer in sequence.
  2. Hashed jump table — once the case count crosses an internal threshold (around 6 type cases since Go 1.19+), the compiler emits a hash on the itab pointer and dispatches in O(1).
func Eval(e Expr) float64 {
    switch x := e.(type) {
    case Num: return x.V
    case Add: return Eval(x.L) + Eval(x.R)
    case Mul: return Eval(x.L) * Eval(x.R)
    case Neg: return -Eval(x.X)
    }
    panic("sealed: unreachable")
}

With four cases this stays in linear form. Above six cases the hashed table kicks in. The key takeaway: a sealed interface with 5–6 variants is the sweet spot — small enough for linear scan, large enough that you do not pay table setup overhead.

For 10+ variants, sealing actually helps the compiler: the closed set lets the type-switch be exhaustive, and the hash table delivers stable per-case latency regardless of input distribution.


2. Inline budget for sealed type-switch

Each Go function has an inline budget (default 80 nodes since Go 1.20). A type-switch consumes nodes per case, and each case body adds more.

// Likely inlined — small bodies, 4 cases
func depth(e Expr) int {
    switch x := e.(type) {
    case Num: return 0
    case Neg: return 1 + depth(x.X)
    case Add: return 1 + max(depth(x.L), depth(x.R))
    case Mul: return 1 + max(depth(x.L), depth(x.R))
    }
    return 0
}

Inspect with:

go build -gcflags='-m=2' ./expr
# can inline depth with cost 78 as: ...

If the type-switch is hot and inlining matters, split large case bodies into named helpers:

func Eval(e Expr) float64 {
    switch x := e.(type) {
    case Num: return x.V
    case Add: return evalAdd(x)   // body extracted
    case Mul: return evalMul(x)
    case Neg: return -Eval(x.X)
    }
    panic("unreachable")
}

The dispatch shell stays small enough to inline at the call site, while the cold paths stay out of the budget.


3. Codegen alternatives — stringer-style generators

Sealed interfaces are a perfect target for code generation, the same pattern stringer uses for enums. A generator can emit:

  • An exhaustive Kind() method
  • A typed Visit dispatcher with no default branch
  • A MarshalJSON / UnmarshalJSON aware of all variants
//go:generate go run ./cmd/sealed -type=Expr -out=expr_sealed.go

Generated output (sketch):

// Code generated by sealed; DO NOT EDIT.

func KindOf(e Expr) Kind {
    switch e.(type) {
    case Num: return KindNum
    case Add: return KindAdd
    case Mul: return KindMul
    case Neg: return KindNeg
    }
    panic("sealed: unknown variant")
}

Why generate?

  1. Single source of truth — adding a new variant in one place makes the build fail at every dispatch site that forgot it (with -fail-on-missing in the lint step).
  2. No reflection — generated code is plain switch and benefits from the same inlining and devirtualization as hand-written code.
  3. Cheap to regenerate — re-run go generate ./... in CI; diff is reviewable.

Compare with reflection-based dispatch, which costs ~100 ns/op per reflect.TypeOf and never inlines.


4. Devirtualization opportunities for sealed types

When the compiler can prove the dynamic type of an interface value, it can replace the iface call with a static method call — devirtualization. Sealed interfaces give the compiler stronger signals.

// Open interface — escapes analysis sees "any implementor"
func sumOpen(e Expr) float64 {
    return e.Eval() + 1
}

// After type-switch — the compiler knows the concrete type inside each arm
func sumSealed(e Expr) float64 {
    switch x := e.(type) {
    case Num: return x.Eval() + 1   // devirtualized to Num.Eval
    case Add: return x.Eval() + 1   // devirtualized to Add.Eval
    case Mul: return x.Eval() + 1
    case Neg: return x.Eval() + 1
    }
    return 0
}

Inside each case arm, x has a concrete type and x.Eval() is a direct call — inlinable, no itab lookup. With Go 1.21+ partial devirtualization extends this to some non-switch contexts when the call site sees a small known set of implementors, which is exactly what a sealed interface gives.

Verify with:

go build -gcflags='-m=2' ./expr 2>&1 | grep -i devirt
# devirtualizing x.Eval to Num

5. Method-set hashing impact when sealing

Each interface type has an itab (interface table) per concrete type that implements it. The runtime caches itabs in a global hash map keyed by (interface, concrete).

Sealing reduces itab cardinality:

Interface Implementors Itabs allocated
io.Reader (open) hundreds hundreds
Expr (sealed, 4 variants) 4 4

Fewer itabs means:

  • Smaller itab map — faster cold lookups on first conversion.
  • Better instruction cache locality — the hot itabs stay resident.
  • Predictable memory footprint — important for embedded or short-lived processes.

The marker method (isExpr()) costs nothing at runtime — it is never called, only used by the type system to enforce the closed set.


6. Interface comparison cost

Interface values are comparable if their dynamic type is comparable. For a sealed interface where every variant is a small comparable struct, equality is fast and predictable:

var a, b Expr = Num{V: 3}, Num{V: 3}
fmt.Println(a == b) // true

The runtime compares:

  1. The itab pointer (one word, equal for same dynamic type).
  2. The data pointer or inline data (one word).

For pointer-sized scalar variants (Num{float64}, JBool{bool}) this is two-word comparison, ~1 ns. For large variants (Add{L, R Expr}) the runtime falls back to runtime.ifaceeq which calls the type's eq function — slower, but still inlined for plain structs.

If you need a hot-path equality check, prefer comparing the concrete types after a type-switch:

func eqNum(a, b Expr) bool {
    an, aok := a.(Num)
    bn, bok := b.(Num)
    return aok && bok && an.V == bn.V
}

This avoids the iface eq call entirely.


7. Visitor pattern alternative — when better

The visitor pattern in Go looks like:

type Visitor interface {
    VisitNum(Num) float64
    VisitAdd(Add) float64
    VisitMul(Mul) float64
    VisitNeg(Neg) float64
}

type Expr interface {
    Accept(Visitor) float64
    isExpr()
}

func (n Num) Accept(v Visitor) float64 { return v.VisitNum(n) }
func (a Add) Accept(v Visitor) float64 { return v.VisitAdd(a) }
func (m Mul) Accept(v Visitor) float64 { return v.VisitMul(m) }
func (n Neg) Accept(v Visitor) float64 { return v.VisitNeg(n) }

When is this better than a type-switch?

  • Multiple orthogonal traversals — Eval, Pretty, Optimize, Validate. Each is a separate Visitor implementation; adding a new traversal does not touch the variant types.
  • Stable variant set — adding a variant forces every Visitor to add a method. The compiler enforces exhaustiveness for free.

When is the type-switch better?

  • Few traversals, many variants — a single Eval over 12 expression kinds; visitor would force 12 Visit methods on every traversal.
  • Inlining matters — the visitor introduces a virtual call (Accept) plus a direct call (VisitX); a type-switch is one branch, often inlined entirely.
  • Recursive traversalsEval(x.L) + Eval(x.R) reads cleaner than threading a Visitor through recursion.

Rule of thumb: fewer than 4 traversals → type-switch; more than 4 → visitor.


8. Exhaustive lint integration

The biggest practical win of sealing is compile-time exhaustiveness. Tools that help:

go-exhaustruct and exhaustive

golangci-lint ships with the exhaustive linter (originally for enum-like consts). The community fork nishanths/exhaustive supports type-switch on sealed interfaces when annotated:

//exhaustive:enforce
func Eval(e Expr) float64 {
    switch x := e.(type) {
    case Num: return x.V
    case Add: return Eval(x.L) + Eval(x.R)
    // missing Mul, Neg → linter fails the build
    }
    panic("unreachable")
}

Configure in .golangci.yml:

linters:
  enable:
    - exhaustive
    - exhaustruct
linters-settings:
  exhaustive:
    default-signifies-exhaustive: false
    check:
      - switch
      - map

Custom checker via golang.org/x/tools/go/analysis

For full sealed-interface enforcement (no implementor outside this package, every type-switch covers every variant), write a small analysis.Analyzer that walks the package AST and asserts:

  1. Every type implementing the marker method is in the sealing package.
  2. Every switch v.(type) over the sealed interface lists every variant.

CI fails on first violation; the rule travels with the code.


9. Memory layout — sealed interface still pays the iface 2-word cost

Sealing is a type-system discipline, not a layout change. An interface value remains a 2-word fat pointer:

+---------------+---------------+
|  itab pointer |  data pointer |
+---------------+---------------+

So a []Expr of 1,000,000 entries occupies 16 MB on 64-bit, regardless of whether the variants are 8 bytes or 80 bytes. The data pointer indirects to the heap-allocated concrete value (or to inlined data when the value fits in one word and is a non-pointer scalar — rarely the case in practice).

If memory layout dominates the workload, replace the sealed interface with a tagged union struct:

type Tag uint8
const (TagNum Tag = iota; TagAdd; TagMul; TagNeg)

type ExprTU struct {
    Tag  Tag
    Num  float64    // valid when Tag==TagNum
    L, R *ExprTU    // valid when Tag in {Add,Mul}; L valid when TagNeg
}

Trade-offs:

  • Pro: one allocation per node, ~32 bytes vs ~48 bytes (iface + concrete struct).
  • Pro: cache-friendly slice scans.
  • Con: loss of type safety; field discipline is manual; Go's compiler will not catch a Num-tagged node read through L.

Use the tagged union only when profiling shows the iface overhead dominates. For most programs the sealed interface stays cleaner.


10. Benchmarks — sealed vs open dispatch

The numbers below are illustrative shapes; run them locally with go test -bench=. -benchmem to confirm on your hardware.

package expr_test

import "testing"

func buildTree(depth int) Expr {
    if depth == 0 { return Num{V: 1} }
    return Add{L: buildTree(depth-1), R: buildTree(depth-1)}
}

var sink float64

// Sealed: compiler sees closed set; type-switch is dense; cases inline.
func BenchmarkSealedTypeSwitch(b *testing.B) {
    e := buildTree(15) // ~32k nodes
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        sink = Eval(e)
    }
}

// Open: same shape but Eval dispatched through interface method.
type ExprOpen interface{ Eval() float64 }
type NumO struct{ V float64 }
type AddO struct{ L, R ExprOpen }
func (n NumO) Eval() float64 { return n.V }
func (a AddO) Eval() float64 { return a.L.Eval() + a.R.Eval() }

func buildOpen(depth int) ExprOpen {
    if depth == 0 { return NumO{V: 1} }
    return AddO{L: buildOpen(depth-1), R: buildOpen(depth-1)}
}

func BenchmarkOpenIfaceDispatch(b *testing.B) {
    e := buildOpen(15)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        sink = e.Eval()
    }
}

// Visitor variant — extra indirection per node.
type evalVisitor struct{}
func (evalVisitor) VisitNum(n Num) float64 { return n.V }
func (evalVisitor) VisitAdd(a Add) float64 {
    return a.L.Accept(evalVisitor{}) + a.R.Accept(evalVisitor{})
}
// ... other Visit methods omitted for brevity

func BenchmarkVisitorDispatch(b *testing.B) {
    e := buildVisitorTree(15)
    v := evalVisitor{}
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        sink = e.Accept(v)
    }
}

Typical relative shape on amd64, Go 1.22:

BenchmarkSealedTypeSwitch-8     ~ 1.0x baseline (cases inline, devirtualized)
BenchmarkOpenIfaceDispatch-8    ~ 1.4x slower  (itab lookup per call)
BenchmarkVisitorDispatch-8      ~ 1.6x slower  (Accept + VisitX, two calls)

Sealed type-switch wins because every concrete arm is a direct call the compiler can inline. The open interface pays the itab indirection on every recursive descent.


11. Cleaner code — sealing as a design tool

Pattern: marker method placement

Keep the marker method on the value receiver so both Num and *Num satisfy the interface, and put it on its own line for grep-ability:

func (Num) isExpr() {}
func (Add) isExpr() {}
func (Mul) isExpr() {}
func (Neg) isExpr() {}

A regex grep func \(\w+\) isExpr lists every variant in one shot — useful in code review.

Pattern: panic("unreachable") at the bottom

Even with exhaustive linting, terminate the type-switch with:

panic(fmt.Sprintf("expr: unhandled variant %T", e))

The panic message points to the concrete type name when an unsealed implementor sneaks in — invaluable during refactors that move types between packages.

Pattern: keep the variant struct minimal

Variants should be plain data. Behavior lives in functions over the sealed interface, not on each variant:

// Good — behavior in one place
func Pretty(e Expr) string { ... }
func Eval(e Expr) float64  { ... }

// Avoid — sprawling method set per variant
func (a Add) Pretty() string { ... }
func (m Mul) Pretty() string { ... }

This keeps variants cheap to add, and centralizes traversal logic next to the type-switch.


12. Cheat Sheet

SEALED INTERFACE PERFORMANCE
─────────────────────────────────────
Variant count  ≤ 6   → linear case chain
Variant count  ≥ 7   → hashed jump table
Marker method        → zero runtime cost
Itab cardinality     → small, predictable
Iface value size     → still 2 words (16B)

DEVIRTUALIZATION
─────────────────────────────────────
Inside type-switch arm → concrete type known
Direct method call     → inlinable
go build -gcflags='-m=2' | grep devirt

DISPATCH CHOICES
─────────────────────────────────────
Few traversals, many variants → type-switch
Many traversals, few variants → visitor
Memory-bound, hot scan        → tagged union

LINTING
─────────────────────────────────────
//exhaustive:enforce  + golangci-lint
custom analysis.Analyzer for sealed sets
panic("unreachable") as belt-and-braces

WHEN TO SEAL
─────────────────────────────────────
ADTs / sum types         → yes
Public extension points  → no, keep open
Plugin / strategy types  → no, keep open

Summary

Sealing an interface is mostly a clarity tool: the closed set lets the compiler, the linter, and the reader reason about every dispatch site. The performance side benefits are real but secondary:

  1. Type-switch dispatch — dense and predictable; jump-table kicks in around 6 cases.
  2. Devirtualization — concrete types inside switch arms enable inlining of method calls.
  3. Smaller itab footprint — fewer concrete implementors, smaller runtime tables.
  4. Codegen friendly — generators emit exhaustive dispatchers without reflection.
  5. Memory layout unchanged — interface values still cost two words; reach for tagged unions only when profiling demands.

Profile before reaching for the visitor or the tagged union. For the typical sealed ADT — fewer than ten variants, a handful of traversals — the plain type-switch is the fastest, smallest, and most readable choice Go offers.