GEP-28
|
Abstract
When the type expected at some position in an expression is known, repeating that type’s name to reference one of its members is ceremony:
logMessage('Failed to connect', LogLevel.ERROR) // the parameter already says LogLevel
Color c = Color.RED // the declaration already says Color
@Retention(RetentionPolicy.RUNTIME) // the attribute already says RetentionPolicy
This GEP proposes a dot shorthand: a leading . followed by a member name, resolved against
the context type — the type the surrounding position expects:
logMessage('Failed to connect', .ERROR)
Color c = .RED
@Retention(.RUNTIME)
The shorthand compiles to an ordinary qualified reference (LogLevel.ERROR) at compile time, so
a misspelt member is a compile error in every compilation mode — unlike today’s String-to-enum
coercion, where Color c = 'RED' is convenient but Color c = 'GRN' compiles and fails at
runtime, even under @CompileStatic.
The feature is deliberately staged: enum constants first, then static final fields of the
context type, then (optionally) static factory methods and constructors, following the scope
Dart 3.10 shipped. It is strictly additive — .name in expression position is a syntax error in
every current Groovy version — and it works in all compilation modes where a context type is
syntactically available, which notably includes dynamic code, annotations, and switch case
labels on declared-enum-typed subjects.
Motivation
Where Groovy stands today
Groovy already has three mechanisms for referencing enum constants without a static import, and
after the @TypeChecked switch-case fix (GROOVY-12190, a GROOVY-11614 follow-up regression, fixed for
Groovy 5.0.x/6.0) the current landscape is:
| Context (no import of constants) | Java | Groovy dynamic | @TypeChecked |
@CompileStatic |
|---|---|---|---|---|
Bare constant in switch case (statement and expression forms) |
✅ |
❌ runtime |
✅ |
✅ |
Bare constant, inferred subject ( |
n/a |
❌ |
✅ via flow typing |
✅ |
String literal → declaration / field / return / default param / ternary / array |
❌ |
✅ |
✅ |
✅ |
String literal → typed method argument |
❌ |
❌ |
❌ |
❌ |
Enum in annotation attribute |
❌ (needs static import) |
❌ |
❌ |
❌ |
|
❌ |
❌ |
❌ |
❌ |
|
❌ |
❌ |
❌ |
❌ |
(The Java column is stable across JDK 17–25; Java’s only unqualified context is the switch case label, though since Java 21 the qualified form is also permitted there — JLS §14.11.1.)
Each existing mechanism has a structural limit:
-
Unqualified switch-case constants (GROOVY-8444, Groovy 3.0.0) ride on the static type checker’s inference, so they cannot work in dynamic code, and they apply to case labels only.
-
String-to-enum coercion (
Color c = 'RED', viaShortTypeHandling#castToEnumat runtime andStaticTypeCheckingSupportunder STC) works in every mode but only in assignment-like positions, only for enums, and — being value-blind at compile time — defers typos to a runtimeIllegalArgumentExceptioneven under@CompileStatic. -
Static imports work everywhere but are the manual ceremony this family of features exists to remove, and they pollute the namespace for the whole file.
The gaps a context-typed shorthand closes
A dot shorthand is resolved from the expected type of the position, not from the switch subject or the assignment target specifically, so one mechanism covers every position that has an expected type:
| Gap today | With dot shorthand |
|---|---|
Typed method argument ( |
|
Annotation attribute (no mode, no mechanism) |
|
|
|
|
|
Dynamic-mode switch on a declared-enum-typed subject |
|
Typo safety of the coercion contexts |
|
What remains genuinely unclosable, and is explicitly out of scope: positions where no context
type exists even syntactically — dynamic-mode arguments to a dynamically dispatched call, and
== where neither operand has a declared type.
The two distinct benefits
The shorthand pays off in two different ways, and they are worth separating because they appeal to different readers.
No import at all. In an argument position the type name never appears in the calling file, so neither an import of the type nor a static import of its constants is needed:
// LogLevel.groovy
enum LogLevel { DEBUG, INFO, WARN, ERROR }
// Loggers.groovy
void logPrint(String message, LogLevel level) { … }
// App.groovy — no import of LogLevel, and no static import of INFO
logPrint('Hello world', .INFO)
This works because the parameter’s type is a resolved reference in logPrint’s signature; the
caller’s import list plays no part in resolving `.INFO, exactly as it plays no part in
def x = foo() where foo’s return type is unimported. This makes the shorthand strictly
stronger than a static import, which still requires naming the type once (or importing `.* and
polluting the file namespace).
A checked literal. In a declaration position the type name is still present, one token away, so
the saving is small — and if brevity were the whole argument this position would be weak. It is
not the whole argument. The realistic competitor to Color c = .RED is not Color c = Color.RED
but the coercion Groovy programmers actually write today:
Color c = 'RED' // works
Color c = 'GRN' // also compiles — under @CompileStatic too — and fails at runtime
Color c = .GRN // compile error, in every mode
So the declaration case is about turning a value-blind string into a checked literal; the reduction in ceremony is a side benefit, not the point.
Precedent
Languages have been converging on exactly this feature:
-
Swift has had implicit member expressions (
.red) since Swift 1, in a language that — like Groovy and unlike Dart — also supports leading-dot method-chain continuation lines, proving the grammar coexistence is tractable. -
Dart 3.10 (2025) shipped dot shorthands for enum values, static fields, static methods and constructors, resolved against the context type.
-
Kotlin 2.2 is previewing the bare-name variant ("context-sensitive resolution", KEEP-379), where unqualified enum entries resolve in expected-type positions.
Java has no equivalent and none proposed; this is a place where Groovy would extend Java ergonomics rather than catch up, consistent with the language’s history.
Mental model
Programmers already have a mental model for this feature, because Java gave them one for switch case labels:
"I already told the compiler the enum type in the switch clause, so I should be able to refer to the actual enum values in the case labels."
That sentence is not really about switches. It is about positions. Java applies it in exactly one position, and applies it invisibly, through a bare name. This GEP applies the same sentence to every position that already knows its type, and marks it with a leading dot so it can never be confused with a property access. The rule in one line:
Whatever I already told the compiler at this position, I should not have to say again in order to name one of its members.
.Xmeans "that type’s `X`".
A practical corollary for readers of code: you can always expand .X by mentally re-inserting
the type the position already names. If you cannot say out loud which type the position expects,
the shorthand is not legal there — and that is precisely the compile error the feature emits.
The rule, restated per position
| Position | What the programmer already told the compiler |
|---|---|
|
"I gave the variable’s type on the left of the |
Field/property initializer |
"I declared the field’s type on this very line." |
|
"I declared the method’s return type in its signature." |
Default parameter value |
"I declared the parameter’s type right there." |
|
Java’s own sentence, generalised: "I told the compiler the subject’s type when I declared the subject" — which is why this also works in dynamic mode, where the |
|
"The method’s signature already declared the parameter’s type; the call site is the one place that should not have to repeat it." The same bargain a lambda strikes when it takes its shape from the SAM parameter. |
|
"The annotation’s own declaration gave the attribute’s type." |
|
"The other operand has a type, and the comparison is only meaningful against that type." |
|
"I gave the element type once; repeating it per element is the worst ratio of all." |
|
"I am giving the type in this very expression." |
Where the model stops, and why
The same sentence explains the exclusions, which is the real test of whether a mental model is honest rather than decorative. Each excluded position fails it identically — nothing was ever told to the compiler, so there is nothing to elide (the full position-by-position breakdown is in Context types):
-
An argument to a dynamically dispatched call: the compiler does not know which method will be selected, so no parameter type exists to read.
-
==where neither operand has a declared type. -
def x = .RED, and GString interpolation.
Note that these are not "dynamic mode is unsupported". Dynamic mode supports the shorthand wherever a type was declared; it fails only where none was.
Proposal
Syntax
A dot shorthand is a leading . immediately followed by an identifier, in expression
position:
.IDENTIFIER // stage 1/2: constant or static field access
.IDENTIFIER(args) // stage 3 (optional): static method / factory invocation
It is valid only where a context type is available (below); anywhere else it is a compile-time error ("no context type for dot shorthand").
Context types
The table below is organised by what supplies the context type, not by compilation mode. That is the distinction that actually matters: a declared type is available to the compiler in every mode, so the first column is not "dynamic mode" but "works everywhere". Only the second column depends on static type checking. This list is the design surface for review; the intent is to match the positions where Groovy already applies target typing for SAM coercion or String-to-enum coercion, plus case labels and annotation attributes:
| Position | Context type from a declaration (all modes) | Context type from inference (STC only) |
|---|---|---|
Variable declaration with explicit type: |
✅ the declared type |
n/a |
Field/property initializer, return statement from a typed method, default parameter value, ternary/Elvis branches in the above |
✅ the declared type |
n/a |
Switch case label: |
✅ when the subject has a declared enum type |
✅ inferred subjects ( |
Method/constructor call argument |
❌ the target method is not known until dispatch, so no parameter type exists (compile error) |
✅ from the resolved parameter type |
Annotation attribute: |
✅ always — attribute types are compile-time-declared |
n/a |
|
✅ when the other operand has a syntactically declared type: a typed local, parameter, or field access resolvable at compile time (see Equality and membership) |
✅ when the other operand’s type comes from flow typing |
Typed collection/array literals: |
✅ arrays, from the declared component type |
✅ generic collections, from the element type |
Explicit target: |
✅ the cast is the declaration |
n/a |
The only ❌ in the table is the position where nothing was declared and nothing can be inferred, which is the mental model doing its job rather than an arbitrary restriction.
Equality and membership
Version 1 of this GEP marked == as unsupported in dynamic mode outright. That was inconsistent
with its own treatment of declarations, case labels and arrays, all of which resolve from a
syntactically declared type in every mode. Given
class Palette {
Color myColor // declared
def anything // not declared
boolean isRed() { this.myColor == .RED } // ✅ every mode: Color is declared
boolean isRed2() { this.anything == .RED } // ❌ dynamic; ✅ under STC iff flow typing pinned it
}
the declared field supplies a context type by exactly the same route Color c = .RED does, so
the shorthand resolves.
This is sound even though dynamic Groovy can subvert the declared type through the MOP. The
declared type is used only to decide which constant .RED names; the emitted bytecode is the
ordinary qualified reference Color.RED. If getProperty interception or an ExpandoMetaClass
has made the runtime value something other than a Color, the comparison simply evaluates to
false — byte-for-byte the same outcome as hand-writing Color.RED. The shorthand adds no
runtime machinery and therefore cannot introduce a runtime discrepancy.
Contrast this with the argument position, where the ❌ is unavoidable rather than conservative:
in logPrint('hi', .INFO) under dynamic dispatch the compiler does not know which logPrint
will be selected, so there is no parameter type to read. The distinction is declared vs not
declared, never dynamic vs static.
|
Note
|
Kotlin encountered a hazard specific to this position: in Kotlin 2.3, context-sensitive resolution combined with equality and type operators can become ambiguous when a clashing class declaration is imported, and the compiler now emits a warning. Groovy will face the same interaction and the resolution rules for this cell should be specified with that case in mind. |
Resolvable members (staged)
| Stage | Member set | Notes |
|---|---|---|
1 |
Enum constants of the context type |
The core use case; smallest surface |
2 |
|
Mirrors Dart/Swift; requires the assignability rule to be pinned down |
3 (optional) |
Static methods and constructors whose return type is the context type — |
Dart parity; largest surface, deferred until 1–2 prove out |
Stage 1 alone closes every gap in the motivation table.
Stage 3 is the stage that draws the most enthusiasm in early feedback, and it is genuinely the
nicest sugar of the three — but it carries a design cost the constant cases do not. In a
declaration position (Duration d = .ofSeconds(3)) it is straightforward, since the context
type is fixed before the call is resolved. In an argument position it is circular: the
shorthand’s context type comes from the candidate parameter type, while candidate selection
wants the argument’s type. Dart resolves this and provides a reference implementation to follow,
but it is a substantially larger item than looking up a constant on a known enum, which is why
it is staged last. Splitting Stage 3 so that declaration-position factories can land earlier
than argument-position ones is an open question for review.
Semantics
A dot shorthand is compile-time rewritten to the equivalent qualified reference
(propX(classX(contextType), name) — the same rewrite GROOVY-8444’s case-label support
performs today in VariableExpressionTransformer and, since the GROOVY-12190 regression fix,
StaticTypesTransformation). Consequences:
-
No runtime machinery. The emitted bytecode is identical to writing
Color.REDby hand; the MOP sees an ordinary qualified static reference. Dynamic metaprogramming that interceptsColor.REDintercepts.REDidentically. -
Fail-fast. An unresolvable member ("no such constant
.GRNonColor`") and a missing context type are compile errors in every mode — no deferred `MissingPropertyExceptionorIllegalArgumentException. -
STC ordering. Under STC, shorthand resolution participates in overload selection the same way lambda/SAM target typing does today: candidate parameter types supply candidate context types; a shorthand that resolves under exactly one applicable overload disambiguates it; one that resolves under several ambiguous overloads is an error naming the candidates.
The two grammar hazards
Both were verified empirically against Groovy 5.0.6 and both need an explicit rule; neither is believed fatal (Swift lives with both properties).
Leading-dot line continuation
Groovy attaches a line starting with . to the previous expression:
def t = s
.toUpperCase() // today: parsed as s.toUpperCase() — and this must not change
Proposed rule: chain continuation wins. A leading .name on a new line following a complete
expression remains member access on that expression, exactly as today; a dot shorthand is only
recognised where the grammar is inside an unfinished expression position (after =, (, ,,
[, →, case, ==, return, an annotation member, etc.). This makes the feature
whitespace-sensitive in one narrow way — Color c = followed by .RED on the next line is a
shorthand because the = leaves the expression unfinished — and parser error messages for the
genuinely ambiguous shapes must be written deliberately. This is the same resolution Swift uses.
.5 float literals
println .5 is 0.5 today and must stay so. The lexer already distinguishes on the character
following the dot; .DIGIT remains a numeric literal, .IDENT becomes a shorthand candidate.
Mechanical, but the interaction with command expressions needs a decision: println .RED
would become grammatically plausible while (dynamic println(Object)) never has a context
type. Proposal: dot shorthands are not recognised in command-expression argument position
(parenthesised calls only), keeping the command-syntax grammar untouched.
Smaller interactions
?., *., .&, ../..< ranges, and slashy strings do not collide with a leading-dot
identifier in expression position, but each needs a grammar test; [.RED] vs the (invalid
today) [..RED] range shapes should produce clear errors. Chaining off a shorthand
(.RED.next()) parses naturally once .RED reduces to an expression; whether Stage 1 allows
it or defers it is an open question (Swift allows chains whose final type re-enters the context
type; Dart restricts more tightly).
Design principles
-
Resolve at compile time, in every mode. The value of the feature is fail-fast semantics from declared types; a runtime-resolved variant would just be a second string coercion.
-
New syntax, no reinterpretation. Bare names (Kotlin-style) are not given new meaning anywhere; only the currently-invalid
.nameform carries the new semantics. Existing programs cannot change meaning. -
One mechanism, not per-position magic. Case labels, arguments, annotations and initializers all resolve through the same "context type" definition, so the mental model is a single rule — stated in Mental model, along with its per-position restatement and the reason each excluded position is excluded.
-
Stay aligned with the coercion positions. Wherever
Color c = 'RED'works today,.REDworks and is preferred; documentation should present the shorthand as the checked successor without deprecating the coercion (which remains for genuinely dynamic string values).
Groovy 7.0 deliverables
| Phase | What ships | Notes |
|---|---|---|
1 |
Grammar (lexer + antlr4 rules + continuation-line rule), Stage 1 members (enum constants), all declared-type contexts — declarations, fields, returns, defaults, ternaries, case labels, annotation attributes, arrays, and |
Closes the dynamic-case-label, annotation and typo-safety gaps; no STC dependency beyond what exists |
2 |
STC-inferred contexts: method/constructor arguments with overload interplay, |
Reuses the SAM target-typing machinery; the hard design item is overload interaction |
3 |
Stage 2 members ( |
Small increment once 1–2 are stable |
4 (stretch) |
Stage 3 members (static factories/constructors, |
Dart parity; only if 1–3 land early enough for a full release cycle of feedback |
A prototype of the Phase 1 lexer/grammar work (the continuation-line rule in particular) should precede GEP acceptance, since parser feasibility is the main technical risk.
Excluded and deferred features
| Feature | Status | Rationale |
|---|---|---|
Shorthands in positions with no context type: arguments to a dynamically dispatched call, |
Not planned |
Nothing to resolve against; explicit compile error keeps the model crisp. Note this excludes only the untyped cases — |
Bare-name resolution outside switch cases (Kotlin KEEP-379 style) |
Not planned |
Ambiguous with property access under the MOP in dynamic code; the dot form exists precisely to avoid reinterpreting valid syntax |
Runtime-resolved shorthands |
Not planned |
Would reintroduce the deferred-failure problem the feature exists to fix |
Command-expression argument position |
Deferred |
Grammar risk outweighs benefit; revisit with usage data |
Chained member access off a shorthand ( |
Open question |
Parses naturally; semantic rule (does the chain result need to re-enter the context type?) to be settled in review |
Deprecating String-to-enum coercion |
Not planned |
Remains correct for dynamic string values; shorthand is documented as the preferred literal form |
Compatibility and impact
Backwards compatibility
Strictly additive. Every proposed form is a syntax error in Groovy 5/6, so no existing program changes meaning or compilation result. The continuation-line rule is defined so that all currently-valid leading-dot chains parse exactly as today. No runtime library surface is added; the rewrite emits ordinary qualified references.
Binary compatibility
None affected — the feature is purely a compile-time rewrite; no new classes, markers, or call-site shapes.
Tooling
The grammar change is the visible surface: IntelliJ IDEA (its own Groovy parser) and Eclipse
need parser support before the feature is usable in IDEs, and groovy-console/groovysh
highlighting follows the shipped grammar. This is the same adoption path every grammar addition
(e.g. switch expressions, ?[]) has taken, but it should be flagged to JetBrains early. Stub
generation and Groovydoc are unaffected (rewrite happens before those consume the AST — to be
verified for joint compilation in Phase 1).
Documentation
The Groovy documentation’s enum section should present the full ladder in one place: qualified reference → static import → switch-case bare constants → dot shorthands → string coercion (dynamic values only), with the mode/position matrix from this GEP’s motivation section. The one-line rule from Mental model should lead that section: readers who take away only "the leading dot means the type this position already declares" can predict every supported position and every excluded one without consulting the matrix.
Alternatives considered
-
Extend bare-name resolution to more contexts (Kotlin KEEP-379 model). Works for the static modes (it is what case labels do today) but is a dead end for dynamic Groovy: a bare
REDis indistinguishable from a property access, so static resolution would silently change program meaning and fight the MOP. Rejected in favour of new, unambiguous syntax that can carry the same semantics in every mode. -
Widen String-to-enum coercion (method args, annotations, generics). Closes fewer cells (annotations and
==stay closed), remains enum-only, and doubles down on value-blind compile-time checking — the typo problem grows with every new position. Rejected. -
Do nothing; recommend static imports. Static imports remain the universal fallback, but the ceremony objection stands, the file-wide namespace pollution is real for enums with generic constant names (
ON,OFF,LEFT), and peer languages are all moving; measured against the small grammar cost, the status quo leaves easy ergonomics unclaimed. -
Sigil variants (
RED,@RED,:RED). Avoid the two grammar hazards but are alien to the Java/Dart/Swift family, collide with existing Groovy meanings (@annotations,shebang, method-pointer proximity), and forgo the "reads as the qualified form with the type elided" property that makes the dot form self-explanatory. Rejected.
References
-
Announcing Dart 3.10 — dot shorthands as shipped (enum values, static fields/methods, constructors, context-type resolution)
-
Swift implicit member expressions — the longest-standing precedent, in a language with leading-dot chain continuation
-
Kotlin KEEP-379: context-sensitive resolution — the bare-name alternative this GEP rejects for dynamic-mode reasons; previewed in Kotlin 2.2
-
What’s new in Kotlin 2.3 — the ambiguity warning added for context-sensitive resolution under equality and type operators, the hazard noted in Equality and membership
-
Dart dot shorthands (language documentation) — the shipped user-facing semantics
-
GROOVY-8444 — unqualified enum constants in switch cases (Groovy 3.0.0)
-
GROOVY-11614 — switch-expression case constants; its follow-up refactor caused the
@TypeCheckedregression -
GROOVY-12190 — the
@TypeCheckedregression fix that restores the baseline this GEP builds on -
org.codehaus.groovy.transform.sc.transformers.VariableExpressionTransformer,org.codehaus.groovy.transform.StaticTypesTransformation,org.codehaus.groovy.transform.stc.EnumTypeCheckingExtension— the existing enum-constant rewrite this proposal generalises -
org.codehaus.groovy.runtime.typehandling.ShortTypeHandling#castToEnum,org.codehaus.groovy.transform.stc.StaticTypeCheckingSupport— the String-to-enum coercion whose positions (and value-blindness) motivate the design -
GEP-27: Compact Closure and Lambda Compilation — companion GEP in the same series
Update history
1 (2026-07-25) Initial draft
2 (2026-08-04) Mental model section added; ==/!=/in corrected to work in all modes against a declared operand (was dynamic-mode ❌) and moved to Phase 1; context-type table recut by declaration vs inference rather than compilation mode; no-import benefit and checked-literal rationale made explicit; Stage 3 argument-position overload circularity noted