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 |
|---|---|---|
|
A mutable variable of declared type |
Under |
|
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. |
|
An alias for |
Added for compatibility with Java 10’s |
|
A final variable, equivalent to |
A contextual keyword, the companion of |
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 aGroovyCastException, whileString name = 42succeeds because any object can be turned into aString. -
@CompileStaticfinds the misspelt methodsize2()at compile time. The same code without the annotation would only fail whensize2()is called. -
A
deforvarvariable is not frozen to the type of its initial value even under@CompileStatic: the type checker follows the value through the method ("flow typing"), sovar x = 'text'; x = 3.14is accepted. DeclareString xif 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
Source: Groovy 6.0 release notes: def and var binder markers, the underscore and Groovy 5.0 release notes: underscore as a placeholder, var with multi-assignment.
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
-
Groovy documentation: Semantics — variable definition, variable assignment and multiple assignment, optional typing
-
Groovy documentation: Program structure — script variables, binding and
@Field -
Groovy 6.0 release notes —
valkeyword (GROOVY-9308, GEP-16), multi-assignment destructuring (GROOVY-11964, GEP-20), thegroovy.val.enabledproperty (GROOVY-11994) -
Groovy 5.0 release notes — underscore placeholder and
varwith multi-assignment -
Groovy in Action (Manning) — publisher page of the book consulted for the fundamentals
-
Booleans & the Groovy Truth, Lists & Arrays and Maps — the values that destructuring takes apart
-
Java: Primitive Types and Variables — the Java side of
varandfinal