Records & Sealed Types

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Groovy 6.0 (6.0.0, 2026-09-22) and every example was run with Groovy 6.0.0 on JDK 26 (checked on 2026-10-14). Records and sealed types are both modern Java features that Groovy adopted in 4.0 and then extended. The Java semantics are explained on Java Records & Sealed Classes and are not repeated here; this page covers the Groovy syntax, the Groovy additions and the places where Groovy differs. Records are still labelled incubating in the object orientation guide, while sealed types were promoted out of incubation in Groovy 6.0.

Records

A record is a final class whose identity is its data. The components in the header define private final fields, a canonical constructor, an accessor per component, and toString, equals and hashCode. A native Groovy record extends java.lang.Record, exactly like a Java record, so Java code sees it as a record.

record Message(String from, String to, String body) {}

def msg = new Message('greenhouse', 'owner', 'Soil is dry')
assert msg.toString() == 'Message[from=greenhouse, to=owner, body=Soil is dry]'

Accessors follow the record convention and are named after the component (msg.from(), not getFrom()). Groovy property syntax still works (msg.from), because Groovy maps it onto the accessor, but there is no JavaBean-style getFrom() method to call. Components cannot be reassigned. Named-argument construction works as it does for any Groovy class.

The running example of this guide stores one sensor measurement per Reading record. It adds a compact constructor, an extra method and a static factory.

import java.time.Instant

record Reading(String sensorId, Instant at, double temperature, double humidity, double soil) {
    // compact constructor: no parameter list, runs before the fields are assigned
    Reading {
        if (humidity < 0 || humidity > 100) {
            throw new IllegalArgumentException("humidity out of range: $humidity")
        }
        sensorId = sensorId.trim().toUpperCase()     // reassigning a parameter changes the stored value
    }

    boolean isDry() { soil < 20 }                      // extra instance method
    static Reading of(String id, double t) {           // static factory
        new Reading(id, Instant.parse('2026-10-14T06:00:00Z'), t, 50, 30)
    }
}

def noon = Instant.parse('2026-10-14T06:00:00Z')
def r = new Reading(' a1 ', noon, 21.5, 60, 15)
println r
println r.sensorId() + ' ' + r.sensorId                // accessor method and property syntax
println r.dry
println Reading.of('b2', 18)
println r == new Reading('A1', noon, 21.5, 60, 15)
println Reading.isRecord()
println Reading.superclass.name
println Reading.recordComponents*.name
try { new Reading('x', noon, 1, 120, 1) } catch (IllegalArgumentException e) { println e.message }
try { r.temperature = 3 } catch (ReadOnlyPropertyException e) { println e.message }
println new Reading(sensorId: 'n1', at: noon, temperature: 1, humidity: 2, soil: 3)
Reading[sensorId=A1, at=2026-10-14T06:00:00Z, temperature=21.5, humidity=60.0, soil=15.0]
A1 A1
true
Reading[sensorId=B2, at=2026-10-14T06:00:00Z, temperature=18.0, humidity=50.0, soil=30.0]
true
true
java.lang.Record
[sensorId, at, temperature, humidity, soil]
humidity out of range: 120.0
Cannot set read-only property: temperature for class: Reading
Reading[sensorId=N1, at=2026-10-14T06:00:00Z, temperature=1.0, humidity=2.0, soil=3.0]

Compact constructors

The compact constructor has no parameter list. The implicit canonical constructor is kept and your statements run first, with the components in scope as assignable parameters. That is the place to validate (the humidity check above) and to normalise (the trimmed, upper-cased sensorId). Providing a full canonical constructor instead is allowed, but then you must assign every field yourself.

Other points that follow from the record rules:

  • A record is implicitly final and cannot extend a class. It can implement interfaces and be generic (record Box<T>(T item) {}).

  • Instance fields other than the components are rejected at compile time (see the output below). Static members, extra methods and extra constructors are fine.

  • The generated toString, equals and hashCode can be overridden by writing the method, or declaratively with the @ToString transform.

record Threshold(String metric, double min = 0, double max = 100) {}
println new Threshold('humidity')
println new Threshold('humidity', 40)
println new Threshold(metric: 'temp', max: 35)

record Point3D(int x, int y, int z) {
    String toString() { "Point3D[coords=$x,$y,$z]" }
}
println new Point3D(10, 20, 30)

record Box<T>(T item) {}
println new Box<String>('seed').item().toUpperCase()

interface Describable { String describe() }
record Crop(String name, int days) implements Describable {
    String describe() { "$name in $days days" }
}
println new Crop('radish', 25).describe()

try {
    new GroovyShell().evaluate('record Bad(int a) { int extra }')
} catch (Throwable e) {
    println e.message.readLines()[1]
}
Threshold[metric=humidity, min=0.0, max=100.0]
Threshold[metric=humidity, min=40.0, max=100.0]
Threshold[metric=temp, min=0.0, max=35.0]
Point3D[coords=10,20,30]
SEED
radish in 25 days
Script1.groovy: 1: Instance field is not allowed in `record` @ line 1, column 21.

The first three lines show a Groovy enhancement: components may have default values, which generate the shorter constructors (dropping arguments from the right) and apply to named-argument construction. @TupleConstructor(defaultsMode=DefaultsMode.OFF) turns this off and leaves the single Java-style constructor.

Groovy enhancements for records

Groovy gives every record a few conveniences that Java does not have. They are on by default and each can be switched off with @RecordOptions, for example @RecordOptions(toList=false).

record Sample(String zone, double value, String unit) {}
def s = new Sample('north', 21.5, 'C')

println s.toList()
println s.toMap()
println s.size()
println s[1] + ' ' + s.getAt(2)
def (zone, value, unit) = s
println "$zone $value $unit"
[north, 21.5, C]
[zone:north, value:21.5, unit:C]
3
21.5 C
north 21.5 C
  • toList() and toMap() return the component values, positionally or keyed by component name.

  • size() is the number of components and s[1] (the getAt method) reads the nth component.

  • Multiple assignment such as def (zone, value, unit) = s destructures a record by position, like a list. This is Groovy’s destructuring, not Java’s record patterns; pattern matching in switch is not part of Groovy 6.0.

Copying with copyWith

A record is shallowly immutable, so changing one component means building a new record. copyWith does that with named arguments: unnamed components are carried over. It is optional and must be enabled with @RecordOptions(copyWith=true). Groovy 6.0 adds nested updates (GROOVY-12015): a quoted dotted path in the map key reaches into a nested record, and a block form gives the pre-update value as old. Every record on the path needs copyWith=true, and untouched branches are shared rather than rebuilt.

import groovy.transform.*

@RecordOptions(copyWith = true)
record Point(int x, int y) {}
@RecordOptions(copyWith = true)
record Line(Point from, Point to, String label) {}

def line = new Line(new Point(1, 2), new Point(5, 6), 'edge')
println new Point(1, 2).copyWith(y: 9)
println line.copyWith(label: 'renamed')

def moved = line.copyWith('from.x': 100)          // nested path (6.0)
println moved
println moved.to.is(line.to)                       // untouched branch is shared

def r = line.copyWith {                            // block form (6.0)
    label = 'block'
    to.y = old.to.y + 10
}
println r
Point[x=1, y=9]
Line[from=Point[x=1, y=2], to=Point[x=5, y=6], label=renamed]
Line[from=Point[x=100, y=2], to=Point[x=5, y=6], label=edge]
true
Line[from=Point[x=1, y=2], to=Point[x=5, y=16], label=block]

Deep immutability, components and record-like classes

Records are shallowly immutable: a List component can still be changed through the original reference. Adding @ImmutableProperties applies the defensive copying of @Immutable. @RecordOptions(components=true) adds a components() method that returns a typed Tuple, useful under @CompileStatic. The record keyword itself is shorthand for a @RecordType class, which is a meta-annotation (see Enums & Annotations) that expands into @TupleConstructor, @RecordBase, @POJO and others; you can write the annotation form to configure them. @RecordOptions(mode=...) chooses between NATIVE (a real java.lang.Record), EMULATE (a record-like class that Java does not see as a record) and AUTO, which means native on the JDK 17 baseline of Groovy 6.0.

import groovy.transform.*

@ImmutableProperties
record Batch(List ids) {}
def ids = ['a', 'b']
def batch = new Batch(ids)
ids << 'c'
println batch.ids()

@RecordOptions(components = true)
record Sample(String zone, double value) {}

@CompileStatic
def typed() {
    def (String z, double v) = new Sample('north', 21.5).components()
    println "$z -> $v"
}
typed()

@RecordType
class Tag { String key; String value }
println new Tag('k', 'v')
println Tag.isRecord()

@RecordOptions(mode = RecordTypeMode.EMULATE)
record Legacy(int id) {}
println Legacy.isRecord()
println new Legacy(3)
[a, b]
north -> 21.5
Tag[key=k, value=v]
true
false
Legacy[id=3]

Differences from Java records

  • Groovy accepts default component values, named-argument construction, copyWith, toList, toMap, size and positional indexing; Java records have none of them.

  • Groovy records are dynamic unless you opt into @CompileStatic; accessors and property syntax are resolved at run time.

  • Java has record patterns in switch and instanceof (see Java Pattern Matching). Groovy 6.0 does not; use destructuring assignments or the accessors.

  • A record-like class (mode=EMULATE, or a plain @RecordType) behaves like a record in Groovy but is an ordinary class to Java.

  • Serialization follows the Java record rules for native records.

Sealed hierarchies

A sealed class, interface or trait lists the types that may extend or implement it. Sealed types were introduced as incubating in Groovy 4.0, matured through 5.x, and were promoted to stable in Groovy 6.0 (GROOVY-12008); the specification is GEP-13: Sealed Types. The permits clause is optional when all subclasses are in the same file: the compiler infers them. Each permitted subclass should state its own mode: final (closed), sealed (closed with its own list) or non-sealed (open again).

sealed interface Zone permits Bench, HangingBasket, Bed {}
record Bench(String id, int slots) implements Zone {}
record HangingBasket(String id, double heightCm) implements Zone {}
non-sealed class Bed implements Zone { String id = 'bed-1' }
class RaisedBed extends Bed {}                     // allowed: Bed is non-sealed

println Zone.sealed
println Zone.permittedSubclasses*.simpleName
println Bench.sealed

String describe(Zone z) {
    switch (z) {
        case Bench         -> "bench with ${z.slots()} slots"
        case HangingBasket -> "basket at ${z.heightCm()} cm"
        case Bed           -> "bed ${z.id}"
    }
}
println describe(new Bench('b1', 6))
println describe(new HangingBasket('h1', 180))
println describe(new RaisedBed())
true
[Bench, HangingBasket, Bed]
false
bench with 6 slots
basket at 180.0 cm
bed bed-1

Records are implicitly final, which is why they are the natural leaves of a sealed hierarchy.

The next example shows the other forms: the @Sealed annotation (with a list of classes), sealed classes with nested sealed subclasses, a sealed trait, and inferred permitted subclasses.

import groovy.transform.Sealed
import groovy.transform.NonSealed

@Sealed(permittedSubclasses = [Cat, Dog])
interface Pet {}
final class Cat implements Pet {}
final class Dog implements Pet {}
println Pet.sealed
println Pet.permittedSubclasses*.simpleName

sealed class Shape permits Circle, Polygon {}
final class Circle extends Shape {}
sealed class Polygon extends Shape permits Square {}
final class Square extends Polygon {}
println Shape.permittedSubclasses*.simpleName
println Polygon.permittedSubclasses*.simpleName

sealed trait Msg permits Ping, Pong {}
final class Ping implements Msg {}
final class Pong implements Msg {}
println Msg.sealed

sealed interface Expr {}                           // no permits: same-file subclasses are inferred
record Const(int i) implements Expr {}
record Plus(Expr l, Expr r) implements Expr {}
record Neg(Expr e) implements Expr {}
println Expr.permittedSubclasses*.simpleName
true
[Cat, Dog]
[Circle, Polygon]
[Square]
true
[Const, Plus, Neg]

The annotation form (@Sealed, @NonSealed) exists for tools that do not yet understand the keywords. @SealedOptions(mode=...) selects NATIVE (a real JVM sealed class, which needs JDK 17 or later), EMULATE (the sealed nature is recorded in the annotation only: Groovy enforces it, but Class.isSealed() reports false and the Java compiler ignores it) or AUTO (native on JDK 17 and later). The choice of keyword versus annotation is independent of the mode.

Differences from Java

  • Java requires every permitted subclass to be final, sealed or non-sealed. Groovy defaults an unmarked subclass to non-sealed; non-sealed and @NonSealed are still accepted and make the intent explicit.

  • Groovy does not check that every class named in permittedSubclasses is available when the sealed class is compiled.

  • A type outside the list cannot extend the sealed type. The compiler rejects it:

sealed interface Zone permits Bench, Bed {}
record Bench(String id) implements Zone {}
record Bed(String id) implements Zone {}
record Other(String id) implements Zone {}
c4.groovy: 4: The class 'Other' is not a permitted subclass of the sealed interface 'Zone'.
 @ line 4, column 1.
   record Other(String id) implements Zone {}
   ^

1 error

Algebraic data types and switch

A sealed interface with record implementations is Groovy’s way to model an algebraic data type: a closed set of shapes, each carrying its own data, processed by a switch that tests the class of the value. This evaluator is the classic example.

sealed interface Expr {}
record Const(int i) implements Expr {}
record Plus(Expr l, Expr r) implements Expr {}
record Neg(Expr e) implements Expr {}

int eval(Expr e) {
    switch (e) {
        case Const -> e.i()
        case Plus  -> eval(e.l()) + eval(e.r())
        case Neg   -> -eval(e.e())
    }
}
println eval(new Plus(new Const(3), new Neg(new Const(1))))
println new Plus(new Const(3), new Neg(new Const(1)))
2
Plus[l=Const[i=3], r=Neg[e=Const[i=1]]]

Calling e.i() in the first arm works because the call is resolved dynamically on the actual object; there is no Java-style type pattern (case Const c) that would give e a narrowed static type in 6.0.

What exhaustiveness does and does not check

In Groovy 6.0 the position of a switch decides what an unmatched value does. In statement position, including as the last statement of a method, nothing is thrown and the result is null. In an expression position (a declaration, a return, an argument) an unmatched selector throws IllegalStateException.

sealed interface Zone permits Bench, Bed {}
record Bench(String id) implements Zone {}
record Bed(String id) implements Zone {}

def pick = { Zone z -> def s = switch (z) { case Bench -> 'bench' }; s }
println pick(new Bench('a'))
try { pick(new Bed('b')) } catch (IllegalStateException e) { println e.class.simpleName + ': ' + e.message }

String stmt(Zone z) { switch (z) { case Bench -> 'bench' } }   // statement position
println stmt(new Bed('x'))
bench
IllegalStateException: the switch expression does not cover the value Bed[id=b]
null

Under @TypeChecked or @CompileStatic, a switch expression without a default arm is a compile-time error unless it covers a complete enum. The checker does not currently use the permits list, so a switch over a sealed hierarchy that names every subclass is still rejected without a default:

import groovy.transform.CompileStatic

sealed interface Zone permits Bench, Bed {}
record Bench(String id) implements Zone {}
record Bed(String id) implements Zone {}

@CompileStatic
String label(Zone z) {
    String s = switch (z) {
        case Bench -> 'bench'
        case Bed   -> 'bed'
    }
    s
}
println label(new Bed('a'))
c6.groovy: 9: the switch expression does not cover all possible input values
 @ line 9, column 16.
       String s = switch (z) {
                  ^

1 error

The practical pattern is a default arm that fails loudly, which keeps the switch an expression and still catches a new subclass at run time. An enum switch needs no default once every constant is covered:

import groovy.transform.CompileStatic

sealed interface Zone permits Bench, Bed {}
record Bench(String id) implements Zone {}
record Bed(String id) implements Zone {}
enum Season { SPRING, SUMMER }

@CompileStatic
String label(Zone z) {
    String s = switch (z) {
        case Bench -> 'bench'
        case Bed   -> 'bed'
        default    -> throw new IllegalStateException("unexpected $z")
    }
    s
}
@CompileStatic
String task(Season s) {
    String r = switch (s) { case Season.SPRING -> 'sow'; case Season.SUMMER -> 'water' }
    r
}
println label(new Bed('a'))
println task(Season.SUMMER)
bed
water

For the enum side see Enums & Annotations; for the general switch rules see Control Structures.

Where the book is outdated

Groovy in Action (publisher page in the references) predates records and sealed types, which arrived in Groovy 4.0 (2022). Look at the current guide and the Groovy 6.0 release notes for these, and read the older @Immutable, @Canonical and @TupleConstructor sections as the pre-record way to get the same effect. Related pages: Classes, Fields & Properties, Traits (sealed traits), AST Transformations: Code Generation & Class Design (@Immutable, @TupleConstructor) and What’s New & Migrating from Older Versions.