GEP-19
WARNING: Material on this page is still under development! We are currently working Groovy 6.0 and this proposal targets Groovy 7.0. The final version of this proposal may differ significantly from the current draft, but having this draft available allows us to gather early feedback, future align design decisions in Groovy 6 as best we can, and iterate on the design. We welcome feedback and discussion, but please keep in mind that the details are not yet finalized.
A reference implementation of the
switchandinstanceofpattern features exists and is kept ported to current Groovy master; learnings from that work are folded into this document. It is a draft: where this page describes what the implementation does, that records where the work has got to rather than a settled decision. Bracket-form assignment is deferred pending further review (see below).
Abstract
This GEP extends Groovy’s switch expression with structural pattern
matching for lists, maps, records, and types, aligning with Java’s
pattern-matching trajectory (JEPs 440, 441, 456, 507).
The proposal adds list patterns ([var h, var… t]), map patterns
([name: var n, var… rest]), record patterns (Type(var x, var y)),
type patterns with binding (String s), guard clauses (when), and a
unified wildcard form ( inside record patterns; var _ elsewhere).
The same pattern grammar is additionally proposed for
def […] = … bracket-form declarations, providing a consistent
destructuring grammar between switch case labels and assignment for
users who want it; that part of the proposal is deferred pending
further review (see _Bracket-form assignment). The everyday parens
form (def (…) = …) is covered separately by GEP-20 and ships in
Groovy 6.x.
All existing switch semantics are preserved: legacy isCase matching
(constants, ranges, regex, classes, collections, maps, closures) compiles
exactly as it does today. Pattern matching is opt-in via the binding
markers that distinguish a structural pattern from a legacy case label.
Motivation
Groovy’s switch already exceeds Java’s classic switch through the
isCase protocol — case labels can be classes, ranges, regex patterns,
collections, and closures. What remains absent is structural
decomposition: type-narrowing bindings, record deconstruction, and the
destructuring of the most common data shapes (lists and maps). This
proposal adds all four together, aligned with Java’s pattern-matching
trajectory (JEPs 440, 441, 456, 507).
Languages that have shipped this — Scala, Rust, Swift, Kotlin (limited), JavaScript (via destructuring), Python (via PEP 634), and Jactl — show that destructuring in switch / match is one of the most-used pattern matching idioms once available. Without it, the canonical functional expression of recursive list algorithms remains awkward in Groovy:
// Without structural matching
def qsort(list) {
if (list.size() <= 1) return list
def h = list[0]
def t = list.drop(1)
qsort(t.findAll { it < h }) + [h] + qsort(t.findAll { it >= h })
}
// With structural matching (this proposal)
def qsort(x) {
switch (x) {
case [] -> []
case [var h, var... t] -> qsort(t.findAll { it < h }) + [h] + qsort(t.findAll { it >= h })
}
}
Java is moving in the same direction. Beyond what has shipped, JEP
drafts cover deconstructor methods for arbitrary classes, primitive
patterns in switch (JEP 507 preview), and array patterns under
discussion. Aligning Groovy’s surface syntax with Java’s where they
overlap — and lowering through a uniform internal representation —
keeps Groovy forward-compatible without paying for it twice.
Design principles
-
No legacy regression — every program valid under Groovy 6 compiles with identical semantics in 7. The disambiguation between legacy
isCaseand structural patterns is parser-decidable and based solely on the presence of binding markers. -
Java alignment where syntax overlaps —
whenguards, type patterns, record patterns, and_unnamed pattern syntax match Java verbatim. Rest bindings reuse Groovy’s existing varargs syntax (Type… ident), the form Java is most likely to adopt for variadic deconstructor components. -
Groovy-native where Java has no analogue — list and map literal patterns (
[…],[k: v]) have no Java counterpart because Java has no list or map literals. Groovy can lead here without future drift risk. In all binding positions,varanddefare interchangeable (matching Groovy’s existing local-variable convention and GEP-20), and rest slots accept the shortcut…(or… ident) forvar… _(orvar… ident). -
Shared bracket-form grammar with assignment (deferred) — the pattern grammar would be accepted by
def […] = exprdeclarations as bycase […] →labels. The parens form (def (…) = …) covered by GEP-20 remains the canonical surface for everyday destructuring; the bracket form would be the opt-in bridge to switch pattern grammar. This feature is deferred pending further review (see Bracket-form assignment). -
Forward-compatible lowering — list and map patterns desugar through an internal
Deconstructablestrategy. When Java’s deconstructor JEP ships, surface forms likecase List.of(var h, var… t) →slot in as additional spellings of the same lowering — no architectural change needed.
Features
Pattern case labels are supported in the arrow form (case … ->)
only; a pattern with a colon label is a compile-time error. Pattern
and legacy labels may mix freely within a single (arrow-form) switch,
each label keeping its own semantics.
Patterns never match null: a null subject simply falls through to
the next arm (or default). Groovy’s switch thus keeps its existing
null-friendliness — a null subject is not an error, and the legacy
case null label still matches it — where Java’s pattern switch
throws NullPointerException for an unmatched null. Within a
pattern, a typed element does not match a null value, while var /
def bindings and _ wildcards do.
Type patterns with binding
A type pattern matches if the switch value is an instance of the named type, binding a new local variable in the case body:
switch (obj) {
case String s -> s.toUpperCase()
case Integer i -> i * 2
case Number n -> n.doubleValue()
default -> null
}
The bound name is narrowed to the declared type within the case body
and any when guard. @CompileStatic and @TypeChecked see the
narrowed type within the binding’s scope.
Record patterns
Record patterns deconstruct record-typed values positionally.
Component positions accept nested patterns, including the unnamed
pattern _:
record Point(int x, int y) {}
record Line(Point start, Point end) {}
switch (obj) {
case Point(int x, int y) -> "$x,$y"
case Point(int x, _) -> "x=$x"
case Line(Point(_, _), Point p2) -> "ends at $p2"
}
Bare _ is permitted inside Type(…) because record-pattern
component grammar is not expression grammar. A call-shaped label is
read as a record pattern only when its arguments are pattern-shaped,
so case foo(), case foo(bar) and case lower('ABC') keep their
legacy isCase semantics.
One narrow carve-out results. Argument forms such as var x do not
parse today, so claiming them breaks nothing; bare does, because it
is an ordinary identifier. A label spelled case Foo() ->, where the
name is capitalised and is in scope as a variable, therefore changes
meaning: it called Foo() before and is now read as a record pattern
on the type Foo. The lower-case spelling case foo(_) -> is
unaffected and still calls foo.
A value deconstructs positionally if it is a record — native or
emulated, Groovy or Java — or if it provides a toList() method,
which is the current spelling of the Deconstructable strategy (see
Compilation).
List patterns
List patterns destructure List, array, and Iterable values
structurally. Element positions accept literals, type patterns,
var/def bindings, nested patterns, and a single rest binding:
switch (xs) {
case [] -> "empty"
case [...] -> "non-empty list (any shape)"
case [var only] -> "single: $only"
case [var h, var... t] -> "h=$h, t=$t"
case [def h, ... t] -> "h=$h, t=$t (using shortcuts)"
case [Integer h, var... t] -> "int head $h"
case [var first, var... middle, var last] -> "$first..$last"
case [1, var x, ...] -> "starts with 1, then $x"
}
var and def are interchangeable in any binding position. The
shortcut … is accepted for var… (and … ident for
var… ident); see _Rest bindings below.
Element count rules:
-
Without rest: a pattern of
nelements matches values of exactlynelements. -
With one rest: a pattern of
nfixed elements plus a rest binding matches values of at leastnelements. Rest may appear in any single position. Multiple rest bindings at the same level are a compile error.
Matching rules:
-
Literal elements (e.g.
1in[1, var x, …]) match their corresponding element by Groovy equality (==). -
Typed elements and typed rest bindings are runtime type tests: a mismatching (or
null) element fails the match rather than casting. -
A rest binding always binds a new
Listholding the collected elements; array and otherIterableinputs are materialised, and a matchedListis never aliased by a rest binding.
Type rules:
-
An
Iterable<T>input that is not aListis materialised as a list once for matching, so it must be traversable non-destructively. -
Untyped bindings are dynamically typed. For
List<T>input, the static type checker may in future infer untyped element bindings asTand the rest binding asList<T>(a planned refinement); bindings are otherwiseObjectandList.
Map patterns
Map patterns destructure Map values by key. Keys are compile-time
constants — identifier keys, string or number literals; a computed key
(a parenthesised expression or GString) is a compile-time error in a
map pattern, though it remains valid in a legacy map label. Values are
arbitrary patterns; literal values (e.g. 'circle' below) match by
Groovy equality (==):
switch (m) {
case [name: var n, age: var a] -> "person $n, $a"
case [type: 'circle', radius: var r] -> "circle r=$r"
case [name: String n, ... rest] -> "named $n; others=$rest"
case [name: def n, ...] -> "any named map (others discarded)"
}
Map pattern semantics are open: a pattern matches if all named keys are present and their value patterns match. Extra keys in the map are ignored unless captured by a rest binding. Closed semantics — "exactly these keys" — is expressed via a guard:
case [name: var n] when ((Map) m).size() == 1 -> ...
The rest binding var… rest in a map pattern binds a new Map of
the entries not matched by named keys (the matched map is never
aliased). A named key whose value pattern is a wildcard still requires
the key to be present: [name: var _] matches a map with a name key
whatever its value (even null), but not a map lacking one.
Empty literals
The empty list literal [] and empty map literal [:] in case-label
position are always treated as patterns matching empty collections of
the appropriate kind:
case [] -> "empty list"
case [:] -> "empty map"
The legacy isCase semantics for these — never matching anything,
because [].contains(x) and [:].get(x) are always false / null —
have no practical use, so claiming the pattern interpretation removes
no functionality. Because patterns require the arrow form, the
reinterpretation applies to case [] -> and case [:] -> only; the
colon-form spellings case []: and case [:]: keep their legacy
never-matching meaning, so nothing that compiles today stops
compiling.
Wildcards
The unnamed pattern matches any value without binding it. The form depends on context:
| Position | Form | Reason |
|---|---|---|
Inside |
|
No expression-grammar collision; matches Java 22 (JEP 456) |
Inside |
|
Bare |
Rest discard |
|
See Rest bindings below |
Top-level case label |
|
Bare |
Rest bindings
Rest bindings collect remaining elements into a single binding. The
canonical form is var… ident (or def… ident / Type… ident),
reusing Groovy’s existing varargs token sequence — the same shape as
int… args in method parameter lists:
case [var h, var... t] -> ... // canonical
case [def h, def... t] -> ... // equivalent (var/def interchangeable)
case [var h, Integer... t] -> ... // typed rest
case [var h, var... _] -> ... // discarded rest, canonical
The var… t spelling reuses an existing Groovy token sequence
(varargs in method declarations) and matches the shape Java is most
likely to adopt for variadic deconstructor components — keeping the
case List.of(var h, var… t) surface form (when Java specifies
it) consistent with the list-literal form.
… shortcut
The triple-dot … is accepted as a shortcut wherever var… (or
def…) appears, reflecting that the leading var / def is
ceremonial once … has signalled the rest position:
case [var h, ... t] -> ... // shortcut for `var... t`
case [var h, ...] -> ... // shortcut for `var... _`
case [...] -> ... // matches any list
Both … ident and bare … flip a […] from legacy to pattern
interpretation on their own. The … token has no expression-position
meaning today (it is reserved only for varargs in method declarations
and enhanced-for index variables), so claiming it as pattern grammar
reinterprets no existing program.
Bare … matches any list (including empty), since the rest can
absorb zero elements. It pairs naturally with case [] ->:
case [] -> "empty"
case [...] -> "non-empty (the empty case is matched above)"
The typed shortcut Integer… t is not further shortened to
… t, because the type ascription carries semantic content (a
runtime element-type check) that bare … would discard.
For reference, GEP-20’s parens-form def (…) = … uses *ident
and * for the same role — the parens-form analogue of
var… ident and var… _ (or the … shortcut). The *
spelling is not adopted inside […] patterns here because *
collides with list-literal spread in expression position; see
_Excluded and deferred features for the relationship between the
two and the conditions under which the parser could in principle
accept the * spelling inside an already-disambiguated pattern.
Guards
when guards apply to patterns:
case Integer i when i > 0 -> "positive"
case [var h, var... t] when t.size() > 5 -> "long list head=$h"
case Point(int x, int y) when x == y -> "diagonal point"
Guards may reference any binding from the same pattern. Guards are evaluated once after pattern matching succeeds; arms with failing guards fall through to the next arm.
when guards apply to pattern labels only. A guard on a
legacy-shaped label (e.g. case [1, 2, 3] when g ->, where the
literal has no binding form) is a compile-time error rather than
silently choosing between containment and equality semantics; adding
a binding form makes the label a pattern.
Patterns in instanceof
Type patterns and record patterns extend to instanceof, mirroring
Java:
if (obj instanceof String s) {
println s.length()
}
if (point instanceof Point(int x, int y)) {
println "$x, $y"
}
List and map patterns are not valid in instanceof because they have
no type at the head. To test "is this value shaped like …", use a
single-arm switch expression returning a boolean.
One divergence from Java in instanceof position: a primitive-typed
record component (e.g. x in instanceof Point(int x, _)) binds its
wrapper type (Integer), because the instanceof lowering lives in
expression context where the primitive re-declaration used by the
switch lowering is unavailable. In switch case labels the declared
primitive type is bound, matching Java (JEP 440). The difference is
only visible to the static type checker (e.g. overload selection);
values are identical.
Disambiguation rule
A […] or [k: v, …] case label is parsed as a structural
pattern if and only if at least one of the following holds:
-
The literal is empty (
[]or[:]). -
Some element (or value, in maps) is a binding form:
var <ident>/def <ident>e.g.
var hordef h(interchangeable)var _/def _unnamed binding
<Type> <ident>e.g.
Integer h<Type> _type-checked unnamed binding
var… <ident>/def… <ident>rest binding
… <ident>rest binding (shortcut for
var… <ident>)<Type>… <ident>typed rest binding
var… _/def… _rest discard
…rest discard (shortcut for
var… _) -
Some element is a nested pattern (record pattern with at least one unambiguous binding form among its components, nested list pattern, nested map pattern).
Otherwise, the label retains its legacy isCase semantics — exactly
today’s behaviour.
The rule is parser-local and does not depend on surrounding scope. Every binding form listed above currently fails to parse as a Groovy expression in list-literal or map-literal value position, so claiming them as pattern grammar does not change the meaning of any program valid in Groovy 6.
The bare identifier _ continues to parse as an identifier in
expression position — case [_] -> therefore retains its legacy
meaning. Users wanting a single-element wildcard pattern write
case [var _] ->.
A nested […] element inside a pattern follows the same rule: a
pattern-shaped nested literal is a nested pattern, while a plain
nested literal (e.g. the inner [1, 2] in [[1, 2], var x]) is a
literal element matched by equality.
Bracket-form assignment
|
Note
|
This feature is deferred pending further review and is not
part of the reference implementation. Relative to the rest of this
proposal it adds the least over the shipped GEP-20 parens form
(nested patterns, runtime type tests and strict arity are the only
deltas), it forks rest-binding semantics between switch and
assignment contexts (see the lowering below), it introduces a second
declaration-destructuring syntax with the opposite failure philosophy
to the parens form (strict IllegalArgumentException vs null
padding), and Java’s anticipated statement-level ("let") patterns may
yet suggest an aligned surface and failure semantics. The design
below is retained as the current straw-man for that review.
|
In Groovy 7.0, def […] = expr would accept the same pattern
grammar as switch case labels. This is the bridge between switch and
assignment destructuring:
def [var h, var... t] = list // canonical
def [def h, ... t] = list // equivalent (shortcuts)
def [Integer h, var... t] = list // typed head
def [var first, var... middle, var last] = list // rest in middle
def [name: var n, age: var a] = person // map destructuring
def [Point(int x, int y), ... rest] = list // nested record pattern
The bracket form supports the full switch pattern grammar — nested
patterns, type bindings with narrowing, wildcard _ (via var _
or def _), typed rest, and record patterns. A binding marker
(var, def, a type, or … for rest) is required inside […]
for the same disambiguation reason it is in case labels: without
one, the literal would parse as today’s legacy list literal.
The parens form (def (…) = …) is a separate, simpler grammar
covered by GEP-20 and shipped in Groovy 6.x. It remains canonical for
everyday destructuring (positional bindings, simple rest, map-style
keys) and does not require var markers because the surrounding def
already declares the names. The two forms coexist:
| Form | Capabilities |
|---|---|
Parens ( |
Positional, rest with |
Bracket ( |
Full pattern grammar — nested patterns, wildcard |
Lowering for bracket-form assignment
The bracket form lowers via the same Deconstructable strategy as
switch case labels, with one accommodation: tail-rest forms accept
the same RHS contract as GEP-20’s parens form (getAt(IntRange) or
iterator() fallback), so iterators and unbounded sources work in
assignment context. List patterns in switch case labels do not
accept iterators — pattern matching against an iterator would
destructively consume it, which is surprising for a match operation.
The divergence between contexts is therefore:
-
def [var h, var… t] = iter— accepted, usesiterator()fallback,tis the iterator (matching GEP-20’s parens form). -
case [var h, var… t] → …against an iterator — does not match; list patterns requireList, array, or anIterablethat can be materialised non-destructively.
A failed match in a bracket-form declaration throws
IllegalArgumentException. Partial matching is via switch.
Position, exhaustiveness and unmatched values
Groovy 6 settled how a switch decides whether it produces a value, and
pattern matching inherits that rule rather than introducing one of its
own. As in Java, the arrow only decides fall-through; the position
decides whether a value is produced
(GROOVY-12399,
GROOVY-12408).
-
A
switchwhose value is not used is a statement, whatever its arm shape. That includes one written as the last statement of a method, closure or script: the implicit return applies to the statement, as it does to a trailingifor colon-formswitch. -
A
switchin expression position —return switch (…) {…}, an assignment, an argument, or the value of an arrow arm — is a switch expression.
Exhaustiveness
| Position | Must be exhaustive? | Notes |
|---|---|---|
Expression |
Always |
Settled in Groovy 6
(GROOVY-12255),
independently of patterns. A |
Statement, with a pattern label |
Yes |
Matches JEP 441, which requires exhaustiveness of a statement only where it uses a pattern label. |
Statement, without a pattern label |
No |
Constant, class-literal and range labels never demand a |
Two distinctions are worth keeping apart. What triggers the requirement is a pattern label and nothing else; what satisfies it is any coverage the compiler can prove, so a class-literal label can help satisfy a check that a pattern has triggered without being able to trigger one itself.
Whether case null should also trigger the requirement, as it does in
Java, is left open. Java has that rule because case null is itself new
in Java 21; in Groovy it is long-standing and matches today, so adopting
the trigger would reject working code. See Remaining work.
Unmatched values at runtime
| Form | When nothing matches |
|---|---|
Statement |
Nothing happens; control continues after the |
Expression |
|
Exhaustive enum expression, where a constant was added after the switch was compiled |
|
Bracket-form declaration |
|
A null subject is not an error, as set out under Features: it
matches no constant or type label, so in statement position it falls
through and in expression position it raises IllegalStateException,
while a legacy case null label still matches it. Java instead throws
NullPointerException unless a case null label is present. Groovy’s
labels match through isCase, for which null is an ordinary value
that simply matches nothing, so the Java behaviour is not adopted.
Compilation
List and map patterns lower through an internal Deconstructable
strategy that performs:
-
a type check (
instanceof List,instanceof Map,instanceof T[], etc.), -
size or key checks for the named elements,
-
component extraction (
get(int),subList,containsKey/get, key-set difference for rest), -
binding assignment.
Record patterns lower via the same Deconstructable strategy: a type
check on the record class, component extraction via the record’s
accessor methods, and recursive lowering for nested component patterns.
When Java’s deconstructor JEP ships, surface forms like
case List.of(var h, var… t) -> and
case Map.of("name", var n) -> are accepted as additional spellings
that lower to the same Deconstructable calls — no re-architecture.
In the reference implementation, everything is parse-time desugaring:
no new AST node kinds and no bytecode-format changes. A value
deconstructs positionally if it is a record — native or emulated,
Groovy or Java, with components resolved reflectively and invoked
through the meta-object protocol so both kinds behave identically —
or if it provides a toList() method. Runtime support lives in the
@Incubating classes RecordPatternSupport, ListPatternSupport
and MapPatternSupport.
Implementation considerations (reflecting the reference implementation):
-
A switch whose case labels are all patterns lowers each arm to nested
instanceofchecks and binding declarations in a per-arm scope — no per-arm closure is allocated, pattern variable names may repeat across arms, and a matched value is destructured exactly once, with thewhenguard as the innermost check. Measured against closure-basedisCasedispatch this is roughly 6x faster under@CompileStatic(arms become plainINSTANCEOFbranches) and 1.4x faster in dynamic code. Switches mixing patterns with legacy labels retain closure-based labels for the pattern arms. -
Emitting
java.lang.runtime.SwitchBootstraps.typeSwitchinvokedynamic dispatch — on parity with how Java compiles pattern switch — remains future work for when a JDK 21+ bytecode target is available; the nested-instanceofshape above is what that bootstrap degenerates to for small label counts. -
Bindings are ordinary local variables (the
instanceofbinding machinery from JEP 394 support); the static type checker propagates narrowed types into them. For dynamic Groovy, untyped bindings are dynamically typed and runtime checks dominate; for@CompileStatic, narrowed types let the JIT see through. -
The static type checker additionally reports an error for a pattern that provably cannot match the switch subject’s static type and for a record pattern whose arity disagrees with the record, and warns for an arm dominated by a preceding label (unreachable). A non-exhaustive switch is currently an error, following what Groovy 6 already does for switch expressions; whether that is the right severity is open, and is discussed under Remaining work. A
defaultbranch, an unconditional pattern, or full sealed-hierarchy coverage each satisfy exhaustiveness. These are static-typing diagnostics only; dynamic Groovy remains permissive. -
Iterable inputs that are not
Listor array are materialised as a list once per match attempt; this is observable for sources with side effects, which must be traversable non-destructively.
Java alignment
| Java feature | Status | Groovy alignment |
|---|---|---|
Pattern matching for |
Shipped |
Type and record patterns in |
Record patterns (JEP 440, 21) |
Shipped |
Added in this proposal |
Pattern matching for |
Shipped |
Arrow-form |
|
Shipped |
Adopted verbatim |
Unnamed patterns |
Shipped |
Adopted in |
Primitive patterns (JEP 507) |
Preview |
Supported (incubating): |
Deconstructors for arbitrary classes |
Draft |
|
Array patterns |
Discussed |
If Java picks |
Excluded and deferred features
| Feature | Status | Rationale |
|---|---|---|
Bracket-form assignment ( |
Deferred |
Largely overlaps the shipped GEP-20 parens form (nested patterns,
type tests and strict arity are the only deltas); strict-match
( |
|
Deferred |
The triple-dot shortcut ( |
Or-patterns ( |
Deferred |
Existing comma-separated case labels ( |
Type-prefixed list patterns (e.g. |
Deferred |
Awaits clarity from Java’s array pattern direction; for now, type information flows from the switch input |
Patterns in |
Deferred |
|
Patterns in |
Not planned |
Multi-catch already covers type unions; structural matching of exception state is rare |
Closed map patterns ("exactly these keys") |
Not planned |
Expressible via guard; dedicated syntax not warranted |
Bare |
Not planned |
Conflicts with legacy |
Exhaustiveness enforcement severity |
Open |
Whether a non-exhaustive switch is an error or a warning is not settled. Erroring matches Java and matches what Groovy 6 already does for switch expressions; warning is safer while the coverage analysis is incomplete, since a pattern the compiler cannot prove exhaustive is rejected even where Java accepts it. A split by how much the compiler knows is proposed in Remaining work |
Compatibility
Backwards compatibility
Every program valid in Groovy 6 compiles with identical semantics in Groovy 7, with the narrow carve-outs itemised below. The disambiguation rule is purely parser-local and is triggered only by syntactic forms that currently fail to parse:
| Form | Parses today (Groovy 6 with GEP-20)? |
|---|---|
|
Yes — legacy |
|
Yes — legacy |
|
Yes — legacy (list containing |
|
Yes — legacy call label, unchanged |
|
Yes — legacy call label, unchanged |
|
Yes — legacy, never matched, unchanged |
|
No — new, claims unused grammar |
|
No — new (Groovy-native equivalent) |
|
No — new ( |
|
No — new |
|
No — new |
|
No — new (bracket form) |
|
No — new (bracket form) |
|
Yes — covered by GEP-20 in Groovy 6.x |
|
Yes — covered by GEP-20 in Groovy 6.x |
Two […] forms have legacy semantics that never usefully match —
case [] → and case [:] → — and are reinterpreted as empty-list
and empty-map patterns respectively. No existing program depends on
these never-matching legacy forms.
Beyond the empty literals, the full list of carve-outs — forms that parse today and change meaning — is:
-
case List<String> l ->(a generic type followed by an identifier): previously a chained comparison expression ((List < String) > l), now a type pattern. The legacy parse cannot usefully evaluate (it compares a class against a class). -
case Foo() ->, whereFoonames a method rather than a type andis in scope as a variable: previously a call label passing, now a record pattern on the typeFoo(see _Record patterns). Only the capitalised spelling is affected;case foo(_) ->still callsfoo, and other argument shapes such asvar xdo not parse today so they claim no existing meaning.
That is the complete list. Colon-form labels are deliberately not on
it: a pattern requires the arrow form, so a colon-form label is never
read as a pattern and keeps whatever it means today, including the
never-matching case []: and case [:]:.
No realistic program relies on either legacy meaning; they are itemised for completeness.
_ semantics across forms
Wildcard _ semantics introduced in this proposal apply inside
[…] list and map patterns, and inside Type(…) record
patterns. The parens-form assignment def (…) = expr (covered by
GEP-20) continues to treat _ as a regular identifier indefinitely,
matching GEP-20’s explicit non-deprecation. Existing idioms such as
def (_, y, m) = Calendar.instance keep compiling unchanged with no
warning in Groovy 7.
| Context | _ meaning |
|---|---|
|
Identifier — unchanged from today |
|
Wildcard — bind-and-discard |
|
Wildcard — bind-and-discard |
|
Wildcard — bind-and-discard (Java-aligned) |
This scoping means GEP-19 introduces no behavioural break for any
existing program: the wildcard semantics live in grammar ([…]
patterns, Type(…) patterns) that does not parse today.
Remaining work
These are open for discussion rather than settled. A reference implementation exists and informs the notes below, but it is a draft and its current behaviour should not be read as a decision.
Severity, and what the compiler can actually prove
The coverage analysis is deliberately conservative in one place: record,
list and map patterns never contribute to it, because a deconstruction
can fail on arity or on a component even when its head type matches. The
consequence is that a switch Java accepts as exhaustive can be rejected
here. For example, with sealed interface S permits P and
record P(Object o), the single arm case P(Object o) -> covers S in
Java but not in the current analysis.
That argues against a single severity for every case:
| What the compiler knows | Analysis | Suggested |
|---|---|---|
Explicit |
Complete |
No diagnostic |
A sealed hierarchy covered by simple type patterns; all constants of an enum |
Complete |
Error |
Any record, list or map pattern present |
Incomplete by construction |
Warning, until component coverage is implemented |
Implementing recursive component coverage — a record pattern is unconditional for its type when all of its component patterns are — would let the third row move up to error.
case null as an exhaustiveness trigger
Discussed in position and exhaustiveness. Adopting Java’s rule would reject working Groovy code; not adopting it leaves a visible difference from Java. Either way it should be a decision rather than an omission.
return, break and continue in arrow arms
Groovy rejects all three inside a statement-position arrow arm, and has since the 4.0 parser; Java allows them. For constant labels the colon form is a workaround, but pattern labels require the arrow form, so under this proposal there is no way to return out of a matching arm. This is not caused by pattern matching, but pattern matching is what makes it hard to live with, so it should be fixed alongside.
Minimum JDK for Groovy 7
Two items depend on it and neither is available below Java 21:
-
java.lang.runtime.MatchException, which JEP 441 specifies when an exhaustive pattern switch matches nothing at runtime. Below 21 Groovy must keep usingIncompatibleClassChangeErrorfor the enum case. -
java.lang.runtime.SwitchBootstraps.typeSwitch, the indy dispatch path the Compilation section describes as desirable. The reference implementation defers it for the same reason.
Pattern arms and statement position
The reference implementation now checks exhaustiveness of a pattern
switch statement, which was previously happening only by accident: before
position became decisive, every arrow switch was an expression and so was
checked on that path. The trigger deliberately keys on pattern labels
alone, since reusing the expression check would demand a default from
every switch statement in existence.