Language Atlas (mini-courses for more languages) Module 8 – Groovy for Java developers, Jenkins and Gradle
Groovy closures, DSLs and static checking
See how a Groovy closure finds names through its owner and delegate, read a Jenkinsfile and build.gradle as method calls, and fix what @TypeChecked reports.
What you will learn
- Explain how a closure resolves a name through its owner and delegate, and choose a resolve strategy
- Build a small DSL whose blocks are closures run against spec objects
- Read a Jenkinsfile and a Gradle build script as ordinary method calls that take closures
- Switch on @TypeChecked or @CompileStatic, fix what they report, and type a builder with @DelegatesTo
Before you start
What you’ll learn · Before you start
What you will learn
- Explain how a closure resolves a name through its owner and delegate, and choose a resolve strategy
- Build a small DSL whose blocks are closures run against spec objects
- Read a Jenkinsfile and a Gradle build script as ordinary method calls that take closures
- Switch on @TypeChecked or @CompileStatic, fix what they report, and type a builder with @DelegatesTo
Before you start
On this page
pipeline { stages { stage('Build') { steps { sh 'make' } } } } looks like configuration, yet it is five method
calls, four of them taking a closure. Fed to a builder of 60 lines in this lesson, the Jenkinsfile below produces a
call log of 10 lines and a two-stage plan, and nothing in it is Jenkins.
Every Groovy DSL works this way, a Jenkinsfile and a build.gradle among them: a method takes a closure and runs it
with another object as its delegate. This lesson shows how a closure finds a name, how the resolve strategy changes
the answer, how a builder uses that, how to read the two files you meet most as calls, and how @TypeChecked and
@CompileStatic move mistakes from the run to the compile.
A closure finds names in its owner, then in its delegate
A closure (Groovy): A block of code in braces, such as { b -> b.minutes }, that is an object of class groovy.lang.Closure: it can be stored, passed and called later, and it keeps this, its owner and its delegate for the names it uses. is an object, and it keeps three references for the names it uses but does
not declare. The closures guide defines them: this is “the enclosing class where the closure is defined”; owner
is “the direct enclosing object, be it a closure or a class”; and delegate is an object of your choosing, which “by
default … is set to owner”. scope.groovy defines a closure in a method of a class and a second closure inside it,
and prints what each one sees:
// Who are this, owner and delegate for a closure defined in a method, and for a closure nested inside it?
class Report {
String title = 'Nightly builds'
def show() {
Closure outer
outer = {
println "outer: this is a ${this.getClass().simpleName}, owner is a ${owner.getClass().simpleName}"
def inner = {
println "inner: this is a ${this.getClass().simpleName}, owner is the outer closure: ${owner.is(outer)}"
println "inner: title found through the owners: $title"
}
inner()
}
outer()
println "a closure's delegate starts as its owner: ${outer.delegate.is(outer.owner)}"
}
}
new Report().show() from session import run
run("""
cd scope
groovy scope.groovy
groovy strategy.groovy
""") Output
$ cd scope $ groovy scope.groovy outer: this is a Report, owner is a Report inner: this is a Report, owner is the outer closure: true inner: title found through the owners: Nightly builds a closure's delegate starts as its owner: true $ groovy strategy.groovy OWNER_FIRST name=release agent=linux DELEGATE_FIRST name=test agent=linux OWNER_ONLY name=release MissingPropertyException: agent DELEGATE_ONLY name=test agent=linux a local variable under DELEGATE_ONLY: name=local
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 scope_session.py
The first four lines are scope.groovy. In both closures this is the Report object, because this is always the
class around the code. The owners differ: the outer closure’s owner is the Report object, the inner closure’s owner
is the outer closure, which the script proved with owner.is(outer). The inner closure printed title, which neither
closure declares; step through how it was found:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- A name nobody declared. The inner closure prints $title. title is not a parameter of the closure and not a local variable, so the closure has to look for it.
- Its owner first. Under the default strategy, OWNER_FIRST, the closure asks its owner first. The owner of a closure defined inside another closure is that outer closure.
- The owner's owner. The outer closure has no title either, so it passes the question to its own owner, the Report object whose method defined it.
- Found. Report has the property title, and the inner closure prints Nightly builds. this is the Report object in both closures, because this is always the class around the code.
- The delegate. Every closure also has a delegate, which starts out as its owner: the last line printed true. A builder changes the delegate, and the strategy says who is asked first.
The lookup went from the closure to its owner, and from that owner to its own owner, until an object had the name. The delegate (of a closure): An object that a Groovy closure can send the names it does not declare to. It starts as the closure's owner; a builder sets it to the object that knows the words of a block. was not needed here, and it was the same object as the owner anyway, as the last line printed. A builder is the code that changes it.
Checkpoint
Check: this, owner and delegate
The resolve strategy decides who answers when both can
When the delegate is another object, a name may be found in two places, and the closure’s
resolve strategy: The rule that tells a Groovy closure where to look for a name it does not declare: OWNER_FIRST, the default, DELEGATE_FIRST, OWNER_ONLY, DELEGATE_ONLY or TO_SELF. says which one is asked. strategy.groovy makes the question
real: its owner, the script, has a variable name set to release in its binding, and its delegate, a Stage, has
a property name set to test and another, agent, set to linux. It runs the same two closures under four
strategies, then one more closure next to a local variable. The last five lines of the session above are its output:
// One name, two places that have it: the closure's owner (this script) and its delegate (a Stage).
class Stage {
String name = 'test'
String agent = 'linux'
}
name = 'release' // a variable of the script's binding, so a property of the owner
def describe = { "name=$name" }
def onlyStage = { "agent=$agent" }
[Closure.OWNER_FIRST, Closure.DELEGATE_FIRST, Closure.OWNER_ONLY, Closure.DELEGATE_ONLY].each { strategy ->
def label = [0: 'OWNER_FIRST', 1: 'DELEGATE_FIRST', 2: 'OWNER_ONLY', 3: 'DELEGATE_ONLY'][strategy]
def c = describe.rehydrate(new Stage(), this, this)
c.resolveStrategy = strategy
def d = onlyStage.rehydrate(new Stage(), this, this)
d.resolveStrategy = strategy
def agent
try {
agent = d()
} catch (MissingPropertyException e) {
agent = "MissingPropertyException: ${e.property}"
}
println "${label.padRight(14)} ${c()} ${agent}"
}
// A local variable is not looked up at all: the closure captures it where it is written.
String withLocal() {
def name = 'local'
def c = { "name=$name" }.rehydrate(new Stage(), this, this)
c.resolveStrategy = Closure.DELEGATE_ONLY
c()
}
println "a local variable under DELEGATE_ONLY: ${withLocal()}" | Strategy | Asks | name gave |
agent gave |
|---|---|---|---|
OWNER_FIRST, the default |
the owner, then the delegate | release |
linux, from the delegate |
DELEGATE_FIRST |
the delegate, then the owner | test |
linux |
OWNER_ONLY |
the owner only | release |
MissingPropertyException: agent |
DELEGATE_ONLY |
the delegate only | test |
linux |
The guide lists a fifth strategy, TO_SELF, for closures that are subclasses of Closure. The table shows the rule a
builder needs: under the default, any name the owner has wins over the delegate. That is why builders set
DELEGATE_FIRST, so that the words of the DSL go to the object that defines them even when the code around the block
has a property or a binding variable of the same name. rehydrate(delegate, owner, thisObject) in the script made a
copy of each closure with a new delegate, and setting resolveStrategy chose the order.
The last line shows what the strategies do not cover. A local variable, declared with def where the closure is
written, is captured by the closure and never looked up, so name gave local even under DELEGATE_ONLY. A block
of a DSL that needs a word must not sit next to a local variable of the same name.
A builder that keeps the default strategy
A builder that sets body.delegate = spec but keeps OWNER_FIRST works until the code around a block has a
property or a binding variable with the same name as a word of the DSL: then the owner’s value wins, silently, as
name gave release here. Set body.resolveStrategy = Closure.DELEGATE_FIRST together with the delegate.
Checkpoint
Check: The resolve strategy
A DSL block is a method that runs its closure against a spec
A domain-specific language (DSL): A small language made for one job, such as describing clock domains. In Tcl it is often a set of procedures, so that a file written in the language is a Tcl script made of their commands. More in “Tcl event loop, coroutines and small DSLs” in Groovy needs nothing beyond what you have
seen: methods that take a closure, and a delegate for each closure. PipelineScript.groovy is a builder for the
shape of a Jenkinsfile. Each block word is a method of a small spec class: stages belongs to PipelineSpec,
stage to StagesSpec, steps to StageSpec, sh and echo to StepsSpec. Each such method makes the spec of
the next level, sets it as the delegate of its closure with DELEGATE_FIRST and runs the closure. read.groovy
compiles a file as a script whose base class is PipelineScript, the way the DSL guide describes a script base class,
so that the first word, pipeline, is a method of the script:
pipeline/PipelineScript.groovy
// A small pipeline builder. Each block is a method that runs its closure with a spec object as the delegate, and
// every call a spec receives goes into a call log. Jenkins's own pipeline engine does far more; this one only records.
abstract class PipelineScript extends Script {
void pipeline(Closure body) {
def spec = new PipelineSpec()
Log.call(0, 'pipeline(Closure)', spec)
Log.runWith(spec, body)
Log.lines.each { println it }
println "Plan on agent ${spec.agentLabel}:"
spec.stages.eachWithIndex { s, i -> println " ${i + 1}. ${s.name}: ${s.steps.join(', ')}" }
}
}
class Log {
static List<String> lines = []
static void call(int depth, String call, Object delegate = null) {
lines << ' ' * depth + call + (delegate ? " -> delegate: ${delegate.getClass().simpleName}" : '')
}
static void runWith(Object spec, Closure body) {
body.delegate = spec
body.resolveStrategy = Closure.DELEGATE_FIRST
body()
}
}
class PipelineSpec {
String agentLabel = 'none'
List<StageSpec> stages = []
String getAny() { 'any' }
void agent(String label) { Log.call(1, "agent('$label')"); agentLabel = label }
void stages(Closure body) { def spec = new StagesSpec(pipeline: this); Log.call(1, 'stages(Closure)', spec); Log.runWith(spec, body) }
}
class StagesSpec {
PipelineSpec pipeline
void stage(String name, Closure body) {
def spec = new StageSpec(name: name)
Log.call(2, "stage('$name', Closure)", spec)
Log.runWith(spec, body)
pipeline.stages << spec
}
}
class StageSpec {
String name
List<String> steps = []
void steps(Closure body) { def spec = new StepsSpec(stage: this); Log.call(3, 'steps(Closure)', spec); Log.runWith(spec, body) }
}
class StepsSpec {
StageSpec stage
void sh(String command) { Log.call(4, "sh('$command')"); stage.steps << "sh $command" }
void echo(String message) { Log.call(4, "echo('$message')"); stage.steps << "echo $message" }
} pipeline/read.groovy
import org.codehaus.groovy.control.CompilerConfiguration
// Compiles the file named on the command line as a script whose base class is PipelineScript, so that its
// first word, pipeline, is a method of that class, then runs it.
def config = new CompilerConfiguration()
config.scriptBaseClass = PipelineScript.name
def shell = new GroovyShell(this.class.classLoader, new Binding(), config)
shell.evaluate(new File(args[0])) pipeline/Jenkinsfile
pipeline {
agent any
stages {
stage('Build') {
steps {
sh 'make'
}
}
stage('Test') {
steps {
sh 'make test'
echo 'tests passed'
}
}
}
} from session import run
# The Jenkinsfile, compiled as a script whose base class is PipelineScript and run: the call log, then the plan.
run("""
cd pipeline
groovy read.groovy Jenkinsfile
""") Output
$ cd pipeline
$ groovy read.groovy Jenkinsfile
pipeline(Closure) -> delegate: PipelineSpec
agent('any')
stages(Closure) -> delegate: StagesSpec
stage('Build', Closure) -> delegate: StageSpec
steps(Closure) -> delegate: StepsSpec
sh('make')
stage('Test', Closure) -> delegate: StageSpec
steps(Closure) -> delegate: StepsSpec
sh('make test')
echo('tests passed')
Plan on agent any:
1. Build: sh make
2. Test: sh make test, echo tests passed
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pipeline_session.py
The call log has one line for each call the builder received, indented by its depth: pipeline(Closure) with a
PipelineSpec as the delegate, then agent('any'), stages(Closure), a stage for Build and one for Test, and
inside each a steps with its sh call, followed in Test by an echo. The plan under it is what the specs recorded.
Step through the blocks and the object that answered each:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- The first word. The file is compiled as a script whose base class is PipelineScript, so pipeline is a method of the script itself. Its only argument is the closure in braces.
- agent any. pipeline runs the closure with a PipelineSpec as its delegate. agent any is the call agent(any), and any is a property the delegate answers with the text any.
- stages. stages is a method of PipelineSpec that takes a closure and runs it with a StagesSpec as its delegate, one level down.
- One stage. stage('Build') { … } is a call with two arguments, a name and a closure; the closure goes after the parentheses. Its delegate is a new StageSpec named Build.
- steps. steps hands its closure a StepsSpec, the object that knows the step commands, and records into the stage it belongs to.
- A step. sh 'make' is the call sh('make') on the StepsSpec. The builder writes it to the call log and adds sh make to the steps of Build.
- The next stage. Back in the stages closure, stage('Test') makes a second StageSpec, and its steps closure records sh make test and echo tests passed.
- The plan. When the outer closure returns, pipeline prints the call log and the plan: Build with one step and Test with two, on agent any.
Key idea
A DSL block is a method call whose last argument is a closure; the method gives the closure a delegate that knows
the words of the next level, and DELEGATE_FIRST makes those words win.
That ease has a cost to keep in mind whenever a DSL reads files written by other people:
A file read by a DSL is a program
read.groovy runs whatever file it is given, with every power of Groovy, so read only files you trust with it. The
DSL guide’s secure AST customizer can restrict the grammar of a script, and it says itself that it “does not
replace a security manager”. Jenkins runs pipeline Groovy in its sandbox, and approving more methods is an
administrator’s decision on the script approval page.
Checkpoint
Check: A builder made of closures
A Jenkinsfile and a build.gradle are calls that take closures
Groovy lets a call drop its parentheses when it is a statement with arguments, sh 'make', and lets a closure that
is the last argument stand after the parentheses, stage('Build') { … }. With every parenthesis written out, the
Jenkinsfile becomes explicit.groovy, and no_parentheses.groovy tries two blocks with their parentheses and
without them. A Gradle build script is read the same way: Gradle’s primer says every script is “backed by an implicit
instance of” a Project, and a block is a method whose last argument is a Closure or an Action.
read_build.groovy runs build.gradle with a stand-in project as the delegate, an object that records each call it
receives, through methodMissing and setProperty, instead of building anything:
pipeline/explicit.groovy
pipeline({
agent(any)
stages({
stage('Build', {
steps({
sh('make')
})
})
stage('Test', {
steps({
sh('make test')
echo('tests passed')
})
})
})
}) pipeline/no_parentheses.groovy
// Two blocks with their parentheses, then the same two lines without them: Groovy gives the closure to the
// argument, so stage and retry are never called.
def stage(String name, Closure body) { println "stage('$name', Closure)"; body() }
def retry(int times, Closure body) { println "retry($times, Closure)"; body() }
def sh(String command) { println " sh('$command')" }
stage('Deploy') { sh './deploy.sh' }
retry(3) { sh 'make' }
try {
stage 'Deploy' { sh './deploy.sh' }
} catch (MissingMethodException e) {
println "stage 'Deploy' { … }: ${e.class.simpleName}, no method ${e.method}(Closure) in ${e.type.simpleName}"
}
try {
retry 3 { sh 'make' }
} catch (MissingMethodException e) {
println "retry 3 { … }: ${e.class.simpleName}, no method ${e.method}(Closure) in ${e.type.simpleName}"
} gradle/build.gradle
plugins {
id 'groovy'
}
version = '1.0.1'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.apache.groovy:groovy:6.0.0'
testImplementation 'org.junit.jupiter:junit-jupiter:6.1.3'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
test {
useJUnitPlatform()
} gradle/read_build.groovy
// Reads build.gradle the way Gradle's Groovy DSL is read, as calls on a project object, with a stand-in project
// that records each call instead of building anything. Gradle itself is not involved.
class Recorder {
String name
int depth
Recorder(String name, int depth) { this.name = name; this.depth = depth }
def methodMissing(String method, Object args) {
def list = (args as List)
def shown = list.collect { it instanceof Closure ? 'Closure' : "'$it'" }.join(', ')
println ' ' * depth + "${name}.${method}(${shown})"
if (list && list.last() instanceof Closure) {
Closure body = list.last()
body.delegate = new Recorder(method, depth + 1)
body.resolveStrategy = Closure.DELEGATE_FIRST
body()
}
}
void setProperty(String property, Object value) {
println ' ' * depth + "${name}.set${property.capitalize()}('$value')"
}
}
def text = new File('build.gradle').text
Closure script = new GroovyShell().evaluate("{ ->\n$text\n}")
script.delegate = new Recorder('project', 0)
script.resolveStrategy = Closure.DELEGATE_FIRST
script() from session import run
# The same pipeline with every parenthesis written out, two blocks with and without their parentheses, then
# build.gradle read by a stand-in project that records each call it receives.
run("""
cd pipeline
groovy read.groovy explicit.groovy
groovy no_parentheses.groovy
cd ../gradle
groovy read_build.groovy
""") Output
$ cd pipeline
$ groovy read.groovy explicit.groovy
pipeline(Closure) -> delegate: PipelineSpec
agent('any')
stages(Closure) -> delegate: StagesSpec
stage('Build', Closure) -> delegate: StageSpec
steps(Closure) -> delegate: StepsSpec
sh('make')
stage('Test', Closure) -> delegate: StageSpec
steps(Closure) -> delegate: StepsSpec
sh('make test')
echo('tests passed')
Plan on agent any:
1. Build: sh make
2. Test: sh make test, echo tests passed
$ groovy no_parentheses.groovy
stage('Deploy', Closure)
sh('./deploy.sh')
retry(3, Closure)
sh('make')
stage 'Deploy' { … }: MissingMethodException, no method Deploy(Closure) in no_parentheses
retry 3 { … }: MissingMethodException, no method call(Closure) in Integer
$ cd ../gradle
$ groovy read_build.groovy
project.plugins(Closure)
plugins.id('groovy')
project.setVersion('1.0.1')
project.repositories(Closure)
repositories.mavenCentral()
project.dependencies(Closure)
dependencies.implementation('org.apache.groovy:groovy:6.0.0')
dependencies.testImplementation('org.junit.jupiter:junit-jupiter:6.1.3')
dependencies.testRuntimeOnly('org.junit.platform:junit-platform-launcher')
project.test(Closure)
test.useJUnitPlatform()
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 reading_session.py
The explicit version printed a call log identical to the Jenkinsfile’s, line for line. The next program ran
stage('Deploy') { … } and retry(3) { … } as calls of stage and retry, and then the same two lines without
their parentheses, which never reached either method:
Leaving out the parentheses before a closure
stage 'Deploy' { … } is not stage('Deploy') { … }: Groovy gives the closure to the argument, not to stage. The
quoted word became a call of a method named Deploy, and the 3 of retry 3 { … } was called with the closure, as
call(Closure) on an Integer; both stopped with MissingMethodException. Keep the parentheses whenever a closure
follows an argument.
The build script turned into the calls a Gradle project receives:
In build.gradle |
The call |
|---|---|
plugins { id 'groovy' } |
project.plugins(Closure), then id('groovy') on its delegate |
version = '1.0.1' |
project.setVersion('1.0.1'), an assignment to a property |
repositories { mavenCentral() } |
project.repositories(Closure), then mavenCentral() |
implementation 'org.apache.groovy:groovy:6.0.0' |
dependencies.implementation('org.apache.groovy:groovy:6.0.0') |
test { useJUnitPlatform() } |
project.test(Closure), then useJUnitPlatform() |
Gradle’s DSL reference writes such a line as configurationName(dependencyNotation): implementation is the name of
a configuration that a plugin added, used as a method, which is why the stand-in caught it with methodMissing too.
Gradle’s Kotlin DSL page presents that DSL as “an alternative to the traditional Groovy DSL” and allows both in one build, so
a build.gradle you meet is ordinary Groovy, read by the Groovy inside Gradle.
Read an unknown block as a call
When a line of a Jenkinsfile or a build script puzzles you, add the parentheses in your head: sh 'make' is
sh('make'), and retry(3) { … } is retry(3, { … }), a method of the delegate in force. Then look the method up
in the tool’s documentation of that object.
Checkpoint
Check: Reading a Jenkinsfile and build.gradle as calls
@TypeChecked turns a run-time typo into a compile error
Dynamic Groovy resolves a property while the program runs, so a typo compiles. typo.groovy reads it.minuts
instead of it.minutes; typo_checked.groovy is the same file with @TypeChecked on the method. The semantics guide
says that with type checking (@TypeChecked): Checking at compile time that every method, property and assignment of the code exists and fits its types. In Groovy it is switched on for a class or a method with @TypeChecked, and the calls stay dynamic. “method calls are resolved at compile time”, so
errors such as a property not found appear before the code runs. Step through the two runs:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Compile, dynamic. groovy typo.groovy compiles the file. In dynamic Groovy, it.minuts is a property read that is resolved only when it runs, so the compiler accepts it.
- Run until the line. The script starts and prints Summing the builds…. Everything before the typo has already happened, here one line of output, in a real tool perhaps a file written.
- The typo runs. At line 9, inside each, Build has no property minuts: MissingPropertyException, with Possible solutions: minutes, and exit status 1.
- Compile, type-checked. groovy typo_checked.groovy compiles the same code with @TypeChecked on the method. The checker knows that it is a Build and that Build has no minuts.
- Nothing runs. The compiler stops with two errors at line 12, No such property: minuts, and the plus that has no matching method as a result. No line of the script ran.
checked/typo.groovy
// A typo in dynamic Groovy: the file compiles, starts, and fails only when the line with the typo runs.
class Build {
String name
int minutes
}
int total(List<Build> builds) {
int sum = 0
builds.each { sum += it.minuts }
sum
}
println 'Summing the builds…'
println total([new Build(name: 'api', minutes: 12), new Build(name: 'web', minutes: 18)]) checked/typo_checked.groovy
import groovy.transform.TypeChecked
// The same typo with @TypeChecked on the method: the compiler stops, and nothing runs.
class Build {
String name
int minutes
}
@TypeChecked
int total(List<Build> builds) {
int sum = 0
builds.each { sum += it.minuts }
sum
}
println 'Summing the builds…'
println total([new Build(name: 'api', minutes: 12), new Build(name: 'web', minutes: 18)]) checked/dsl_unhinted.groovy
import groovy.transform.TypeChecked
// A builder method whose closure parameter says nothing about its delegate, used from type-checked code.
class StepsSpec {
List<String> commands = []
void sh(String command) { commands << command }
}
List<String> steps(Closure body) {
def spec = new StepsSpec()
body.delegate = spec
body.resolveStrategy = Closure.DELEGATE_FIRST
body()
spec.commands
}
@TypeChecked
List<String> release() {
steps {
sh 'make'
sh 'make test'
}
}
println release() checked/dsl_hinted.groovy
import groovy.transform.TypeChecked
// The same builder with @DelegatesTo: the type checker now knows which class answers sh inside the block.
class StepsSpec {
List<String> commands = []
void sh(String command) { commands << command }
}
List<String> steps(@DelegatesTo(value = StepsSpec, strategy = Closure.DELEGATE_FIRST) Closure body) {
def spec = new StepsSpec()
body.delegate = spec
body.resolveStrategy = Closure.DELEGATE_FIRST
body()
spec.commands
}
@TypeChecked
List<String> release() {
steps {
sh 'make'
sh 'make test'
}
}
println release() from session import run
# A typo found while the script runs, then by @TypeChecked before it runs; a builder under @TypeChecked without and
# with @DelegatesTo.
run("""
cd checked
!groovy typo.groovy
!groovy typo_checked.groovy
!groovy dsl_unhinted.groovy
groovy dsl_hinted.groovy
""") Output
$ cd checked
$ groovy typo.groovy
Summing the builds…
… groovy.ui.GroovyMain run
SEVERE: Caught: {0}
groovy.lang.MissingPropertyException: No such property: minuts for class: Build
Possible solutions: minutes
at typo$_total_closure1.doCall(typo.groovy:9)
at typo.total(typo.groovy:9)
at typo.run(typo.groovy:14)
groovy.lang.MissingPropertyException: No such property: minuts for class: Build
Possible solutions: minutes
at typo$_total_closure1.doCall(typo.groovy:9)
at typo.total(typo.groovy:9)
at typo.run(typo.groovy:14)
[exit status 1]
$ groovy typo_checked.groovy
… groovy.ui.GroovyMain run
SEVERE: org.codehaus.groovy.control.MultipleCompilationErrorsException: startup failed:
typo_checked.groovy: 12: [Static type checking] - No such property: minuts for class: Build
@ line 12, column 26.
builds.each { sum += it.minuts }
^
typo_checked.groovy: 12: [Static type checking] - Cannot find matching method java.lang.Integer#plus(java.lang.Object). Please check if the declared type is correct and if the method exists.
@ line 12, column 19.
builds.each { sum += it.minuts }
^
2 errors
[exit status 1]
$ groovy dsl_unhinted.groovy
… groovy.ui.GroovyMain run
SEVERE: org.codehaus.groovy.control.MultipleCompilationErrorsException: startup failed:
dsl_unhinted.groovy: 20: [Static type checking] - Cannot find matching method dsl_unhinted#sh(java.lang.String). Please check if the declared type is correct and if the method exists.
@ line 20, column 9.
sh 'make'
^
dsl_unhinted.groovy: 21: [Static type checking] - Cannot find matching method dsl_unhinted#sh(java.lang.String). Please check if the declared type is correct and if the method exists.
@ line 21, column 9.
sh 'make test'
^
2 errors
[exit status 1]
$ groovy dsl_hinted.groovy
[make, make test]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 typecheck_session.py
The dynamic script printed Summing the builds… and then failed at line 9 with MissingPropertyException, and
Groovy even suggested minutes. The checked one never started: two errors at line 12, the missing property and, as a
result, a plus with no matching method. Groovy 6 reports both through Java’s logging, which is why each starts with
a line from groovy.ui.GroovyMain.
A builder needs one more step under the checker. In dsl_unhinted.groovy the checker sees sh 'make' inside a
closure whose delegate it cannot know, and reports “Cannot find matching method dsl_unhinted#sh(java.lang.String)”.
The DSL guide’s answer is @DelegatesTo on the closure parameter. dsl_hinted.groovy declares
@DelegatesTo(value = StepsSpec, strategy = Closure.DELEGATE_FIRST) Closure body, the checker then knows which class
answers sh, and the run printed [make, make test]. The same annotation is what lets an IDE complete the words of
a DSL.
Checkpoint
Check: @TypeChecked and @DelegatesTo
@CompileStatic binds each call when the code compiles
@CompileStatic does everything @TypeChecked does and then compiles the calls as Java does, bound once, when the
code compiles. The semantics guide lists three benefits: type safety, “immunity to monkey patching” and better
performance on CPU-heavy code. The second is easy to see. patched.groovy replaces a method of Pricing at run time
through its metaclass, the way Groovy code can change a class while it runs, and calls the method through a dynamic
function and through a static one:
import groovy.transform.CompileStatic
// @CompileStatic binds the call when it compiles, so a method replaced at run time does not reach it.
class Pricing {
int doubled(int x) { x * 2 }
}
int dynamicCall(Pricing p) { p.doubled(21) }
@CompileStatic
int staticCall(Pricing p) { p.doubled(21) }
println "before the patch: dynamic ${dynamicCall(new Pricing())}, static ${staticCall(new Pricing())}"
Pricing.metaClass.doubled = { int x -> x * 3 }
println "after the patch: dynamic ${dynamicCall(new Pricing())}, static ${staticCall(new Pricing())}" from session import run
run("""
cd checked
groovy patched.groovy
""") Output
$ cd checked $ groovy patched.groovy before the patch: dynamic 42, static 42 after the patch: dynamic 63, static 42
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 static_session.py
Before the patch both calls gave 42. After it, the dynamic call found the new method, 21 × 3 = 63, and the static
call still gave 42: it had been bound to the compiled doubled when the file compiled. That is the trade:
static compilation (@CompileStatic): Compiling Groovy code with the checks of @TypeChecked and every call bound when the code compiles, as in Java. Such code ignores methods that metaprogramming adds or replaces at run time. makes calls predictable and fast, and it ignores what
metaprogramming changes at run time, so code that relies on methodMissing, an added method or a builder without
@DelegatesTo must stay dynamic.
| Case | Dynamic Groovy | @TypeChecked |
@CompileStatic |
|---|---|---|---|
A typo such as it.minuts |
fails when the line runs | compile error | compile error |
| A method added at run time | is called | compile error unless declared | compile error unless declared |
| How a call is bound | while it runs | while it runs | when it compiles, as in Java |
A builder without @DelegatesTo |
works | compile error | compile error |
Static where the logic is, dynamic at the edges
Put @CompileStatic on the classes that compute, and keep the parsing, printing and DSL glue dynamic. The project
lesson of this module does exactly that: its summary class is static, and the JSON reading and the report printing
are not.
Checkpoint
Check: @CompileStatic
Practise: find a name the way a closure does
The exercise models the lookup that this lesson is built on: given what the owner and the delegate have, and a
strategy, return what a closure finds or the error it gets. Write it in Python, whose sample tests the page can run
for you, against the four strategies that strategy.groovy recorded:
Exercise · Easy · Python
Find a name the way a closure's resolve strategy does
A builder works only if you can predict where each name in a block is found. Write resolve(name, owner, delegate, strategy) in resolve.py, a model of the lookup a Groovy closure makes for a name it does not declare itself:
owneranddelegateare dicts from the names each object has to their values;strategyis one of"OWNER_FIRST"(Groovy's default),"DELEGATE_FIRST","OWNER_ONLY"and"DELEGATE_ONLY";- return the value from the first object the strategy asks that has the name: the owner, then the delegate, for
OWNER_FIRST; the delegate, then the owner, forDELEGATE_FIRST; only the owner or only the delegate for the other two; - when no object it may ask has the name, raise
LookupError(f"No such property: {name}"), as Groovy raisesMissingPropertyException.
The tests replay strategy.groovy: the owner, the script, has name = "release", and the delegate, a Stage, has name = "test" and agent = "linux".
Starter code · resolve.py
def resolve(name, owner, delegate, strategy):
"""The value a closure finds for name under the strategy, or LookupError when it may not find one."""
# Replace this with your code: it always asks the owner first, then the delegate, whatever the strategy says.
for place in (owner, delegate):
if name in place:
return place[name]
raise LookupError(f"No such property: {name}") The sample tests · test_resolve.py
from resolve import resolve
SCRIPT = {"name": "release"}
STAGE = {"name": "test", "agent": "linux"}
def error_of(*args):
"""The message of the LookupError that resolve raises, or None when it returns a value."""
try:
resolve(*args)
except LookupError as e:
return str(e)
return None
def test_owner_first_finds_the_scripts_name():
"""OWNER_FIRST printed name=release and agent=linux"""
assert resolve("name", SCRIPT, STAGE, "OWNER_FIRST") == "release"
assert resolve("agent", SCRIPT, STAGE, "OWNER_FIRST") == "linux"
def test_delegate_first_finds_the_stages_name():
"""DELEGATE_FIRST printed name=test"""
assert resolve("name", SCRIPT, STAGE, "DELEGATE_FIRST") == "test"
assert resolve("agent", SCRIPT, STAGE, "DELEGATE_FIRST") == "linux"
def test_owner_only_never_asks_the_delegate():
"""OWNER_ONLY printed name=release, then MissingPropertyException: agent"""
assert resolve("name", SCRIPT, STAGE, "OWNER_ONLY") == "release"
assert error_of("agent", SCRIPT, STAGE, "OWNER_ONLY") == "No such property: agent"
def test_delegate_only_never_asks_the_owner():
"""DELEGATE_ONLY printed name=test, and a name only the owner has is out of reach"""
assert resolve("name", SCRIPT, STAGE, "DELEGATE_ONLY") == "test"
assert error_of("version", {"version": "1.0.1"}, STAGE, "DELEGATE_ONLY") == "No such property: version"
def test_a_name_nobody_has():
"""no strategy invents a value"""
assert error_of("timeout", SCRIPT, STAGE, "DELEGATE_FIRST") == "No such property: timeout" A hint
Turn the strategy into the list of objects to ask, in order: [owner, delegate] for OWNER_FIRST, [delegate, owner] for DELEGATE_FIRST, and a list of one for the other two. Then return from the first dict that has the name.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then test yourself on the six ideas, from the owner of a closure to @CompileStatic. A session begins with the
idea you have answered worst so far, and every choice carries its own reason:
Practice that adapts to you
Practise this lesson
12 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to mid-level): What are the owner and the delegate of a Groovy closure, and why do builders change the delegate?
The owner is the object the closure was defined in: the enclosing class instance, or the enclosing closure when one
closure is nested in another. The delegate is a second object the closure can send names to; it starts out as the
owner, and any code can set it. When a closure uses a name it does not declare, its resolve strategy says where to
look: the default, OWNER_FIRST, asks the owner, then the delegate. A builder such as a Jenkinsfile reader sets the
delegate to an object that knows the words of its block, stage or sh, and switches the strategy to
DELEGATE_FIRST, so those words win even when the surrounding script has a property with the same name. In the
lesson, the same closure read name as release from the owner under OWNER_FIRST and as test from the delegate
under DELEGATE_FIRST.
Two longer questions, one about a DSL that read the wrong value and one about static compilation:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A release DSL has blocks like module('api') { artifact "api-${version}.jar" }, and the builder sets each block's delegate to a ModuleSpec with a version property. After someone adds version = '9.9' near the top of the script for a log line, every artifact carries 9.9. What happened, and how do you fix and test the builder?
-
Senior to staff
A team maintains 40,000 lines of dynamic Groovy: services, a few builders and some metaclass tricks in tests. A lead proposes @CompileStatic everywhere, for speed and safety. How do you evaluate the proposal, and how would you roll it out?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What do this, owner and delegate refer to inside a Groovy closure?
this: the class around the code. owner: the object or closure in which the closure was defined. delegate: an object anyone may set, which starts out as the owner.
-
Both the owner and the delegate have a name. Which value does a closure get under the default strategy?
The owner's: OWNER_FIRST asks it first, so name gave release from the script's binding. Under DELEGATE_FIRST the delegate answered with test.
-
Why does a builder set resolveStrategy to Closure.DELEGATE_FIRST as well as the delegate?
So the words of its DSL reach the spec that defines them even when the code around the block has a property or binding variable with the same name.
-
Which name does no resolve strategy override?
A local variable declared where the closure is written: the closure captures it rather than looking it up, so it answered local even under DELEGATE_ONLY.
-
How does a DSL block such as stage('Build') { steps { sh 'make' } } work underneath?
Each word is a method call whose last argument is a closure. The method makes the spec of the next level the closure's delegate and runs it.
-
Why is stage 'Deploy' { … } not the same call as stage('Deploy') { … }?
Without the parentheses Groovy gives the closure to the argument, so 'Deploy' { … } turned into a call of a method named Deploy and failed. Keep the parentheses when a closure follows an argument.
-
What does @TypeChecked do to a typo such as it.minuts?
Makes it a compile error, since names are resolved when the code compiles; dynamic Groovy failed only when the line ran. A builder's closure parameter needs @DelegatesTo so the checker knows its delegate.
-
A method of Pricing is replaced through its metaclass at run time. Which calls see the new method?
Dynamic calls do, and gave 63. A call compiled with @CompileStatic was bound when the file compiled, so it still gave 42.
Key takeaways
- A closure keeps
this, the class around it,owner, the object or closure it was defined in, anddelegate, which starts as the owner; an undeclared name is looked up through them. - The resolve strategy decides the order:
OWNER_FIRSTby default, so builders setDELEGATE_FIRSTwith the delegate, and theONLYstrategies refuse names from the other side; a captured local variable wins under every strategy. - A DSL is methods that take closures and run them against spec objects; a builder of 60 lines read a Jenkinsfile into a call log and a plan.
- Without parentheses a Jenkinsfile and a
build.gradleare still method calls:stage('Build') { … }anddependencies { implementation '…' }, answered by the delegate in force; a closure that follows an argument needs the parentheses, or the argument is called with it. @TypeCheckedturnsit.minutsinto a compile error,@DelegatesTotells it the delegate of a builder’s closure, and@CompileStaticbinds calls at compile time, ignoring methods replaced at run time.
Terms in this lesson
- closure (Groovy)
- A block of code in braces, such as { b -> b.minutes }, that is an object of class groovy.lang.Closure: it can be stored, passed and called later, and it keeps this, its owner and its delegate for the names it uses.
- delegate (of a closure)
- An object that a Groovy closure can send the names it does not declare to. It starts as the closure's owner; a builder sets it to the object that knows the words of a block.
- resolve strategy
- The rule that tells a Groovy closure where to look for a name it does not declare: OWNER_FIRST, the default, DELEGATE_FIRST, OWNER_ONLY, DELEGATE_ONLY or TO_SELF.
- static compilation (@CompileStatic)
- Compiling Groovy code with the checks of @TypeChecked and every call bound when the code compiles, as in Java. Such code ignores methods that metaprogramming adds or replaces at run time.
- type checking (@TypeChecked)
- Checking at compile time that every method, property and assignment of the code exists and fits its types. In Groovy it is switched on for a class or a method with @TypeChecked, and the calls stay dynamic.
References
- Closures: owner, delegate, this and the delegation strategy (The Apache Software Foundation)
- Domain-Specific Languages: command chains, script base classes, @DelegatesTo, builders (The Apache Software Foundation)
- Groovy semantics: type checking and static compilation (The Apache Software Foundation)
- Pipeline syntax (Jenkins project)
- In-process Script Approval (Jenkins project)
- Gradle: Groovy build script primer (Gradle)
- Gradle Kotlin DSL primer (Gradle)
- Gradle DSL reference: DependencyHandler (Gradle)
Report a problem with this lesson
Kept only in this browser. Your Learn progress