GEP-13
Abstract
Sealed types — classes, interfaces, and traits — restrict the set of
permitted direct subtypes. They sit between the unconstrained
extensibility of public types and the absolute closure of final,
enabling enumerable hierarchies suitable for algebraic data type
modelling, compiler-checked exhaustiveness analysis, and stable API
design where the implementation set is intentionally bounded.
Motivation
Inheritance is a powerful mechanism but is binary as expressed by the
existing modifiers: a public, non-final class invites unbounded
extension; final prevents all of it. Visibility modifiers
(protected, package-private) constrain extension at the cost of
losing the parent type as a public abstraction.
Sealed types fill the gap. A sealed type is publicly accessible — it can be a method parameter type, a switch selector, a return type — but the set of types that may extend or implement it is explicitly enumerated. Code receiving a value of a sealed type knows the exhaustive list of its possible runtime shapes.
This makes sealed types the type-system foundation for several adjacent features:
-
algebraic data type modelling, particularly when combined with records;
-
compiler-checked exhaustiveness in structural switch (a potential topic for GEP-19);
-
stable APIs whose implementation set is intentionally closed.
Specification
Sealed type declarations
A class, interface, or trait is sealed by declaring it with the
sealed modifier and an optional permits clause:
sealed interface Shape permits Circle, Square, Triangle {}
sealed class Vehicle permits Car, Truck, Motorcycle {}
sealed trait Auditable permits LedgerEntry, JournalEntry {}
Enums and annotation definitions cannot be sealed.
The annotation form @Sealed is provided as an equivalent surface for
environments where keyword grammar is inconvenient:
@Sealed(permittedSubclasses = [Circle, Square, Triangle])
interface Shape {}
The keyword and annotation forms are interchangeable.
The permits clause
The permits clause enumerates the direct subtypes permitted to
extend or implement the sealed type. A permitted subtype must:
-
declare the sealed type as a direct supertype (via
extendsorimplements); -
be accessible to the compiler when the sealed type is loaded;
-
not be the sealed type itself (self-references are rejected at compile time).
Inference
If the permits clause is omitted (and permittedSubclasses is not
set on @Sealed), the compiler infers permitted subtypes by collecting
all direct subtypes declared in the same compilation unit.
Inference does not consult other compilation units.
Permitted-subtype obligations
Every direct permitted subtype of a sealed type adopts one of three stances:
| Stance | Declaration | Effect |
|---|---|---|
|
|
C is closed; no further subtypes. |
|
|
C continues the closed hierarchy with its own enumerated subtypes. |
Non-sealed (explicit) |
|
C is open; descendants of C are unconstrained and require no marker. |
Non-sealed (implicit) |
|
Same effect as explicit |
Implicit non-sealed default
Where Java requires one of final, sealed, or non-sealed to be
stated, Groovy infers non-sealed when none is given. This is a
deliberate divergence motivated by Groovy’s preference for terse
declarations.
Propagation past a non-sealed boundary
The permits constraint applies to direct subtypes only. Once a
non-sealed type appears in a hierarchy descending from a sealed root,
that branch is unconstrained: descendants of a non-sealed type require
no sealed-related modifier and are not permits-restricted by virtue
of the sealed ancestor.
sealed interface Shape permits Polygon, Circle {}
final class Circle implements Shape {}
class Polygon implements Shape {} // implicit non-sealed
class RegularPolygon extends Polygon {} // unrestricted
class Hexagon extends RegularPolygon {} // unrestricted
Constraint applicability
A class declared non-sealed (whether by keyword or @NonSealed)
must have a sealed direct parent. A class without a sealed parent
cannot be marked non-sealed.
Restricted identifiers
sealed, non-sealed, and permits are restricted identifiers,
not reserved keywords. They retain their identifier meaning in
expression and identifier positions, taking on grammatical meaning
only in type-declaration contexts. Existing code using these names as
identifiers continues to compile.
Subtype-creating paths
The set of types extending or implementing a sealed type is enumerated
in permits; no other type may do so. Any path that would otherwise
produce a subtype of a sealed type must honour the permits contract
— whether the path is an explicit declaration, anonymous-class
generation, trait composition, as coercion, @Delegate, or runtime
proxy construction. How each path complies (compile-time rejection,
silent omission, runtime check) is left to the compiler and the
relevant AST transform.
Bytecode representation
Sealed types are represented in bytecode using one of two mechanisms,
selected by @SealedOptions(mode = …):
| Mode | Behaviour |
|---|---|
|
Emit JVM-level sealed metadata ( |
|
Emit |
|
|
The alwaysAnnotate attribute of @SealedOptions (default true)
controls whether the @Sealed annotation is also emitted alongside
native metadata. Setting it to false suppresses the annotation for
hierarchies that are confirmed JDK-17-only and want to avoid duplicate
metadata.
Annotation forms
The annotation forms parallel the keyword forms and exist for environments where keyword grammar is inconvenient.
| Annotation | Equivalent |
|---|---|
|
|
|
|
|
Fine-grained control over bytecode representation; no keyword equivalent. |
@Sealed has RUNTIME retention; @NonSealed and @SealedOptions
have SOURCE retention.
Joint compilation and decompiled types
Sealed type information flows correctly across joint Groovy/Java compilation:
-
a Groovy class extending a Java sealed class is checked against the Java type’s
permitsset; -
a Java class extending a Groovy sealed class likewise honours the Groovy
permitsset; -
the implicit non-sealed rule applies on the Groovy side; Java’s explicit-modifier-required rule applies on the Java side; descendants on either side compute non-sealed status from the immediate parent’s sealed flag.
For a class loaded from bytecode (without source available), non-sealed
status is computed as: the parent is sealed AND this type is neither
final nor sealed. The JVM has no non-sealed flag, so this derivation
is the canonical algorithm.
Differences from Java
| Aspect | Java | Groovy |
|---|---|---|
Subtype modifier |
One of |
Defaults to implicit |
Compile-together enforcement |
Required: all permitted subclasses must be available and compiled together with the sealed parent |
Not enforced; permitted-subclass references are resolved when available |
Module membership |
Permitted subclasses must be in the same module (or in the unnamed module within the same package) |
Not enforced |
Annotation form |
None |
|
Earlier-JDK targets |
Not supported; sealed metadata requires JDK 17+ bytecode |
Annotation-based emulation works on earlier target bytecode and is recognised by the Groovy compiler (invisible to the Java compiler and to JVM-level sealed checks) |
Pattern matching integration
Sealed types provide the closed-set semantics that compiler-checked
exhaustiveness analysis requires. A switch over a value of a sealed
type whose arms cover every permitted subtype is, in principle,
exhaustive without a default arm. Recursion into sealed-or-final
permitted subtypes contributes to exhaustiveness; a non-sealed
permitted subtype requires a fallback arm.
The switch surface that consumes sealed types — type patterns and record patterns over a sealed-typed selector — already works in Groovy. The remaining piece is the exhaustiveness analysis itself, which is a potential topic for GEP-19. GEP-13 contributes the type-system primitives; any consumer that performs exhaustiveness analysis operates as a compile-time check on the consumer side and does not change sealed-type semantics, on-disk representation, or public API. The severity of such consumer-side checking — warning, opt-in error, or default error — is a consumer decision and is not constrained by this GEP.
Excluded and deferred features
| Feature | Status | Rationale |
|---|---|---|
Mandatory compile-together enforcement |
Deferred |
Java’s hard requirement raises the cost of using sealed types in multi-module builds. May be surfaced as an opt-in compiler check. |
Mandatory module-membership enforcement |
Deferred |
Same reasoning; cross-module sealed hierarchies are occasionally useful and a hard JPMS check would block them. |
Synthesized subtypes in inferred |
Deferred |
When |
Any future tightening of these constraints is opt-in. See Compatibility for the stability commitment. Severity of consumer-side checks (such as exhaustiveness analysis on a structural switch) is out of scope for this GEP — see Pattern matching integration.
Compatibility
Public API surface
The following are part of Groovy’s public API and stable:
-
the
sealed/non-sealed/permitsgrammar; -
the
@Sealed,@NonSealed, and@SealedOptionsannotations, including their attributes and retention; -
ClassNode.isSealed()andClassNode.getPermittedSubclasses(); -
the public helpers in
SealedASTTransformation.
The annotations and public AST API shipped under @Incubating in
Groovy 4.0 and remained incubating through the Groovy 5 line; they
were promoted out of incubation in Groovy 6.0.
Stability commitment
Sealed-type semantics — declaration syntax, the permits contract,
bytecode representation (native and emulated), public API surface,
and joint-compilation interoperability — are stable. Programs that
declare or extend sealed types under this specification continue to
compile and produce equivalent bytecode across subsequent revisions.
The deferred items in Excluded and deferred features — mandatory
compile-together enforcement and mandatory module-membership
enforcement — are subject to this commitment: if introduced, they
are opt-in checks (compiler flag or @SealedOptions attribute)
rather than default behaviour.
Compile-time checks performed by consumers of sealed types — most notably exhaustiveness analysis on a structural switch (see GEP-19) — fall outside this commitment. Their severity may evolve independently and may, in a future major revision, default to error. Such evolution is a consumer-side language change, not a change to sealed types: it does not alter sealed-type semantics or the on-disk representation, and existing sealed declarations are unaffected.
Bytecode interoperability
Native sealed bytecode (mode = NATIVE, or AUTO on target 17 or
later) is JVM-level sealed metadata. Java and Groovy compilers consume
each other’s sealed types under the standard JVM rules.
The EMULATE mode produces an annotation that is recognised by the
Groovy compiler but not by the Java compiler or the JVM. Mixing
emulated sealed Groovy types with Java consumers is therefore not
sealed-checked from the Java side; this is a known consequence of
emulation and is the reason AUTO defaults to NATIVE whenever the
target permits.
References
-
Sealed Classes in Kotlin
-
Sealed Types in Scala (withdrawn)
Reference implementation
JIRA issues
-
GROOVY-10148 — Initial implementation
-
GROOVY-10193 —
sealed/permits/non-sealedgrammar -
GROOVY-10201 — Proxy generation against JDK 17 sealed interfaces
-
GROOVY-10233 — Native sealed bytecode for JDK 17+
-
GROOVY-10240 — Record/sealed grammar consistency
-
GROOVY-10340 — Drop system-property gating
-
GROOVY-10433 — Restricted identifiers
-
GROOVY-10434 — Public AST API for sealed status
-
GROOVY-10451 — Self-reference guard in
permittedSubclasses -
GROOVY-10565 — Packaged sealed type
ClassFormatErrorfix -
GROOVY-11292, -11750, -11768 — Implicit non-sealed propagation across hierarchies and joint-compilation chains
Update history
1 (2021-07-22) Initial draft
2 (2021-11-06) Update to align with 4.0.0-beta-2
3 (2026-05-05) Specification rewrite: implicit non-sealed propagation, joint-compilation rules, restricted identifiers, public API stability commitment, pattern-matching integration