Sealed Interfaces — Professional Level¶
Table of Contents¶
- Introduction
- The Sealing Mechanism in Production
- API Design Around Closed Type Sets
- Sealing Strategies for Libraries
- Multi-Package Architecture
- ADT Modeling at Scale
- Migration Playbooks
- Exhaustiveness Tooling
- Embedding Caveat and Hardening
- Production Patterns Catalog
- Anti-Patterns Catalog
- Performance Considerations
- Summary
Introduction¶
A "sealed" interface in Go is an interface that contains at least one unexported method. Because the method name is unexported, only types declared in the same package can satisfy the interface. The Go language has no sealed keyword — sealing is an idiom, encoded in the visibility rules from the language specification (§Exported_identifiers, §Method_sets).
In production codebases sealing is used to: - Enumerate a finite, closed set of variants — Algebraic Data Types (ADT) emulation. - Protect parsers, compilers, and AST/IR layers from third-party extension. - Lock the surface area of a public API while keeping the package extensible internally. - Make exhaustive switch statements analyzable by linters.
Canonical standard-library users: - go/ast.Node, go/ast.Expr, go/ast.Stmt, go/ast.Decl - go/types.Type, go/types.Object - reflect.Type - golang.org/x/tools/go/ssa.Value, ssa.Instruction - database/sql/driver.Value (semi-sealed via documentation)
This file covers the architectural decisions a senior or staff engineer makes when adopting sealing in a production system.
The Sealing Mechanism in Production¶
The minimum form¶
package ast
// Node is the sealed root of every AST node in this package.
type Node interface {
Pos() token.Pos
End() token.Pos
aNode() // unexported; sealing marker
}
aNode() is empty, has no semantics, and exists only to make the method name unexported. External packages cannot declare a method with that name on a foreign type, so they cannot satisfy Node.
Naming convention¶
Two conventions dominate in the Go ecosystem:
| Convention | Example | Where used |
|---|---|---|
aXxx() | aNode(), aType() | go/ast, go/types |
sealed() or isXxx() | isExpr(), sealed() | application code |
<typeName>Node() | exprNode(), stmtNode() | go/ast (per category) |
Pick one and apply it uniformly. In go/ast the prefixed-with-category form (exprNode(), stmtNode(), declNode()) lets a node belong to multiple sealed groups — *ast.FuncLit is both an Expr and an ast.Node.
Implementation pattern: zero-cost marker¶
type Expr interface {
Node
exprNode()
}
func (*BinaryExpr) exprNode() {}
func (*Ident) exprNode() {}
func (*CallExpr) exprNode() {}
Each implementation is a one-liner with an empty body. The compiler will emit it but the method is never called by the runtime — the inliner usually elides the entire stub.
Where sealing breaks down¶
External code cannot directly implement the interface but it can embed any of your concrete types and inherit the sealing method:
package thirdparty
type FakeExpr struct{ ast.Ident } // embeds *ast.Ident? No — Ident is a struct
// type FakeExpr struct{ *ast.Ident }
// FakeExpr now satisfies ast.Expr through promotion
We address mitigation in Embedding Caveat and Hardening. For most production code the embedding loophole is acceptable because anyone who embeds your private type accepts the contract.
API Design Around Closed Type Sets¶
When to seal a public interface¶
Seal only when the closed set is intentional. Ask three questions before sealing:
- Does the meaning of "this interface" rely on the variants being exhaustively known? — Yes for AST nodes, JSON values, currency types; No for
io.Reader,http.Handler,error. - Is exhaustive
switcha real concern in caller code? — If linters need to verify every case is handled, sealing is justified. - Will adding a new variant be a controlled, versioned event? — If a new variant arrives at every release, sealing is fine. If users must be free to add their own, do not seal.
A decision matrix¶
| Question | Sealed | Open |
|---|---|---|
| Domain has finite, well-defined variants | yes | no |
| Users implement the interface | no | yes |
| Cross-package mocking via real implementations needed | no | yes |
| Cross-package mocking via test doubles only | yes | yes |
| Library acts as a closed system (parser, IR, language) | yes | no |
| Library acts as a hub (HTTP, RPC, plugin) | no | yes |
Public marker vs private marker¶
If you want to allow embedding-based extension while still discouraging direct implementation, expose the marker but document it:
// Node represents any node in the IR.
//
// Implementations must embed BaseNode to satisfy the contract. Direct
// implementation is unsupported; the interface may grow new methods in
// future versions and only embedded types are guaranteed to keep working.
type Node interface {
Pos() token.Pos
End() token.Pos
isNode()
}
type BaseNode struct{}
func (BaseNode) isNode() {}
This pattern (popularized by cuelang.org/go and go/ast.CommentGroup) gives you both extensibility (via embedding) and forward compatibility (the interface can grow methods that have default implementations on BaseNode).
Sealing Strategies for Libraries¶
Strategy A: Hard seal, no extension¶
The strictest option — used by go/ast, go/types, and cmd/compile.
package expr
type Expr interface {
expr()
String() string
}
type Lit struct{ V int64 }
type Add struct{ L, R Expr }
type Mul struct{ L, R Expr }
type Neg struct{ X Expr }
func (Lit) expr() {}
func (Add) expr() {}
func (Mul) expr() {}
func (Neg) expr() {}
Pros: fully controlled, exhaustive switches are reliable, package owners can refactor freely. Cons: third parties cannot extend without forking.
Strategy B: Soft seal via embedding¶
Permits extension but flags the contract.
Third-party code:
type MyCustomExpr struct {
expr.ExprBase
Value string
}
func (m MyCustomExpr) String() string { return m.Value }
Pros: extensibility for advanced users, default behavior via base. Cons: weakens exhaustiveness — linters cannot enumerate external implementations.
Strategy C: Sealed by interface composition¶
Seal a domain interface but compose it from open ones. The open interfaces stay reusable; the sealed one becomes the contract for your algorithms.
type Stringer interface{ String() string }
type Hasher interface{ Hash() uint64 }
type Expr interface {
Stringer
Hasher
expr()
}
Stringer and Hasher remain open and reusable across packages; only Expr is sealed.
Strategy D: Sealed via internal package¶
Place the unexported marker inside internal/:
// internal/sealed/seal.go
package sealed
// Mark is a sealing token. Embed it to satisfy the sealed contract.
type Mark struct{}
// public/api.go
package api
import "yourmodule/internal/sealed"
type Expr interface {
sealed.Mark
Eval() int
}
This pattern lets multiple public packages share a single sealing primitive without exposing it. It is used in go/internal/gcimporter and golang.org/x/tools/go/types/typeutil.
Multi-Package Architecture¶
Sealed kernel + open extensions¶
Common in compilers and IDE tooling:
mylang/
├── ir/ // sealed: ir.Node, ir.Expr, ir.Stmt
├── parser/ // emits ir.* values
├── analyzer/ // pattern-matches ir.* with exhaustive switch
├── plugin/ // open: plugin.Pass interface (NOT sealed)
└── codegen/ // pattern-matches ir.*
The IR layer is sealed because: - Adding a new IR node requires changing every analyzer pass — you want compile-time discoverability. - Plugins extend behavior, not data. Behavior is open; data is closed.
Spreading variants across files (still in one package)¶
Even with hundreds of variants, all sealed implementations must live in one package. Use file-per-variant organization:
ir/
├── node.go // Node interface + sealing marker
├── expr.go // Expr interface composition
├── expr_lit.go
├── expr_add.go
├── expr_call.go
├── stmt.go
├── stmt_if.go
├── stmt_for.go
└── visitor.go
This is exactly the structure of go/ast: one file per node category and per variant.
Sealed across modules — not possible¶
You cannot split a sealed interface across two Go modules unless one re-exports the other's types. The unexported method anchors the seal to a single package.
If you need module-spanning sealing, choose between: - A single module with sub-packages (recommended). - A internal/sealed package that both modules import indirectly via the same root module.
You cannot seal across truly independent modules.
ADT Modeling at Scale¶
Real-world example: a JSON value type¶
package jsonv
type Value interface {
isValue()
Type() Kind
}
type Kind int
const (
KindNull Kind = iota
KindBool
KindNumber
KindString
KindArray
KindObject
)
type Null struct{}
type Bool struct{ V bool }
type Number struct{ V float64 }
type String struct{ V string }
type Array struct{ V []Value }
type Object struct{ V map[string]Value }
func (Null) isValue() {}
func (Bool) isValue() {}
func (Number) isValue() {}
func (String) isValue() {}
func (Array) isValue() {}
func (Object) isValue() {}
func (Null) Type() Kind { return KindNull }
func (Bool) Type() Kind { return KindBool }
func (Number) Type() Kind { return KindNumber }
func (String) Type() Kind { return KindString }
func (Array) Type() Kind { return KindArray }
func (Object) Type() Kind { return KindObject }
The seal lets a Walk(v Value) function safely assume it has covered every shape:
func Walk(v Value, f func(Value)) {
f(v)
switch x := v.(type) {
case Null, Bool, Number, String:
// leaf; nothing to recurse into
case Array:
for _, e := range x.V { Walk(e, f) }
case Object:
for _, e := range x.V { Walk(e, f) }
default:
panic("unreachable: jsonv.Value sealed but unknown variant")
}
}
The default arm is defensive: linters should never let it execute, but if a future maintainer adds a variant and forgets to update Walk, the panic surfaces fast.
Real-world example: payment events¶
package payments
type Event interface {
isEvent()
OccurredAt() time.Time
}
type Authorized struct {
At time.Time
Amount Money
}
type Captured struct {
At time.Time
Amount Money
Auth *Authorized
}
type Refunded struct {
At time.Time
Amount Money
}
type Voided struct {
At time.Time
}
func (Authorized) isEvent() {}
func (Captured) isEvent() {}
func (Refunded) isEvent() {}
func (Voided) isEvent() {}
func (e Authorized) OccurredAt() time.Time { return e.At }
func (e Captured) OccurredAt() time.Time { return e.At }
func (e Refunded) OccurredAt() time.Time { return e.At }
func (e Voided) OccurredAt() time.Time { return e.At }
A state machine that consumes events can pattern-match exhaustively:
func (s *State) Apply(e Event) error {
switch e := e.(type) {
case Authorized: return s.applyAuth(e)
case Captured: return s.applyCapture(e)
case Refunded: return s.applyRefund(e)
case Voided: return s.applyVoid(e)
}
return fmt.Errorf("unknown event %T", e)
}
Real-world example: SQL expression IR (mini-go/ast)¶
package sqlir
type Expr interface {
Node
expr()
}
type Stmt interface {
Node
stmt()
}
type Node interface {
Pos() Position
aNode()
}
// Statements
type SelectStmt struct {
Cols []Expr
From *TableRef
Where Expr
}
type InsertStmt struct {
Table *TableRef
Values [][]Expr
}
// Expressions
type Ident struct{ Name string }
type Literal struct{ V any }
type BinaryExpr struct{ Op string; L, R Expr }
type FuncCall struct{ Name string; Args []Expr }
func (*SelectStmt) aNode() {}
func (*InsertStmt) aNode() {}
func (*Ident) aNode() {}
func (*Literal) aNode() {}
func (*BinaryExpr) aNode() {}
func (*FuncCall) aNode() {}
func (*SelectStmt) stmt() {}
func (*InsertStmt) stmt() {}
func (*Ident) expr() {}
func (*Literal) expr() {}
func (*BinaryExpr) expr() {}
func (*FuncCall) expr() {}
This mirrors the multi-tier sealing pattern from go/ast (Node is the most general; Expr, Stmt, Decl are sealed sub-interfaces of Node).
Migration Playbooks¶
Playbook 1: Sealing an existing public interface — breaking¶
Adding an unexported method to an existing interface breaks every external implementor.
// v1 — open
type Visitor interface {
Visit(Node)
}
// v2 — sealed (BREAKING)
type Visitor interface {
Visit(Node)
visitor() // unexported — every external impl breaks
}
This is a major-version bump. Steps: 1. Announce intent in CHANGELOG.md one minor version before. 2. Provide a BaseVisitor type that satisfies the new interface for users to embed. 3. Cut a new major version (/v2).
Playbook 2: Soft migration via interface split¶
Keep the old interface open; add a new sealed one.
// v1 stays open
type Visitor interface {
Visit(Node)
}
// v2 adds a sealed sub-interface
type SealedVisitor interface {
Visitor
visitor()
}
Internal algorithms migrate to SealedVisitor over several minor versions. Old Visitor keeps working for external callers.
Playbook 3: Adding a variant to a sealed type¶
Adding a new variant is non-breaking for users who only consume values, but may break users who pattern-match exhaustively without a default arm.
Steps: 1. Add the variant. 2. Update internal exhaustive switches. 3. Document in CHANGELOG.md under "New variants". 4. If you ship staticcheck directives or go-exhaustive config, ensure CI for downstream users will fail loudly so they can react.
Playbook 4: Removing a variant¶
Removing a sealed variant is always breaking because pattern-matching code references the type by name.
Steps: 1. Mark the variant as // Deprecated:. 2. Make every internal site stop producing it. 3. Wait one or two minor versions. 4. Remove in a major version.
Playbook 5: Renaming a sealed marker method¶
Internal-only change. Safe because external code cannot reference the unexported method anyway. Use gofmt -r or gopls rename.
Exhaustiveness Tooling¶
Go has no native exhaustive switch. Tooling fills the gap.
nishanths/exhaustive¶
Originally for enums (iota constants), but with -default-signifies-exhaustive=false and the //exhaustive:enforce directive it can be wired to interface switches via separate analyzers.
go-exhaustive-interface (community)¶
Several community linters implement interface-exhaustiveness. The reliable approach in production is to combine:
- A list of sealed types maintained as a comment near the interface declaration.
- A custom
golang.org/x/tools/go/analysisanalyzer that walks*ast.TypeSwitchStmtand verifies all case clauses cover the listed types.
Hand-rolled analyzer skeleton¶
package exhaustivesealed
import (
"go/ast"
"go/types"
"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"
)
var Analyzer = &analysis.Analyzer{
Name: "exhaustivesealed",
Doc: "checks type switches over sealed interfaces are exhaustive",
Requires: []*analysis.Analyzer{inspect.Analyzer},
Run: run,
}
func run(pass *analysis.Pass) (any, error) {
insp := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
sealed := findSealedInterfaces(pass)
insp.Preorder([]ast.Node{(*ast.TypeSwitchStmt)(nil)}, func(n ast.Node) {
ts := n.(*ast.TypeSwitchStmt)
// determine the interface type of the assertion
// compare the set of case clauses to the known impls of that interface
// emit a diagnostic if any impl is missing
_ = ts; _ = sealed
})
return nil, nil
}
The full implementation needs to: - Resolve the type of the type-switch expression. - Look up all types implementing it within the sealing package (use go/types.Implements). - Diff against the case clauses.
This is the same approach go/analysis/passes/typeparams uses internally.
staticcheck¶
staticcheck does not yet have native sealed-exhaustiveness, but S1034 and SA9005 cover related concerns. Many teams couple a custom analyzer with the rest of staticcheck.
CI integration¶
# .golangci.yml
linters:
enable:
- exhaustive
- exhaustivestruct
- staticcheck
linters-settings:
exhaustive:
default-signifies-exhaustive: false
check-generated: false
package-scope-only: true
Run on every PR. Fail the build if exhaustiveness is violated.
Embedding Caveat and Hardening¶
The loophole¶
// your library
package ir
type Expr interface { expr() }
type BaseExpr struct{}
func (BaseExpr) expr() {}
// third-party code
package external
type Evil struct {
ir.BaseExpr // satisfies ir.Expr through promotion
Wickedness string
}
Any exported type whose method set contains an unexported sealing marker becomes a vehicle for embedding-based extension.
Hardening 1: Don't expose any concrete type with the marker¶
Make every implementation an unexported struct with an exported constructor:
package ir
type Expr interface{ expr() }
type literal struct{ V int64 }
func (literal) expr() {}
func NewLiteral(v int64) Expr { return literal{V: v} }
External code cannot embed literal, so the seal holds. But callers also cannot pattern-match by struct name from outside — they must call accessor methods. This is the trade-off go/types chose: every types.Type is accessed via methods, not field access.
Hardening 2: Marker on a private wrapper¶
package ir
type sealedMark struct{}
func (sealedMark) expr() {}
type Expr interface{ expr() }
type Add struct {
sealedMark
L, R Expr
}
sealedMark is unexported, so external code cannot embed it. But Add is exported — and any field of Add is a sealedMark, but you cannot embed Add from outside without reproducing the sealedMark, which is impossible.
Wait — external code can embed ir.Add:
Hardening 2 alone is insufficient. Combine it with Hardening 1.
Hardening 3: Runtime type guard¶
func mustBeMine(e Expr) {
switch e.(type) {
case Lit, Add, Mul, Neg:
return
}
panic("ir: foreign Expr implementation")
}
Use sparingly — it is a runtime check rather than a compile-time guarantee. Acceptable in compilers and parsers where the cost is dominated by other work.
Practical recommendation¶
For most production libraries: - Use the simplest sealing form (interface with aXxx() marker). - Accept the embedding loophole — it is rarely exploited by accident. - Document the contract: "Direct implementation outside this package is unsupported."
The Go standard library makes this exact choice. go/ast.Node is technically embeddable, but no production code does it because the consequences are clearly unsupported.
Production Patterns Catalog¶
Pattern 1: Sealed visitor¶
type ExprVisitor interface {
visitor()
VisitLit(*Lit)
VisitAdd(*Add)
VisitMul(*Mul)
VisitNeg(*Neg)
}
func Walk(v ExprVisitor, e Expr) {
switch e := e.(type) {
case *Lit: v.VisitLit(e)
case *Add: v.VisitAdd(e); Walk(v, e.L); Walk(v, e.R)
case *Mul: v.VisitMul(e); Walk(v, e.L); Walk(v, e.R)
case *Neg: v.VisitNeg(e); Walk(v, e.X)
}
}
The visitor itself is sealed so the library author can add new VisitXxx methods without breaking external visitors that embed BaseVisitor.
Pattern 2: Sealed event log¶
type Event interface{ event() }
type Log struct {
events []Event
}
func (l *Log) Append(e Event) { l.events = append(l.events, e) }
func (l *Log) Replay(state any) {
for _, e := range l.events {
switch e := e.(type) {
case UserCreated: applyUserCreated(state, e)
case UserUpdated: applyUserUpdated(state, e)
case UserDeleted: applyUserDeleted(state, e)
}
}
}
Sealed events guarantee the replay function covers every case — verifiable by an exhaustiveness linter.
Pattern 3: Sealed result type¶
type Result interface{ result() }
type Ok[T any] struct{ Value T }
type Err struct{ Error error }
func (Ok[T]) result() {}
func (Err) result() {}
A Result ADT mirrors Rust/Haskell-style error handling. Mostly idiomatic Go prefers (T, error), but in pipelines of fallible transformations a sealed Result is cleaner.
Pattern 4: Sealed state machine¶
type State interface{ state() }
type Idle struct{}
type Running struct{ Started time.Time }
type Done struct{ Result string }
type Failed struct{ Err error }
func (Idle) state() {}
func (Running) state() {}
func (Done) state() {}
func (Failed) state() {}
type Machine struct{ s State }
func (m *Machine) Transition(input Event) {
switch s := m.s.(type) {
case Idle: m.s = m.fromIdle(s, input)
case Running: m.s = m.fromRunning(s, input)
case Done: // terminal
case Failed: // terminal
}
}
Each transition handler returns a State. The compiler plus an exhaustiveness linter ensure every state has a handler.
Pattern 5: Sealed configuration variant¶
type DataSource interface{ dataSource() }
type FileSource struct{ Path string }
type S3Source struct{ Bucket, Key string }
type HTTPSource struct{ URL string }
func (FileSource) dataSource() {}
func (S3Source) dataSource() {}
func (HTTPSource) dataSource() {}
type Config struct {
Sources []DataSource
}
A finite set of allowed configurations. New source types require a release.
Anti-Patterns Catalog¶
Anti-pattern 1: Sealing an interface that should be open¶
// BAD — http.Handler-style interface, sealed
type Middleware interface {
Wrap(http.Handler) http.Handler
middleware()
}
Middleware is the canonical extension point. Sealing kills the ecosystem. Reserve sealing for closed type families.
Anti-pattern 2: Sealing without exhaustive checks¶
type Expr interface{ expr() }
func Eval(e Expr) int {
switch e := e.(type) {
case Lit: return e.V
case Add: return Eval(e.L) + Eval(e.R)
}
return 0 // silently swallows new variants
}
If you seal, commit to exhaustive switches. Otherwise the seal gives no benefit over an open interface.
Anti-pattern 3: Sealing across packages by accident¶
// pkg a
type Expr interface{ expr() }
// pkg b
type MyExpr struct{}
func (MyExpr) expr() {} // compile error — wait, no, it compiles!
// BUT b.expr() != a.expr() — different identifier
If you misread the rules and create a same-named unexported method in another package, you have not satisfied the original interface — you have created a different one. Verify with var _ a.Expr = b.MyExpr{}.
Anti-pattern 4: Marker method that returns something useful¶
The marker should be func() expr() with no result. Returning data couples the marker to behavior and makes refactoring harder.
Anti-pattern 5: Public marker¶
Defeats the purpose. The marker must be unexported.
Anti-pattern 6: Multiple competing markers¶
One marker is sufficient. More than one adds boilerplate without security.
Anti-pattern 7: Marker called on the hot path¶
The marker exists for compile-time reasons. Never call it at runtime.
Performance Considerations¶
Marker method overhead¶
The unexported marker method is empty. The compiler: - Generates a small function body (typically the function epilogue only). - Inlines it everywhere it can — with -gcflags=-m you'll see can inline (*Lit).expr. - Includes it in the type's itab along with all other methods.
The runtime cost is zero in any realistic profile. The main impact is binary size: each implementation contributes one tiny function. For ASTs with hundreds of node types this can add a few KB to the binary.
Type switch vs interface dispatch¶
A type switch over a sealed interface compiles to a sequence of comparisons against *itab pointers. For a small number of cases (less than ~8) the compiler emits a linear search; for larger sets it may emit a binary search or a jump table.
Benchmark from a real go/ast-style switch with 12 cases:
Visitor pattern (virtual dispatch through the sealed visitor interface) is usually faster than type switch for hot paths with many cases. Sealed marker plus visitor is the canonical compiler design.
Memory layout¶
Sealed interface values have the standard Go interface layout: a (*itab, *data) pair, 16 bytes on 64-bit platforms. The interface header is the same regardless of sealing.
A sealed concrete type with only an embedded marker (type Lit struct{ V int64 } plus func (Lit) expr() {}) has the same memory footprint as one without the marker — empty methods do not consume struct memory.
BaseExpr embedding cost¶
Embedding BaseExpr struct{} adds zero bytes (Go specifies size-zero structs). Embedding is the cheapest way to share the marker.
Cheat Sheet¶
SEALING DECISION
────────────────────────────
Closed type family → seal
Plugin extension point → don't seal
Mocking surface → don't seal (use test doubles)
AST/IR layer → seal
HTTP middleware → don't seal
Domain ADT → seal
NAMING CONVENTION
────────────────────────────
aNode() / aType() — go/ast, go/types
exprNode() / stmtNode()— go/ast (per category)
isXxx() / sealed() — application code
Pick ONE; apply uniformly
EMBEDDING DEFENSE
────────────────────────────
Best: hide every concrete type behind constructors
Good: document "no direct implementation"
OK: accept the loophole — most code is honest
EXHAUSTIVENESS TOOLING
────────────────────────────
nishanths/exhaustive
golangci-lint with `exhaustive`
custom analyzer via golang.org/x/tools/go/analysis
MIGRATION
────────────────────────────
Add variant → minor (informational)
Remove variant → major (deprecate first)
Add marker → major (BREAKING)
Remove marker → major (BREAKING — silently widens)
CANONICAL EXAMPLES
────────────────────────────
go/ast.Node, ast.Expr, ast.Stmt, ast.Decl
go/types.Type, types.Object
reflect.Type
golang.org/x/tools/go/ssa.Value, ssa.Instruction
Summary¶
Sealing in Go is not a language feature — it is an idiom built on the unexported-method visibility rule from the spec. Production use boils down to:
- Decide intent — seal only when the closed type set is part of the design contract.
- Choose strategy — hard seal (
go/aststyle) or soft seal (embeddable base) depending on extension policy. - Organize the package — file-per-variant, sealed kernel, open extensions.
- Tooling — pair sealing with an exhaustiveness analyzer or accept silent gaps.
- Migration — sealing a public interface is breaking; plan the major version.
- Trade-offs — the embedding loophole is real; mitigate via constructor APIs.
- Performance — sealing has effectively zero runtime cost; visitor pattern outperforms type switch on hot paths.
The standard library's go/ast, go/types, reflect, and SSA tooling all rely on sealing. When you adopt the pattern, you join a long-standing Go tradition for closed, exhaustively analyzable type hierarchies.