Sealed Interfaces — Specification¶
Official Specification Reference Source: Go Language Specification — §Exported_identifiers, §Method_sets, §Interface_types, §Type_assertions, §Type_switches
Sealing is not a Go language keyword. It is an idiom that derives entirely from the visibility rules of unexported identifiers and the formal definition of interface satisfaction. This document grounds the idiom in the spec and shows how
go/ast,go/types, andreflectimplement it.
Table of Contents¶
- Spec Reference
- Formal Grammar (EBNF)
- Core Rules and Constraints
- Identifier Visibility — Foundation of Sealing
- Method Sets and Interface Satisfaction
- Behavioral Specification of Sealed Interfaces
- Defined vs Undefined Behavior
- Edge Cases from Spec
- Standard Library Reference
- Tooling: go/ast, reflect, go/types, go/analysis
- Spec Compliance Checklist
1. Spec Reference¶
Exported and Unexported Identifiers — Official Text¶
An identifier is exported if both: - the first character of the identifier's name is a Unicode uppercase letter (Unicode class "Lu"); and - the identifier is declared in the package block or it is a field name or method name. All other identifiers are not exported.
Source: https://go.dev/ref/spec#Exported_identifiers
This single rule is the foundation of sealing. A method whose name begins with a lowercase letter is unexported, and "Method names referring to interface methods may be exported or not, but in either case they're identified by the (qualified) method name (see Uniqueness of identifiers)." Two methods with the same simple name in different packages are distinct identifiers.
Uniqueness of Identifiers — Official Text¶
Given a set of identifiers, an identifier is called unique if it is different from every other in the set. Two identifiers are different if they are spelled differently, or if they appear in different packages and are not exported. Otherwise, they are the same.
Source: https://go.dev/ref/spec#Uniqueness_of_identifiers
This is the formal mechanism by which an unexported method expr() declared in package p cannot be satisfied by expr() declared in package q: they are different identifiers.
Interface Types — Official Text¶
An interface type defines a type set. A variable of interface type can store a value of any type that is in the type set of the interface. Such a type is said to implement the interface.
The type set of an interface type is the intersection of the type sets of its interface elements.
A type T implements an interface if [...] for every basic interface in the interface's type set, T's method set contains the interface's method set.
Source: https://go.dev/ref/spec#Interface_types
When the interface contains an unexported method, the requirement "T's method set contains the interface's method set" means T must declare a method with the same unqualified name in the same package. This is sealing.
Method Sets — Official Text¶
The method set of a type determines the interfaces that the type implements. The method set of a defined type T consists of all methods declared with receiver type T.
Source: https://go.dev/ref/spec#Method_sets
Type Assertions — Official Text¶
For an expression x of interface type, but not a type parameter, and a type T, the primary expression
x.(T)asserts that x is not nil and that the value stored in x is of type T.
Source: https://go.dev/ref/spec#Type_assertions
Sealed interfaces depend heavily on type assertions and type switches for pattern matching.
Type Switches — Official Text¶
A type switch compares types rather than values. It is otherwise similar to an expression switch.
Source: https://go.dev/ref/spec#Type_switches
A type switch over a sealed interface is the canonical pattern for ADT discrimination.
2. Formal Grammar (EBNF)¶
Interface Type with Sealing Marker¶
InterfaceType = "interface" "{" { InterfaceElem ";" } "}" .
InterfaceElem = MethodElem | TypeElem .
MethodElem = MethodName Signature .
MethodName = identifier .
The sealing marker is a MethodElem whose MethodName starts with a lowercase letter:
The grammar does not distinguish a sealing marker from any other unexported method — sealing is encoded in the visibility of the identifier, not in the grammar.
Type Switch over a Sealed Interface¶
TypeSwitchStmt = "switch" [ SimpleStmt ";" ] TypeSwitchGuard "{" { TypeCaseClause } "}" .
TypeSwitchGuard = [ identifier ":=" ] PrimaryExpr "." "(" "type" ")" .
TypeCaseClause = TypeSwitchCase ":" StatementList .
TypeSwitchCase = "case" TypeList | "default" .
TypeList = Type { "," Type } .
There is no grammar-level enforcement of exhaustiveness. Exhaustiveness is enforced by external analyzers.
3. Core Rules and Constraints¶
Rule 1 — A sealed interface contains at least one unexported method¶
package p
type Expr interface {
expr() // unexported — sealing marker
String() string // exported — public method
}
The unexported method is what makes the interface sealed. Without it, any package can implement the interface.
Rule 2 — Only types in the same package can implement the interface¶
By spec §Uniqueness_of_identifiers, the unexported expr declared in package p is not the same identifier as expr declared in package q. Therefore types in q cannot satisfy p.Expr.
// pkg p
type Expr interface { expr() }
// pkg q
type Foo struct{}
func (Foo) expr() {} // this `expr` is q.expr, not p.expr
var _ p.Expr = Foo{} // compile error: Foo does not implement p.Expr
// (missing method expr)
Rule 3 — Embedding propagates sealing¶
If a type T from package p is exported and has the sealing method in its method set, embedding T from another package brings the sealing method into the embedder's method set.
// pkg p
type Expr interface { expr() }
type Lit struct{ V int }
func (Lit) expr() {}
// pkg q
type Wrap struct { p.Lit }
var _ p.Expr = Wrap{} // OK — sealing method promoted
This is the embedding loophole.
Rule 4 — Sealing applies to concrete types, not to interfaces¶
An interface I cannot have methods, only declared method elements. There is no concept of "implementing" one interface by another — by spec, an interface J satisfies I if J's type set is a subset of I's type set.
type I interface { x() }
type J interface { x(); String() string } // J's type set ⊂ I's type set
var _ I = J(nil) // OK by spec — type set inclusion
A sealed interface can be embedded by another interface in the same package; outside the package, the embedded interface is still sealed.
Rule 5 — Sealing is not transitive across packages via aliases¶
Type aliases preserve the original package binding:
// pkg p
type Expr interface { expr() }
// pkg q
type Expr = p.Expr // alias
type Foo struct{}
func (Foo) expr() {} // q.expr, not p.expr
var _ q.Expr = Foo{} // compile error — still sealed
Rule 6 — Generic interface types¶
Type parameters do not affect sealing. A generic sealed interface is still sealed.
Type-set sealing applies the same way regardless of T.
4. Identifier Visibility — Foundation of Sealing¶
The visibility rule (formal)¶
From §Declarations_and_scope:
An identifier declared in a block may be redeclared in an inner block. While the identifier of the inner declaration is in scope, it denotes the entity declared by the inner declaration.
And from §Exported_identifiers:
An identifier is exported if [first character is uppercase Unicode "Lu"]. All other identifiers are not exported.
For unexported identifiers, the package itself acts as an outer scope. The identifier is qualified by its package when compared to identifiers from other packages.
Practical consequence: "private namespace" per package¶
Each package owns the namespace of its unexported identifiers. Two packages can declare unexported identifiers with the same spelling and they remain distinct.
// pkg a
type I interface { foo() }
// pkg b
type J interface { foo() }
// pkg c
type T struct{}
func (T) foo() {}
var _ a.I = T{} // compile error: T's foo is c.foo, not a.foo
var _ b.J = T{} // compile error: T's foo is c.foo, not b.foo
var _ a.I = a.something{} // possibly OK if a.something defines foo
The compiler tracks the defining package of every unexported method name. The go/types package exposes this via types.Func.Pkg():
import "go/types"
func isSealed(iface *types.Interface) bool {
for i := 0; i < iface.NumMethods(); i++ {
m := iface.Method(i)
if !m.Exported() {
return true
}
}
return false
}
func sealingPackage(iface *types.Interface) *types.Package {
for i := 0; i < iface.NumMethods(); i++ {
m := iface.Method(i)
if !m.Exported() {
return m.Pkg()
}
}
return nil
}
types.Func.Pkg() returns the package in which the identifier was declared — the only package whose types can satisfy the interface.
5. Method Sets and Interface Satisfaction¶
Method set of a sealing implementor¶
type Lit struct{ V int }
func (Lit) expr() {}
func (Lit) Print() string { return fmt.Sprint(Lit{}.V) }
| Type | Method set (unqualified names) |
|---|---|
Lit | {expr, Print} |
*Lit | {expr, Print} |
Both methods are in the method set of Lit and *Lit because both are declared with value receivers.
Interface satisfaction with unexported methods (formal)¶
For Lit to implement p.Expr (defined in package p with an expr() method), the spec requires:
Lit's method set contains a method whose qualified name isp.expr().- Equivalently:
Litis declared in packagepand declares a methodexpr().
If Lit is in package q, condition 1 fails — q.expr() is a different qualified identifier.
How go/types checks satisfaction¶
import "go/types"
func implements(t types.Type, iface *types.Interface) bool {
return types.Implements(t, iface)
}
types.Implements walks the interface methods and looks them up in t's method set using LookupFieldOrMethod. For unexported names, it requires the lookup to succeed with the interface's defining package as the visibility scope.
Method set rules and sealing¶
The standard rules (§Method_sets) apply. A sealing marker declared with a value receiver puts it in the method set of both T and *T. With a pointer receiver, only *T.
Convention: declare the marker with value receiver.
func (Lit) expr() {} // good — both Lit and *Lit satisfy
func (*Lit) expr() {} // surprising — only *Lit satisfies
6. Behavioral Specification of Sealed Interfaces¶
Type assertion against a sealed interface¶
A type assertion x.(T) where x has interface type and T is one of the sealed implementors is the standard discriminator.
The runtime check is identical to any other type assertion. Sealing is a compile-time property; the runtime knows nothing about it.
Type switch over sealed interface¶
The compiler does not enforce exhaustiveness. Per the spec:
The expression may also be the predeclared identifier nil. The case "default" is allowed.
A missing case yields the zero value of the result (or falls through to a default). Linters fill the gap.
Reflection¶
Reflection treats sealed and unsealed interfaces identically. reflect.Type.Method(i) returns all methods including unexported ones, but the unexported method's PkgPath is set:
import "reflect"
t := reflect.TypeOf(Lit{})
for i := 0; i < t.NumMethod(); i++ {
m := t.Method(i)
fmt.Println(m.Name, m.PkgPath)
// expr pkg/p
// Print ""
}
The PkgPath is non-empty for unexported methods, identifying the sealing package.
reflect.Type.Implements¶
ifaceType := reflect.TypeOf((*p.Expr)(nil)).Elem()
litType := reflect.TypeOf(Lit{})
fmt.Println(litType.Implements(ifaceType))
Returns true only if Lit is in package p (or embeds something in package p whose method set contains expr).
7. Defined vs Undefined Behavior¶
Defined Operations¶
| Operation | Behavior |
|---|---|
| Declare interface with unexported method | sealed; only same-package types satisfy |
| Implement sealed interface from same package | satisfies |
| Implement sealed interface from another package | compile error |
| Embed exported sealed type from another package | satisfies via promotion |
| Type assertion to a sealed implementor | standard runtime check |
| Type switch over sealed interface | standard semantics; no exhaustiveness check |
| Reflection on sealed interface | works; unexported method has PkgPath |
types.Implements from go/types | recognizes sealing |
Compile Errors¶
| Operation | Result |
|---|---|
| Implementing sealed interface from another package without embedding | "T does not implement I (missing method m)" |
| Calling unexported method from outside the package on the interface | "m undefined (type I has no field or method m)" — but is implementation-defined; the spec disallows external access |
| Declaring a method with the same simple name on a foreign type | unrelated: it satisfies a different interface |
Undefined Behavior¶
The Go spec does not contain undefined behavior in this domain. Either a program is well-formed and behaves per the spec, or it is rejected at compile time.
The only runtime panic possible from sealing-related code: - Type assertion that fails without ok form: e.(Lit) panics if e is not a Lit. This is standard panic behavior, not specific to sealing.
8. Edge Cases from Spec¶
Edge Case 1 — Marker on unexported type¶
type Expr interface { expr() }
type lit struct{ V int }
func (lit) expr() {}
func NewLit(v int) Expr { return lit{V: v} }
lit is unexported, so external code cannot directly construct or embed it. The sealing is reinforced — even the embedding loophole is closed for lit.
Edge Case 2 — Sealed interface embedding open interface¶
type Stringer interface { String() string }
type Expr interface {
Stringer // open
expr() // sealed marker
}
Expr is sealed because of expr(). The Stringer requirement is unchanged.
Edge Case 3 — Open interface embedding sealed interface¶
ExprPlus inherits the seal: only types in Expr's package can satisfy ExprPlus. Embedding does not break the seal.
Edge Case 4 — Multiple sealed markers¶
Both must be implemented. No additional security; idiomatic style is one marker.
Edge Case 5 — Sealed marker with parameters¶
Even more restrictive: external code cannot construct internal struct{} with the right package qualification, so they cannot call the method even via reflection. Rarely used; the parameterless marker is sufficient.
Edge Case 6 — Generic sealed interface¶
type Container[T any] interface {
container()
Get() T
}
type intBox struct{ v int }
func (intBox) container() {}
func (b intBox) Get() int { return b.v }
var _ Container[int] = intBox{}
Generics do not affect sealing. Type-parameterized sealed interfaces are common in libraries that mix ADT modeling with generics (e.g., Result[T]).
Edge Case 7 — Reflective construction¶
reflect.New(reflect.TypeOf(Lit{})) creates a new *Lit, which satisfies the sealed interface. Reflection cannot be used to satisfy a sealed interface from a foreign package because there is no way to create a foreign type with the matching unexported method via reflection alone — reflect.StructOf builds anonymous structs but cannot attach methods.
Edge Case 8 — any to sealed interface conversion¶
Standard interface-to-interface conversion via type assertion:
The conversion respects the seal: only types whose method set contains the sealed method succeed.
Edge Case 9 — comparable constraint and sealed interfaces¶
Sealed interfaces are not necessarily comparable. If any implementor is non-comparable (contains a slice or map), the interface itself cannot be used as a map key.
type Expr interface { expr() }
type Add struct{ L, R Expr } // contains Expr (interface) — comparable depends on dynamic type
m := map[Expr]int{}
m[Lit{V: 1}] = 1 // OK if Lit comparable
// m[Array{[]int{}}] = 1 // panic: comparing uncomparable type
The seal does not change comparability rules.
9. Standard Library Reference¶
go/ast — the canonical sealed hierarchy¶
// $GOROOT/src/go/ast/ast.go (excerpt)
type Node interface {
Pos() token.Pos
End() token.Pos
}
type Expr interface {
Node
exprNode()
}
type Stmt interface {
Node
stmtNode()
}
type Decl interface {
Node
declNode()
}
Node itself is not sealed (no unexported method) — any type with Pos() and End() could satisfy it. The seals live one layer down: Expr, Stmt, Decl.
Implementations are spread across many files:
// ast/expr.go
func (*BadExpr) exprNode() {}
func (*Ident) exprNode() {}
func (*Ellipsis) exprNode() {}
func (*BasicLit) exprNode() {}
func (*FuncLit) exprNode() {}
func (*CompositeLit) exprNode() {}
func (*ParenExpr) exprNode() {}
func (*SelectorExpr) exprNode() {}
func (*IndexExpr) exprNode() {}
func (*IndexListExpr) exprNode() {}
func (*SliceExpr) exprNode() {}
func (*TypeAssertExpr) exprNode() {}
func (*CallExpr) exprNode() {}
func (*StarExpr) exprNode() {}
func (*UnaryExpr) exprNode() {}
func (*BinaryExpr) exprNode() {}
func (*KeyValueExpr) exprNode() {}
Source: https://pkg.go.dev/go/ast#Expr
go/types — sealed type system¶
Despite appearance, Type is sealed via documentation and convention rather than an unexported method. Types like *Basic, *Slice, *Map, *Struct, *Interface, *Named all implement it. New types cannot meaningfully be added because algorithms in the package switch on the known set.
Pre-Go-1.18 the sealing was de facto. In modern code, types.Type is sometimes called "soft sealed".
go/types.Object¶
type Object interface {
Parent() *Scope
Pos() token.Pos
Pkg() *Package
Name() string
Type() Type
Exported() bool
Id() string
String() string
// ...
color() color
setColor(color)
setOrder(uint32)
order() uint32
setColor1(color)
// ...
}
Object is sealed via several unexported methods. Implementations: *PkgName, *Const, *TypeName, *Var, *Func, *Label, *Builtin, *Nil.
Source: https://pkg.go.dev/go/types#Object
reflect.Type¶
// $GOROOT/src/reflect/type.go
type Type interface {
Align() int
FieldAlign() int
Method(int) Method
MethodByName(string) (Method, bool)
NumMethod() int
Name() string
PkgPath() string
Size() uintptr
String() string
Kind() Kind
Implements(u Type) bool
AssignableTo(u Type) bool
ConvertibleTo(u Type) bool
Comparable() bool
Bits() int
ChanDir() ChanDir
IsVariadic() bool
Elem() Type
Field(i int) StructField
FieldByIndex(index []int) StructField
FieldByName(name string) (StructField, bool)
FieldByNameFunc(match func(string) bool) (StructField, bool)
In(i int) Type
Key() Type
Len() int
NumField() int
NumIn() int
NumOut() int
Out(i int) Type
OverflowComplex(x complex128) bool
OverflowFloat(x float64) bool
OverflowInt(x int64) bool
OverflowUint(x uint64) bool
CanSeq() bool
CanSeq2() bool
common() *abi.Type
uncommon() *uncommonType
}
The two trailing methods, common() and uncommon(), are the sealing markers. They return internal types from internal/abi and reflect itself, so even if an external implementer guessed the names, the parameter types would be inaccessible.
Source: https://pkg.go.dev/reflect#Type
golang.org/x/tools/go/ssa.Value¶
// golang.org/x/tools/go/ssa/ssa.go
type Value interface {
Name() string
String() string
Type() types.Type
Parent() *Function
Referrers() *[]Instruction
Pos() token.Pos
}
Sealing is by documentation (// All concrete types that implement Value also implement Member or Instruction.) and the package's unexported helper methods on related interfaces. SSA's Instruction interface uses unexported markers more aggressively.
10. Tooling: go/ast, reflect, go/types, go/analysis¶
Detecting sealed interfaces with go/types¶
import (
"go/types"
)
// IsSealed reports whether iface contains at least one unexported method.
func IsSealed(iface *types.Interface) bool {
for i := 0; i < iface.NumMethods(); i++ {
if !iface.Method(i).Exported() {
return true
}
}
return false
}
// SealingPackage returns the package whose types are allowed to implement iface.
func SealingPackage(iface *types.Interface) *types.Package {
for i := 0; i < iface.NumMethods(); i++ {
m := iface.Method(i)
if !m.Exported() {
return m.Pkg()
}
}
return nil
}
Enumerating implementors of a sealed interface¶
import (
"go/types"
"golang.org/x/tools/go/packages"
)
func Implementors(iface *types.Interface, sealingPkg *types.Package) []types.Type {
var impls []types.Type
scope := sealingPkg.Scope()
for _, name := range scope.Names() {
obj := scope.Lookup(name)
t := obj.Type()
if types.Implements(t, iface) {
impls = append(impls, t)
}
// Also consider pointer receiver
if pt := types.NewPointer(t); types.Implements(pt, iface) {
impls = append(impls, pt)
}
}
return impls
}
This is the basis of an exhaustiveness analyzer.
Building an exhaustiveness analyzer with go/analysis¶
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)
insp.Preorder([]ast.Node{(*ast.TypeSwitchStmt)(nil)}, func(n ast.Node) {
ts := n.(*ast.TypeSwitchStmt)
// Resolve the switched expression's type.
var assert *ast.TypeAssertExpr
switch s := ts.Assign.(type) {
case *ast.AssignStmt:
assert = s.Rhs[0].(*ast.TypeAssertExpr)
case *ast.ExprStmt:
assert = s.X.(*ast.TypeAssertExpr)
}
tv := pass.TypesInfo.Types[assert.X]
iface, ok := tv.Type.Underlying().(*types.Interface)
if !ok || !IsSealed(iface) {
return
}
// Collect cases.
covered := map[types.Type]bool{}
for _, c := range ts.Body.List {
cc := c.(*ast.CaseClause)
for _, e := range cc.List {
covered[pass.TypesInfo.Types[e].Type] = true
}
}
// Compare against sealed implementors.
sealingPkg := SealingPackage(iface)
for _, impl := range Implementors(iface, sealingPkg) {
if !covered[impl] {
pass.Reportf(ts.Pos(), "missing case for %s", impl)
}
}
})
return nil, nil
}
This skeleton is the production pattern. Real implementations also handle pointer/value distinctions, embedded types, and default clauses.
reflect-based sealing inspection at runtime¶
import "reflect"
func IsSealedRuntime(ifaceVal any) (bool, string) {
t := reflect.TypeOf(ifaceVal)
for t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Interface {
return false, ""
}
for i := 0; i < t.NumMethod(); i++ {
m := t.Method(i)
if m.PkgPath != "" {
return true, m.PkgPath
}
}
return false, ""
}
Runtime detection is rarely needed; static analysis covers the use cases.
11. Spec Compliance Checklist¶
- At least one unexported method in the interface (the sealing marker).
- Marker has empty body and no parameters or return values (idiomatic).
- Marker declared with value receiver (so both
Tand*Tsatisfy). - All implementations live in the same package as the interface.
- Embedded base types (if any) are documented as the official extension point.
- Package documentation states whether external implementation is supported.
- Type switches over the sealed interface either cover every variant or include a
defaultarm. - Exhaustiveness is enforced by an analyzer in CI.
- No type alias re-exports the interface in a way that misleads about its package boundary.
- When using
go/types.Implements, account for bothTand*Tmethod sets. - When using reflection, treat unexported methods as sealing markers (
PkgPath != ""). - Adding a marker to an existing public interface is recognized as a major-version-breaking change.
Summary¶
Sealing in Go is a direct application of three spec sections:
- §Exported_identifiers — lowercase first letter ⇒ unexported.
- §Uniqueness_of_identifiers — unexported identifiers are scoped to their declaring package.
- §Interface_types — implementation requires the implementor's method set to contain the interface's method set, including unexported methods, which can only be declared in the interface's defining package.
The standard library uses this pattern in go/ast, go/types, reflect, and golang.org/x/tools/go/ssa. The go/types package exposes types.Implements and types.Func.Pkg() to programmatically inspect sealed interfaces. Tooling like go/analysis enables custom analyzers for exhaustiveness checks. Sealing has zero runtime semantics — it is purely a compile-time, identifier-visibility property.