GEP-19


Metadata
Number

GEP-19

Title

Structural Pattern Matching in switch

Version

3

Type

Feature

Status

Draft

Leader

Paul King

Created

2026-04-25

Last modification

2026-10-23

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 overlaps — when 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.

A record with no components is matched by empty parentheses:

sealed interface Opt permits Some, None {}
record Some(Object value) implements Opt {}
record None() implements Opt {}

switch (opt) {
    case Some(var v) -> "some $v"
    case None()      -> 'none'
}

Empty parentheses form a pattern only after a capitalised type name, optionally qualified (case Opt.None() ->), so case foo() remains a call.

Two narrow carve-outs result. 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. Likewise case Foo() ->, where Foo names a zero-argument method, called Foo() before and is now a zero-component record pattern. The lower-case spellings case foo(_) -> and case foo() -> are unaffected and still call foo. To keep a call to a capitalised method, quote its name or give it a receiver: case 'Foo'() -> or case this.Foo() ->. A GString name, as in case "$name"() ->, is always a call.

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.

Position also decides control transfer, as in Java. In a statement-position arrow switch, return is an ordinary return, break leaves the switch and continue targets the enclosing loop; a switch expression rejects all three, including inside a statement switch nested in one of its arms. Groovy rejected them in every arrow arm until the reference implementation lifted the restriction for statements. That matters here because pattern labels are arrow-only: without it there would be no way to return out of a matching arm.

Exhaustiveness

Position Must be exhaustive? Notes

Expression

Always

Settled in Groovy 6 (GROOVY-12255), independently of patterns: a compile-time error under @TypeChecked / @CompileStatic, whatever the labels.

Statement, with a pattern label

Yes

Matches JEP 441, which requires exhaustiveness of a statement only where it uses a pattern label. The reference implementation makes this an error too; see Remaining work.

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.

Any of the following satisfies it:

  • a default;

  • all constants of an enum;

  • an unguarded type pattern whose type covers the selector type, such as case Object o. A bare class-literal label such as case Object does not, since it is an ordinary isCase test;

  • labels that together cover a closed sealed hierarchy. Following GROOVY-12447, only a native sealed type counts as closed (an emulated one does not); a concrete sealed class needs a label for itself; and a permitted subtype that is itself an abstract sealed class or sealed interface may instead be covered through its own permitted subtypes.

A pattern counts toward coverage only when it provably matches every value of its type:

Pattern Counts?

Unguarded type pattern case T t

Yes, for T

Record pattern whose components are all var / def bindings or _, e.g. case Circle(var r), or that has no components, e.g. case None()

Yes, for the record

Record pattern with a typed component on a primitive record component of the same type, e.g. case Circle(double r)

Yes: the component can never be null

Record pattern with a typed component on a reference record component, e.g. case Box(Object o)

No: a typed element does not match a null component. Java counts this case; see Remaining work

Record pattern with a nested pattern, or over a non-record toList() deconstructable (arity unknown at compile time)

No

List pattern […​] or [var…​ rest]

Yes, when the selector is statically a list, array or Iterable

Any other list pattern; any map pattern

No

Any pattern with a when guard

No

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

Expression proven exhaustive without a default, where an enum constant or sealed subtype was added after the switch was compiled

On a Java 21+ target, java.lang.MatchException, as in Java (JEP 441). On a 17 target, IncompatibleClassChangeError for the enum case and IllegalStateException otherwise

Bracket-form declaration

IllegalArgumentException

The MatchException choice is made from the target bytecode, not from the JDK running the compiler or the program. The exception is referenced by name in the generated code, so a class compiled for 21 throws it on every JDK it can load on, and a class compiled for 17 never does. This needs no change to Groovy’s minimum JDK. Neither exception is meant to be caught; a default arm that throws gives a defined exception on every target.

A null subject is not an error, as set out under Features: it is matched only by an explicit case null label, and otherwise in statement position it falls through and in expression position it raises IllegalStateException, on every target. 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. Like MatchException it needs a Java 21+ target, not a higher minimum JDK: the bootstrap is referenced by name, as record codegen already does for ObjectMethods, and a 17 target would keep the nested-instanceof shape above, which is what the bootstrap degenerates to for small label counts. Its labels are limited to classes, strings, integers and enum constants, so only a switch whose arms are all type or record patterns could use it.

  • 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 an error, as Groovy 6 already does for switch expressions; for a pattern switch statement that severity is still open, see Remaining work. What satisfies exhaustiveness is set out under position and 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 is adopted on a Java 21+ target; Java’s case null rules 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 (statements only)

For switch expressions this is settled by Groovy 6: a non-exhaustive expression is an error under the type checker. What remains open is the severity for a pattern switch statement; see 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 Foo() → (capitalised)

Yes — now a zero-component record pattern (carve-out)

case 'Foo'() → / case this.Foo() →

Yes — legacy call label, unchanged

case Foo(): (colon form)

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.

  • case Foo() ->, where Foo names a zero-argument method rather than a type: previously a call label, now a zero-component record pattern on the type Foo (see Record patterns). Unlike the other carve-outs this one is plausible in real code, so it fails loudly: with no type Foo in scope the label is a compile-time error ("unable to resolve class Foo") rather than a silent change of meaning. Only a capitalised name is affected; case foo() -> still calls foo, and case 'Foo'() -> or case this.Foo() -> keeps the call.

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 [:]:, and case Foo():, which is still a call in a switch statement or a switch expression.

No realistic program relies on the first two legacy meanings; they are itemised for completeness. The third is the one with real exposure, which is why it is a compile-time error rather than a silent change.

_ 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.

Several items earlier drafts listed here are now settled: severity for switch expressions (an error, from Groovy 6), coverage by record and total list patterns, MatchException on a Java 21+ target, and return / break / continue in statement-position arrow arms. Each is described in position and exhaustiveness. None of them needs a higher minimum JDK for Groovy 7.

Severity for pattern switch statements

The reference implementation reports a non-exhaustive pattern switch statement as an error, following JEP 441. The alternatives are a warning, or no check at all, so that an unmatched value falls through silently as in every other Groovy statement. Erroring costs no compatibility, since pattern syntax is new, and matches Java. Whichever is chosen applies only to type-checked code: dynamic Groovy has no exhaustiveness analysis, so a non-exhaustive pattern statement falls through there either way.

Typed components on reference-typed record components

This is the one place where the coverage analysis still rejects a switch Java accepts. With sealed interface S permits P and record P(Object o), the single arm case P(Object o) -> covers S in Java, because there a nested type pattern that is total for the component type also matches null. Under this proposal a typed element does not match a null component (see Features), so the pattern can fail and a default is required. Adopting Java’s rule would mean a typed element matches null exactly when its type is total for the component, which is a decision about null semantics inside patterns rather than about exhaustiveness.

Dominance

A pattern dominated by an earlier label is a warning here and an error in Java. GROOVY-12447 already makes an exact duplicate type label an error, so case Circle after case Circle is an error while case Circle c after case Shape s is a warning. The two should probably agree.

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.