GEP-13


Metadata
Number

GEP-13

Title

Sealed Types

Version

2

Type

Feature

Status

Final

Leader

Paul King

Created

2021-07-22

Last modification

2026-05-05

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 extends or implements);

  • 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

final

final class C extends S

C is closed; no further subtypes.

sealed

sealed class C extends S permits …​

C continues the closed hierarchy with its own enumerated subtypes.

Non-sealed (explicit)

non-sealed class C extends S or @NonSealed

C is open; descendants of C are unconstrained and require no marker.

Non-sealed (implicit)

class C extends S (no other sealed-related modifier)

Same effect as explicit non-sealed.

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

NATIVE

Emit JVM-level sealed metadata (ACC_SEALED access flag and PermittedSubclasses attribute). Requires --target 17 or later.

EMULATE

Emit @Sealed annotation only, recognised by the Groovy compiler. Compatible with all target bytecode versions supported by the Groovy compiler, but invisible to the Java compiler and to JVM-level sealed checks.

AUTO (default)

NATIVE when target bytecode is 17 or later, otherwise EMULATE.

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

@Sealed(permittedSubclasses = […​])

sealed …​ permits …​. permittedSubclasses defaults to the inferred set when omitted.

@NonSealed

non-sealed.

@SealedOptions(mode = …​, alwaysAnnotate = …​)

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 permits set;

  • a Java class extending a Groovy sealed class likewise honours the Groovy permits set;

  • 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 final / sealed / non-sealed is mandatory

Defaults to implicit non-sealed when none is given

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

@Sealed, @NonSealed, @SealedOptions are equivalent surface forms

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 permits

Deferred

When permits is inferred from the compilation unit, only source-declared subtypes are currently considered. Whether to relax this so that subtypes synthesized by AST transformations (e.g. @Delegate) also contribute to the inferred set is a future decision.

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 / permits grammar;

  • the @Sealed, @NonSealed, and @SealedOptions annotations, including their attributes and retention;

  • ClassNode.isSealed() and ClassNode.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

JIRA issues

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