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:
- Linear chain of comparisons — each case checks the itab pointer in sequence.
- 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:
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
Visitdispatcher with nodefaultbranch - A
MarshalJSON/UnmarshalJSONaware of all variants
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?
- 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-missingin the lint step). - No reflection — generated code is plain
switchand benefits from the same inlining and devirtualization as hand-written code. - 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:
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:
The runtime compares:
- The itab pointer (one word, equal for same dynamic type).
- 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
Evalover 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 traversals —
Eval(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:
- Every type implementing the marker method is in the sealing package.
- 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:
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 throughL.
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:
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:
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:
- Type-switch dispatch — dense and predictable; jump-table kicks in around 6 cases.
- Devirtualization — concrete types inside switch arms enable inlining of method calls.
- Smaller itab footprint — fewer concrete implementors, smaller runtime tables.
- Codegen friendly — generators emit exhaustive dispatchers without reflection.
- 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.