Variables, Typing, def, var & val

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Groovy 6.0 (6.0.0, released 2026-09-22; JDK 17 or newer). Every example was run with Groovy 6.0.0 on JDK 26 and the output shown is the real output of that run. Features newer than Groovy 4.0 name the release that introduced them. It covers the ways a name is introduced in Groovy — def, an explicit type, var, val and final — how much the compiler enforces about the type behind a name, how several variables are assigned at once (multiple assignment and destructuring), and where the variables of a script live.

Declaring a variable: def, a type, var and val

Every variable definition needs a type or a type placeholder. def is that placeholder: use it when you do not care about the type at compile time, or when you rely on type inference under static compilation. Think of def as an alias for Object. If the placeholder is left out, the name is not a definition at all: it refers to an existing variable or, in a script, to a variable of the script binding (see Script variables).

def zone = 'north'          // def: no static type, behaves as Object
String crop = 'tomato'      // explicit type
int count = 12
var label = 'bench-7'       // var (3.0): same as def in a definition
val minTemp = 14            // val (6.0): same as final def

zone = 42                   // a def variable accepts any value
assert zone instanceof Integer

crop = 99                   // a typed variable casts what it is given
assert crop == '99' && crop instanceof String
assert label == 'bench-7' && minTemp == 14 && count == 12

The four spellings differ as follows:

Spelling Meaning Notes

def x = ...

A mutable variable of declared type Object.

Under @CompileStatic or @TypeChecked the compiler infers the type from the assigned value instead of treating the variable as plain Object.

String x = ...

A mutable variable of the declared type.

Assignments are checked: at run time in dynamic code (a cast), at compile time under static type checking.

var x = ... (3.0)

An alias for def in a definition.

Added for compatibility with Java 10’s var, so pasted Java code and Java-minded readers feel at home. It does not change the typing rules of Groovy.

val x = ... (6.0)

A final variable, equivalent to final def x = ....

A contextual keyword, the companion of var (GROOVY-9308, GEP-16). It also works for fields.

The release notes for 6.0 describe val this way: it declares an immutable binding, with shallow finality, and it follows the same type-inference rules as var under static type checking. A final modifier in front of an explicit type remains the long spelling.

import org.codehaus.groovy.control.MultipleCompilationErrorsException

class Bed {
    val id = 7                      // final field, inferred as def
    final String crop = 'basil'     // the long spelling
    var water = 1.5                 // mutable
}
def b = new Bed()
assert b.id == 7 && b.crop == 'basil'
b.water = 2.0
assert b.water == 2.0
try { b.id = 8 } catch (ReadOnlyPropertyException e) { println e.message }

final int cap = 10
def src = 'final int cap = 10; cap = 11'
try { new GroovyShell().evaluate(src) } catch (MultipleCompilationErrorsException e) { println e.message.readLines()[1] }

def val = 1                         // val is contextual: still a legal name
def m = [val: 42]
assert m.val == 42 && val == 1
Cannot set read-only property: id for class: Bed
Script1.groovy: 1: The variable [cap] is declared final but is reassigned

A val field in a class is a read-only property: the assignment b.id = 8 is rejected at run time with a ReadOnlyPropertyException because the property has a getter only. Reassigning a final local variable is a compile-time error, as the second line of output shows. The finality is shallow: val tags = ['a', 'b'] followed by tags << 'c' is legal because the list object itself is not frozen. For truly immutable data use records, @Immutable or an immutable collection (see Records & Sealed Types).

Because val and var are contextual keywords, existing code such as def val = 1, obj.val and [val: 42] keeps working. They are rejected only where they would be ambiguous with a type name, for example as a method return type or in a class val {} declaration. A few parser edge cases that already existed for var apply to val, mainly a field named val declared right before a method or a constructor, and val as Type cast expressions. Codebases that need more time to migrate can start the JVM with -Dgroovy.val.enabled=false, which makes val a plain identifier again (GROOVY-11994); with the flag set, my run of val x = 1 failed with unable to resolve class val, which is what a plain-identifier reading predicts. Groovy has no const keyword: the 6.0 compiler message for it points to val or static final.

Optional typing: what the declared type does

Groovy lets you decide per variable how much typing you want. In dynamic code the type you write is a cast applied at assignment time and a documentation aid for readers and tools; it is not a compile-time guarantee unless you switch on static type checking.

import org.codehaus.groovy.runtime.typehandling.GroovyCastException
import org.codehaus.groovy.control.MultipleCompilationErrorsException

int count = 12
try {
    count = 'fifteen'
} catch (GroovyCastException e) {
    println e.class.simpleName
    println e.message
}

def x = 'text'
x = 3.14
assert x instanceof BigDecimal      // def keeps no memory of the first value

String name = 42                    // a typed variable casts with the normal rules
assert name == '42'

def tryIt = { String src ->
    try { new GroovyShell().evaluate(src); println 'compiled and ran' }
    catch (MultipleCompilationErrorsException e) { println e.message.readLines()[1] }
}
tryIt '''
@groovy.transform.CompileStatic
def f() { String s = 'a'; s.size2() }
f()'''
tryIt '''
@groovy.transform.CompileStatic
def f() { var x = 'text'; x = 3.14; x }
f()'''
GroovyCastException
Cannot cast object 'fifteen' with class 'java.lang.String' to class 'int'
Script1.groovy: 3: [Static type checking] - Cannot find matching method java.lang.String#size2(). Please check if the declared type is correct and if the method exists.
compiled and ran

Reading the output:

  • An assignment to a typed variable is a Groovy cast. int count = 12; count = 'fifteen' fails with a GroovyCastException, while String name = 42 succeeds because any object can be turned into a String.

  • @CompileStatic finds the misspelt method size2() at compile time. The same code without the annotation would only fail when size2() is called.

  • A def or var variable is not frozen to the type of its initial value even under @CompileStatic: the type checker follows the value through the method ("flow typing"), so var x = 'text'; x = 3.14 is accepted. Declare String x if you want the compiler to reject that line.

The whole subject of how much the compiler proves, and the trade-offs between the dynamic and static styles, belongs to Static Type Checking & @CompileStatic. The conversions applied when a value does not match a declared type are covered in Type Coercion & Promotion.

Style guidance that most Groovy teams converge on: use explicit types for public method signatures, fields and anything that is part of an API; use def (or var, if Java readers dominate) for short-lived locals whose type is obvious from the right-hand side; use val or final for anything that must not be reassigned. See Style Guide & Idioms for the broader conventions.

Multiple assignment and destructuring

A parenthesised list of names on the left of = assigns several variables at once. The right-hand side can be a list, an array, any method result of those types, or an object that implements getAt(int) (and, in 6.0, a few other shapes described below).

// lists, with and without types, and onto existing variables
def (sensor, reading, unit) = ['S-12', 21.5, 'C']
assert sensor == 'S-12' && reading == 21.5 && unit == 'C'
def (int bench, String crop) = [7, 'basil']
assert bench == 7 && crop == 'basil'
def lo, hi
(lo, hi) = [14, 28]
assert lo == 14 && hi == 28

// underflow pads with null, overflow is ignored
def (p, q, r) = [1, 2]
assert p == 1 && q == 2 && r == null
def (s, t) = [1, 2, 3]
assert s == 1 && t == 2

// arrays and method results
def (day, month, year) = '18th June 2009'.split()
assert "$month $year" == 'June 2009'

The rules in short:

  • def (a, b) = ... declares the variables; (a, b) = ... assigns to variables that already exist.

  • Each name can carry a type: def (int bench, String crop) = .... A mismatch is a cast, as for any typed variable.

  • If the left side has more names than the right side has elements, the extra variables are set to null; if the right side has more elements, the surplus is ignored.

  • A method that returns a list or an array fits naturally, which is the usual way to return "two things" from a method.

Object destructuring with getAt

Any object that implements getAt(int) can be destructured positionally. The subscript operator and the destructuring syntax share the same method, so a class decides which of its values come out first.

class Position {
    double latitude, longitude
    double getAt(int i) {
        i == 0 ? latitude : i == 1 ? longitude : { throw new IndexOutOfBoundsException('use 0 or 1') }()
    }
}
def (la, lo2) = new Position(latitude: 43.23, longitude: 3.67)
assert la == 43.23 && lo2 == 3.67

Rest binders (6.0)

Groovy 6.0 adds rest bindings to multiple assignment (GROOVY-11964, GEP-20). A name prefixed with * captures all the elements that no other name claims. The extension is strictly additive: every program valid in Groovy 4 or 5 compiles with identical semantics, because the new shapes were unparseable before.

import java.util.stream.Stream
import java.util.stream.Collectors

// tail, head and middle positions
def (h, *tl) = [1, 2, 3, 4]
assert h == 1 && tl == [2, 3, 4]
def (*front, last) = [1, 2, 3, 4]
assert front == [1, 2, 3] && last == 4
def (a, *mid, z) = [1, 2, 3, 4, 5]
assert a == 1 && z == 5 && mid == [2, 3, 4]

// the type of the rest follows the right-hand side
def (c1, c2, *cs) = 'hello'
assert cs == 'llo'

// lazy sources stay lazy
def (header, *body) = Stream.of('# bed 3', 'tomato', 'basil')
assert body.collect(Collectors.toList()) == ['tomato', 'basil']
def naturals = (1..Integer.MAX_VALUE).iterator()
def (one, *more) = naturals
assert one == 1 && more.next() == 2

// a type ascription on the rest binder
def (hd, List<Integer> *rest2) = [1, 2, 3, 4]
assert rest2 == [2, 3, 4]

How the compiler lowers each shape is described in the release notes: a rest binder works against any right-hand side that supports either getAt(IntRange) or iterator(); a trailing binder over a Stream keeps the pipeline lazy, and an iterator source is not materialised. Head and middle binders (def (*front, last), def (l, *middle, r)) need a sized, indexable right-hand side, since the compiler must know where the end is. That is why they work on a list or a range but cannot be used with an endless iterator.

Map-style destructuring (6.0)

key: name pairs in the declarator list bind through property access, so they work on maps, on JavaBeans and, through the MOP, on anything with a matching property (GROOVY-11964, GEP-20).

def reading2 = [sensor: 'S-12', value: 21.5, unit: 'C']
def (sensor: id, value: v) = reading2
assert id == 'S-12' && v == 21.5

// type ascriptions pin or coerce the binding types
def (sensor: String sid, value: double dv) = reading2
assert sid == 'S-12' && dv == 21.5

// any object with matching properties
def (year: y, monthValue: mo) = java.time.LocalDate.of(2026, 10, 14)
assert y == 2026 && mo == 10

The release notes also show def (year: y, month: m) = Calendar.instance as a bean example. In my run of 6.0.0 that line failed with No such property: year for class: java.util.GregorianCalendar, because GregorianCalendar has no year property (only the YEAR constant), so verify bean examples against the actual getters of the class involved.

The underscore placeholder and markers

def and var may appear in front of any binder, which is equivalent to omitting a type, and modifiers on the outer declaration propagate to every binder, including rest and map-style ones. The "discard" convention _ for unwanted slots has been available since 5.0 for positional destructuring (and for lambda and closure parameters, GROOVY-10943) and in 6.0 works in every new form. _ is an ordinary identifier, not a wildcard: it really is bound, and a scope can declare it once.

def (var va, var vb) = [1, 2]              // same as: def (va, vb) = [1, 2]
assert va + vb == 3
final (fa, *ft) = [1, 2, 3]                // both fa and ft are final
assert ft == [2, 3]

var (vx, vy) = [1, 2]                      // var with multi-assignment (5.0)
assert vx == 1 && vy == 2

// several _ in ONE declaration are fine ...
var (_, yy, mm, _, _, dd) = Calendar.instance
assert yy >= 2026
// ... but a second declaration of _ in the same scope is rejected, so use a nested scope
def discard = {
    def (_, *keep) = [1, 2, 3]
    assert keep == [2, 3]
}
discard()
def discard2 = {
    def (name: _, age: ag) = [name: 'A', age: 30]
    assert ag == 30
}
discard2()
println 'all destructuring asserts passed'
all destructuring asserts passed

Two details from the runs above. Writing def (_, a) = ... and then def (_, b) = ... in the same scope fails to compile with The current scope already contains a variable of the name _, which confirms that _ is a real variable. And var combined with multi-assignment (5.0) and with final (...) (6.0) behave like def and final respectively.

Script variables

In a script, whether a variable is declared decides where it lives. This page only summarises the rule; the generated Script class and the Binding object are explained in Program Structure, Packages, Imports & Scripts.

import groovy.transform.Field

int local = 1            // local to run(): invisible to methods
shared = 2               // undeclared: goes into the binding
@Field int perScript = 3 // field of the generated Script class

def inMethod() {
    def seen = []
    try { seen << local } catch (MissingPropertyException e) { seen << 'local: missing' }
    seen << shared
    seen << perScript
    seen
}
assert inMethod() == ['local: missing', 2, 3]
assert binding.hasVariable('shared')
assert !binding.hasVariable('local')
assert !binding.hasVariable('perScript')
println binding.variables
[args:[], shared:2]

A declared script variable is a local of the generated run() method: methods of the script cannot see it. An undeclared name is stored in the script Binding, so every method sees it and so does the host application that embedded the script. @Field turns a declaration into a field of the generated class: visible to all methods, but not part of the binding. When a local variable or @Field shares its name with a binding variable, rename one of them; if you cannot, binding.name (or binding.getVariable('name')) reaches the binding one.

Where the book is outdated

The book consulted for this reference is the first edition of Groovy in Action (2007), which describes Groovy 1.0. It knows def and explicit types, but a lot has been added since: multiple assignment arrived in Groovy 1.6 (2009), var in 3.0, the underscore placeholder and var with multi-assignment in 5.0, and val, rest binders and map-style destructuring in 6.0. Its explanation of optional typing predates @TypeChecked and @CompileStatic (2.0), so it treats a declared type only as a run-time cast and does not mention that modern code often has the compiler check it. Scripts are described without the 5.0 variations (instance main, no-arg run()). Prefer the Groovy 6.0 documentation linked below.

References