GEP-19


Metadata
Number

GEP-19

Title

Structural Pattern Matching in switch

Version

2

Type

Feature

Status

Draft

Leader

Paul King

Created

2026-04-25

Last modification

2026-09-14

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 switch and instanceof pattern 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 isCase and structural patterns is parser-decidable and based solely on the presence of binding markers.

  • Java alignment where syntax overlapswhen guards, 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, var and def are interchangeable (matching Groovy’s existing local-variable convention and GEP-20), and rest slots accept the shortcut …​ (or …​ ident) for var…​ _ (or var…​ ident).

  • Shared bracket-form grammar with assignment (deferred) — the pattern grammar would be accepted by def […​] = expr declarations as by case […​] → 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 Deconstructable strategy. When Java’s deconstructor JEP ships, surface forms like case 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 n elements matches values of exactly n elements.

  • With one rest: a pattern of n fixed elements plus a rest binding matches values of at least n elements. Rest may appear in any single position. Multiple rest bindings at the same level are a compile error.

Matching rules:

  • Literal elements (e.g. 1 in [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 List holding the collected elements; array and other Iterable inputs are materialised, and a matched List is never aliased by a rest binding.

Type rules:

  • An Iterable<T> input that is not a List is 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 as T and the rest binding as List<T> (a planned refinement); bindings are otherwise Object and List.

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 Type(…​) record patterns

_

No expression-grammar collision; matches Java 22 (JEP 456)

Inside […​] list and [k: v] map patterns

var _ or def _

Bare _ is a legal identifier in expression grammar today; explicit var _ / def _ avoids re-interpreting valid programs

Rest discard

…​ (shortcut) or var…​ _ / def…​ _ (canonical)

See Rest bindings below

Top-level case label

default → (preferred) or case var _ ->

Bare case _ -> retains its legacy meaning when _ is in scope as an identifier

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 h or def 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 (def (…​))

Positional, rest with *, map-style with key:. GEP-20, Groovy 6.x.

Bracket (def […​])

Full pattern grammar — nested patterns, wildcard _, type narrowing, record patterns. This proposal, Groovy 7.0.

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, uses iterator() fallback, t is the iterator (matching GEP-20’s parens form).

  • case [var h, var…​ t] → …​ against an iterator — does not match; list patterns require List, array, or an Iterable that 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 switch whose 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 trailing if or colon-form switch.

  • A switch in 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 default, a complete set of enum constants, an unconditional pattern, or type tests covering a sealed hierarchy all satisfy it.

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 default. This is what keeps every switch statement written before patterns existed compiling unchanged.

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 switch

Expression

IllegalStateException naming the selector

Exhaustive enum expression, where a constant was added after the switch was compiled

IncompatibleClassChangeError. Java throws MatchException here since JEP 441; Groovy cannot while its baseline predates Java 21 (see Remaining work)

Bracket-form declaration

IllegalArgumentException

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 instanceof checks 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 the when guard as the innermost check. Measured against closure-based isCase dispatch this is roughly 6x faster under @CompileStatic (arms become plain INSTANCEOF branches) 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.typeSwitch invokedynamic dispatch — on parity with how Java compiles pattern switch — remains future work for when a JDK 21+ bytecode target is available; the nested-instanceof shape above is what that bootstrap degenerates to for small label counts.

  • Bindings are ordinary local variables (the instanceof binding 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 default branch, 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 List or 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 instanceof (JEP 394, 16)

Shipped

Type and record patterns in instanceof added in this proposal

Record patterns (JEP 440, 21)

Shipped

Added in this proposal

Pattern matching for switch (JEP 441, 21)

Shipped

Arrow-form switch, this proposal. Exhaustiveness follows JEP 441’s split between statement and expression position; MatchException and case null are not adopted, see position and exhaustiveness

when guards (Java 21)

Shipped

Adopted verbatim

Unnamed patterns _ (JEP 456, 22)

Shipped

Adopted in Type(…​); var _ / def _ elsewhere (avoids identifier collision); …​ for rest discard (Groovy-native)

Primitive patterns (JEP 507)

Preview

Supported (incubating): case int i tests the wrapper type and binds the primitive, matching JEP 507’s semantics for a reference-typed subject (an Integer matches int; no widening or narrowing). Semantics may be tweaked if JEP 507 shifts before finalising

Deconstructors for arbitrary classes

Draft

Deconstructable lowering accommodates this when surface syntax is specified

Array patterns

Discussed

If Java picks T[] {…​} syntax, Groovy will accept it as an alternative surface for case […​]

Excluded and deferred features

Feature Status Rationale

Bracket-form assignment (def […​] = expr)

Deferred

Largely overlaps the shipped GEP-20 parens form (nested patterns, type tests and strict arity are the only deltas); strict-match (IllegalArgumentException) vs null-padding philosophies and switch-vs-assignment rest semantics diverge; no Java statement-pattern anchor yet. Held for further review — see Bracket-form assignment.

*name / *_ rest sugar (in bracket-form patterns / switch)

Deferred

The triple-dot shortcut (…​, …​ ident) supersedes both. is GEP-20’s canonical rest spelling in def (…​) = …​ (no expression collision there) but inside […​] patterns collides with list-literal spread, so this proposal uses …​ instead. The GEP-20 spellings (*ident, bare key: ident) could be accepted inside a […​] pattern once another binding form elsewhere in the literal has triggered pattern mode — they are deferred rather than blocked, to keep binding markers locally explicit for readers and to leave the design space open for a future relaxation if usage warrants it.

Or-patterns (p1 | p2)

Deferred

Existing comma-separated case labels (case 1, 2, 3 →) cover the constant case; pattern alternation is rarely needed and ambiguous with list-element commas

Type-prefixed list patterns (e.g. case List<Integer>[var h, var…​ t])

Deferred

Awaits clarity from Java’s array pattern direction; for now, type information flows from the switch input

Patterns in for loops

Deferred

for (Map.Entry e in map.entrySet()) is already idiomatic; structural unrolling is a future consideration

Patterns in catch clauses

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 case _ → at top level

Not planned

Conflicts with legacy _-as-identifier semantics; users write default -> or case var _ ->

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)?

case [1, 2, 3] →

Yes — legacy isCase, unchanged

case [name: 'x'] →

Yes — legacy Map.isCase, unchanged

case [_] →

Yes — legacy (list containing _)

case foo() → / case foo(bar) →

Yes — legacy call label, unchanged

case foo(_) → (lower case)

Yes — legacy call label, unchanged

case []: / case [:]: (colon form)

Yes — legacy, never matched, unchanged

case [var h, var…​ t] →

No — new, claims unused grammar

case [def h, def…​ t] →

No — new (Groovy-native equivalent)

case […​ t] → / case […​] →

No — new (…​ shortcut / flipper)

case [Integer h, var…​ t] →

No — new

case String s →

No — new

def [var h, var…​ t] = list

No — new (bracket form)

def [name: var n, age: var a] = person

No — new (bracket form)

def (h, *t) = list

Yes — covered by GEP-20 in Groovy 6.x

def (name: n, age: a) = person

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() ->, where Foo names a method rather than a type and is in scope as a variable: previously a call label passing , now a record pattern on the type Foo (see _Record patterns). Only the capitalised spelling is affected; case foo(_) -> still calls foo, and other argument shapes such as var x do 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

def (…​, _, …​) = expr (parens form, GEP-20)

Identifier — unchanged from today

def [var _, …​] = expr (bracket form, deferred)

Wildcard — bind-and-discard

case [var _, …​] → (list pattern in switch)

Wildcard — bind-and-discard

case Type(_, _) → (record pattern)

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 default, or an unconditional type pattern covering the selector

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 using IncompatibleClassChangeError for 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.