GEP-16


Metadata
Number

GEP-16

Title

val type placeholder for final declarations

Version

4

Type

Feature

Status

Draft

Leader

Paul King

Created

2026-04-12

Last modification

2026-04-12

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 var keyword

  • Encouragement of immutability as a default practice

  • Improved parity for Gradle build script authors: Kotlin Gradle build scripts (build.gradle.kts) already use val for local variables. Supporting val in Groovy Gradle build scripts (build.gradle) makes it easier for teams that mix both languages, and reduces friction when translating between them.

Requirements

  • val declares a final variable (like final def)

  • val must still be usable as a variable name, method name, map key, and property name — mirroring var behavior

  • val must NOT be usable for method return types or type declarations — mirroring var restrictions

  • final val should be accepted (redundant but harmless)

  • when statically type checking code with val, the same type inference occurs as occurs today for final or final def

Non-goals

  • Changing the behavior of def or var

  • Making val the default declaration style

  • Adding immutability enforcement beyond the final modifier (deep immutability is the domain of @Immutable and 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

def

No — def is used for method return types, so allowing it as an identifier would create unresolvable ambiguity

Declaration-only keywords

var, val

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

new val()

Yes

val parsed as class name in new expression

import val as MyVal then MyVal x = new MyVal()

Yes

Import alias fully resolves the ambiguity

val x = new val()

Yes*

val on LHS is the keyword (final + inferred type), not the class. Type is inferred from RHS. Reassignment is forbidden.

val bar() as method return type

No

Keyword takes precedence — use FQN or import alias

val x as explicit type in declaration

No

Keyword takes precedence — use FQN or import alias

Java-defined @val annotation

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

GroovyLexer.g4

Add VAL : 'val'; token (1 line)

GroovyParser.g4

Add VAL to modifier, variableModifier, indexVariable, identifier, and keywords rules — alongside VAR in each (5 lines)

ModifierNode.java

Add VAL to MODIFIER_OPCODE_MAP mapping to ACC_FINAL, add isVal() method, include VAL in isDef() (3 lines + import)

AstBuilder.java

Add VAL to type name, method return type, and compact constructor restrictions — alongside VAR in each (3 lines)

SmartDocumentFilter.java

Add VAL to syntax highlighting keyword list (1 line + import)

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

src/test-resources/core/Val_01x.groovy

Valid usage: basic val, closure param, val val, map key, type inference, shallow finality, final val, for loop, GString

src/test-resources/fail/Val_01x.groovy

class val {} — type declaration forbidden

src/test-resources/fail/Val_02x.groovy

val someMethod() {} — method return type forbidden

src/test-resources/fail/Val_03x.groovy

val x = 1; x = 2 — reassignment forbidden

GroovyParserTest.groovy

Wire up Val_01x core test

SyntaxErrorTest.groovy

Wire up Val_01x/02x/03x fail tests

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 with def, identifier val)

  • val = something — still works (assignment to variable named val)

  • [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 (or var) before a method or constructor declaration — parsed as modifiers on the member that follows

  • val as a cast expression — val as Type reads val as 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 val as a field name works (no modifier ambiguity)

  • val as String works (cast expression)

  • class val {} works (type declaration)

  • val x as explicit type works (if a class named val exists)

  • val = something and def val = 1 work (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.

JIRA issues

  • GROOVY-9308: Support val for final declarations — originally raised in 2019. At that time, the val/var distinction was less widely understood. Since then, Kotlin adoption has grown significantly and val/var semantics 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