GEP-16
Abstract
Groovy already supports var as a type placeholder (alias for def,
introduced for Java 10 compatibility). This GEP proposes adding val
as an additional type placeholder but also combining the final modifier;
equivalent to final def, final var, final Object,
or just final (Groovy already allows a visibility modifier
to be a type placeholder).
Motivation
Declaring immutable local variables in Groovy currently requires:
final name = 'Groovy'
final def name = 'Groovy'
final var name = 'Groovy'
final String name = 'Groovy'
All of these work, but none are as concise or intention-revealing as:
val name = 'Groovy'
The val keyword is familiar from Kotlin and Scala, where it is the
default and idiomatic way to declare immutable bindings. Adding val
to Groovy provides:
-
A concise, readable way to declare final variables
-
Familiar syntax for developers coming from Kotlin or Scala
-
A natural complement to the existing
varkeyword -
Encouragement of immutability as a default practice
-
Improved parity for Gradle build script authors: Kotlin Gradle build scripts (
build.gradle.kts) already usevalfor local variables. Supportingvalin Groovy Gradle build scripts (build.gradle) makes it easier for teams that mix both languages, and reduces friction when translating between them.
Requirements
-
valdeclares afinalvariable (likefinal def) -
valmust still be usable as a variable name, method name, map key, and property name — mirroringvarbehavior -
valmust NOT be usable for method return types or type declarations — mirroringvarrestrictions -
final valshould be accepted (redundant but harmless) -
when statically type checking code with
val, the same type inference occurs as occurs today forfinalorfinal def
Non-goals
-
Changing the behavior of
deforvar -
Making
valthe default declaration style -
Adding immutability enforcement beyond the
finalmodifier (deep immutability is the domain of@Immutableand related transforms)
Design
Contextual classification
val interpretation is contextual in the same category as var.
It is a reserved token at the lexer level but is included in the
parser’s identifier rule, allowing it to be used as a name in
non-declaration contexts.
Groovy’s keywords fall into two categories with respect to identifier usage:
| Category | Examples | In identifier rule? |
|---|---|---|
Declaration + method keywords |
|
No — |
Declaration-only keywords |
|
Yes — these are forbidden on method return types, so the contexts don’t overlap and identifier use is unambiguous |
This is why var var = 4 works but def def = 2 does not.
val follows the var pattern: val val = 5 will work.
Semantics
val is equivalent to final def, final var, or just final:
With type-checking, normal inference applies:
val x = 42 // final, type inferred as int
val s = 'hello' // final, type inferred as String
val list = [1,2,3] // final, type inferred as ArrayList
The variable cannot be reassigned:
val x = 1
x = 2 // compile error: cannot assign to final variable
But the object itself can be mutated (shallow finality):
val list = [1, 2, 3]
list << 4 // OK: mutates the list, doesn't reassign
Where val is allowed
-
Local variable declarations:
val x = 1 -
Field declarations:
class C { val x = 1 } -
For-loop index variables:
for (val i in 0..10) {} -
Closure/lambda parameters:
{ val x → x * 2 } -
Package names:
package foo.val.bar
Where val is NOT allowed
-
Method return types:
val someMethod() {}— parse error -
Type declarations:
class val {},interface val {},@interface val {}— parse error -
Compact constructor declarations (records)
These restrictions mirror var exactly. Note that val can still be used
as a method name (def val() {}).
Identifier contexts that still work
// As a variable name (just like var):
val val = 5
assert val == 5
// As a map key:
def m = [val: 42]
// As a method name:
def val() { 'hello' }
// In GString interpolation:
assert "$val" == '5'
// Combined with other contextual keywords:
var var = 4
val val = 5
assert "$var$val" == '45'
Interaction with final
final val is redundant but accepted, just as final def and final var are:
final val x = 1 // OK, same as val x = 1
Interaction with Java classes named val
Unlike var (which Java prohibits as a class name since JDK 10), Java
allows classes named val. This creates a potential conflict when such a
class is used in Groovy code. The following table summarises the behavior:
| Scenario | Works? | Notes |
|---|---|---|
|
Yes |
|
|
Yes |
Import alias fully resolves the ambiguity |
|
Yes* |
|
|
No |
Keyword takes precedence — use FQN or import alias |
|
No |
Keyword takes precedence — use FQN or import alias |
Java-defined |
Yes |
Annotations are resolved by class name, no conflict |
Workaround: If you need to use a Java class named val as an explicit
type or method return type, use its fully-qualified name or an import alias:
import val as Val
Val x = new Val() // explicit type via alias
class Foo {
Val bar() { new Val() } // return type via alias
}
Implementation
|
Note
|
Early in parsing, val is simply converted to final with a dynamic
type. From that point on, the compiler never sees val again — it follows
the same code paths as any final declaration, including type inference
in @CompileStatic mode.
|
Spike implementation
A spike of the proposed functionality was implemented to validate the design and explore edge cases.
Production code (~15 lines across 5 files):
| File | Change |
|---|---|
|
Add |
|
Add |
|
Add |
|
Add |
|
Add |
No changes were needed in ModifierManager.java — val is handled
automatically via the opcode map (ACC_FINAL), and isDef() returning
true ensures the dynamic type is applied.
Test code (4 new files + 2 existing test fixes):
| File | Purpose |
|---|---|
|
Valid usage: basic |
|
|
|
|
|
|
|
Wire up |
|
Wire up |
The spike confirmed that all edge cases (identifier usage, map keys, package names, Java class interop, import aliases, cast expressions) work correctly with no additional code beyond the changes listed above.
Breaking behavior
You might think the main concern is existing code that uses val
as a variable name. But, since val will be in the identifier rule
(like var), most usage continues to work:
-
def val = 1— still works (declaration withdef, identifierval) -
val = something— still works (assignment to variable namedval) -
[val: 42]— still works (map key) -
obj.val— still works (property access)
The disambiguation works the same way as var: the parser uses
SemanticPredicates.isInvalidLocalVariableDeclaration() to check
what follows. val = 42 is an assignment; val x = 42 is a declaration.
The remaining edge cases all share the same root: val (and var)
are contextual keywords, so a few positions where they could be either
keyword or identifier have to resolve one way. In summary:
-
Field named
val(orvar) before a method or constructor declaration — parsed as modifiers on the member that follows -
valas a cast expression —val as Typereadsvalas the keyword -
Java class named
val— the keyword wins in declared-type positions
Each is detailed below with workarounds.
Field named val (or var) before a method or constructor declaration
A field declared as def val (or def var) immediately before a method
or constructor declaration is misinterpreted by the parser as modifiers
on the member that follows. This is a pre-existing issue with var that
equally applies to val:
class Foo {
def val // intended as field, but parsed as
void doSomething() {} // modifiers for this method -> error
}
class Bar {
def val // intended as field, but parsed as
Bar() {} // return type for a method named Bar -> error
}
Workarounds:
class Foo {
def val = null // add initializer
def val; // add semicolon
String val // use explicit type instead of def
void doSomething() {}
}
val as a cast expression
A variable or field named val used with the as cast operator is
misinterpreted. The parser sees val as the keyword followed by as:
def val = 42
val as String // error: unable to resolve class as
Workarounds:
this.val as String // qualify with this
(val) as String // parenthesise
"$val" as String // alternative approach
|
Note
|
This also applies to var. It is a pre-existing issue.
|
Java class named val
A Java class named val cannot be used directly as a declared type or
method return type in Groovy — the keyword takes precedence in those
positions. The workaround is to use a fully-qualified name or import alias.
This matches the behavior var would have if Java permitted class var.
Migration flag: groovy.val.enabled
For codebases that use val as an identifier and cannot immediately
migrate, a system property disables the val keyword entirely:
-Dgroovy.val.enabled=false
When set to false, val is lexed as a plain identifier (not a keyword).
All breaking changes listed above are resolved:
-
def valas a field name works (no modifier ambiguity) -
val as Stringworks (cast expression) -
class val {}works (type declaration) -
val xas explicit type works (if a class namedvalexists) -
val = somethinganddef val = 1work (assignment and declaration)
The flag is implemented as a lexer-level semantic predicate: when
disabled, the lexer emits IDENTIFIER instead of VAL, so the parser
never sees val as a keyword. We anticipate most users will use this
as a short-lived porting aid — once code is migrated,
remove the flag to benefit from val.
References and useful links
-
Groovy
varimplementation: commitf4d96d8872(2018)
Reference implementation
JIRA issues
-
GROOVY-9308: Support val for final declarations — originally raised in 2019. At that time, the
val/vardistinction was less widely understood. Since then, Kotlin adoption has grown significantly andval/varsemantics are now familiar to a broad audience, making the case for this feature stronger.
Update history
1 (2026-04-12) Initial draft
2 (2026-04-12) Added Java interop edge cases, spike implementation summary
3 (2026-04-12) Added field naming edge case, Gradle motivation
4 (2026-04-12) Added cast expression edge case, split code stats, linked spike branch and JIRA
5 (2026-04-16) Added groovy.val.enabled migration flag