GEP-19


Metadata
Number

GEP-19

Title

Structural Pattern Matching in switch

Version

1

Type

Feature

Status

Draft

Leader

Paul King

Created

2026-04-25

Last modification

2026-04-25

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

Abstract

This GEP extends Groovy’s switch expression with structural pattern matching for lists, maps, and types, building on the record patterns introduced in 5.0 and 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]), type patterns with binding (String s), guard clauses (when), and a unified wildcard form (_ inside record patterns; var _ elsewhere). The same pattern grammar is also accepted in def […​] = …​ bracket-form declarations, providing a consistent destructuring grammar between switch case labels and assignment for users who want it. 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. Groovy 5.0 added record patterns aligned with Java 21’s JEP 440. What remains absent is structural decomposition of the most common data shapes: lists, maps, and arrays.

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 — the pattern grammar is 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 is the opt-in bridge to switch pattern grammar.

  • 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

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 via the same flow analysis already used for record patterns.

Record patterns

Record patterns (introduced in Groovy 5.0) 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 — there is no collision with _ as an identifier in expression position.

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.

Type rules:

  • For List<T> input, untyped element bindings are inferred as T, the rest binding as List<T>.

  • For T[] input, the rest binding is inferred as T[].

  • For other Iterable<T> input, the iterable is materialised as a list once for matching.

  • For Object input, bindings fall back to Object and List.

Map patterns

Map patterns destructure Map values by key. Keys are compile-time constant expressions; values are arbitrary patterns:

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 Map of the entries not matched by named keys.

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.

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.

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.

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

Bracket-form assignment

In Groovy 7.0, def […​] = expr accepts 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.

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 reuse the lowering already present in 5.0. 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.

Implementation considerations:

  • Switch dispatch on JDK 21+ should evaluate java.lang.runtime.SwitchBootstraps.typeSwitch as the dispatch path for arms whose patterns admit it, on parity with how Java compiles pattern switch.

  • Bindings live in synthetic locals; the static type checker propagates narrowed types into them.

  • For dynamic Groovy, bindings are typed Object and runtime checks dominate; for @CompileStatic, narrowed types let the JIT see through.

  • Iterable inputs that are not List or array are materialised as a list once per match attempt; this is observable for iterators with side effects.

Java alignment

Java feature Status Groovy alignment

Pattern matching for instanceof (JEP 394, 16)

Shipped

Type and record patterns valid in instanceof

Record patterns (JEP 440, 21)

Shipped

Already in Groovy 5.0; extended in this proposal

Pattern matching for switch (JEP 441, 21)

Shipped

Arrow-form switch, this proposal

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

Groovy already coerces; revisit when finalised

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

*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

Warn-only initially

Java errors for sealed-type expression switches; Groovy emits a warning in 7.0 and may escalate via opt-in flag in 7.x

Compatibility

Backwards compatibility

Every program valid in Groovy 6 compiles with identical semantics in Groovy 7. 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 [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 (5.0 supported only Type(…​))

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.

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

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.