Sealed Interface
A sealed interface is an interface whose set of permitted implementations is
fixed. Where an ordinary interface is open – any type, in any package, can
implement it – a sealed one closes the set so that the implementations are
always fixed and known by the interface. Some languages handle this with
special keywords/syntax, others enable it through privacy semantics, or even
exhaustive switch/matches.
Why it matters
Sealing answers one question: who is allowed to implement this interface? Normal interfaces can be implemented by anyone, either the library author, or a consumer of the library – provided the interface is namable. A sealed one changes these semantics so that only a fixed, well-known set of types may implement the interface. This leads to two critical improvements:
Control over the invariants: When the author of the interface names the complet set of implementers, any property that is supposed to hold across that set can be guaranteed by the library author. This enforces that no foreign package definitions can slip in a type that quietly or unintentionally violates it. This makes it much easier to guarantee that every type is substitutable, even if an interface has implied contract behaviors.
Exhaustive semantics: Code htat consumes the interface can more easily be implemented against the entire set of implementations and have better assurance that the family will not grow unexpectedly.
Example
Consider a Shape interface with a fixed set of implementers – Circle and
Rectangle – that no code outside the defining unit is allowed to extend. Each
language seals the set differently, but the guarantee is the same: these are the
only shapes there will ever be.
package geometry
import "math"
// Shape is sealed by the unexported marker method: only types declared in this
// package can satisfy it, so the set of shapes is fixed and known.
type Shape interface {
Area() float64
isShape()
}
type Circle struct{ Radius float64 }
type Rectangle struct {
Width float64
Height float64
}
func (Circle) isShape() {}
func (Rectangle) isShape() {}
func (c Circle) Area() float64 { return math.Pi * c.Radius * c.Radius }
func (r Rectangle) Area() float64 { return r.Width * r.Height }
mod sealed {
pub trait Sealed {}
}
// Shape can only be implemented by types that implement the private Sealed
// supertrait, which no outside crate can name.
pub trait Shape: sealed::Sealed {
fn area(&self) -> f64;
}
pub struct Circle {
pub radius: f64,
}
pub struct Rectangle {
pub width: f64,
pub height: f64,
}
impl sealed::Sealed for Circle {}
impl sealed::Sealed for Rectangle {}
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
}
sealed interface Shape permits Circle, Rectangle {
double area();
}
record Circle(double radius) implements Shape {
public double area() {
return Math.PI * radius * radius;
}
}
record Rectangle(double width, double height) implements Shape {
public double area() {
return width * height;
}
}
import kotlin.math.PI
sealed interface Shape {
fun area(): Double
}
data class Circle(val radius: Double) : Shape {
override fun area() = PI * radius * radius
}
data class Rectangle(val width: Double, val height: Double) : Shape {
override fun area() = width * height
}
Java and Kotlin state the seal in the language: permits – explicit in Java,
inferred from the same file in Kotlin – fixes the list, and a class outside it
will not compile as a Shape. Go seals through privacy: the unexported isShape
marker can only be satisfied inside the package, so no importer can add a Shape.
Rust’s sealed trait pattern does the same with a public trait bounded by a
private one – an outside crate cannot name the supertrait, so it cannot implement
Shape.
Whichever mechanism is used, the permitted implementations form a closed set that can be drawn directly.
When not to seal
Sealing is the wrong default whenever the set of implementations is meant to be open. Extension points like plugins, strategies, drivers, storage backends, codecs, etc. exist precisely so that code outside the defining package can supply new implementations without the original being changed. This follows the open/closed principle since it’s open for modification. Sealing an interface disables that extension point.
Sealing is only the right choice when a type hierarchy is intentionally bounded and exhaustive, having no logical reason to ever be extensible. Examples of this are things with a fully-known family, like an AST, a protocol message, etc. – anything where an implementation would logically be a defect. In these cases, sealing the interface is likely the right choice.
References
- JEP 409: Sealed Classes – the specification that introduced
sealedandpermitsto Java, restricting which classes may implement an interface. - Sealed classes and interfaces – Kotlin’s
sealedmodifier and the file/module scope that fixes the set of implementers. - Sealed traits protect against downstream implementations – the sealed-trait pattern and when to close a trait against foreign implementers.