Configuring the kotlin {} Extension: Toolchains and Compiler Options
Pin a JDK with jvmToolchain, shape bytecode with compilerOptions, and make every build reproducible.
When you apply the `org.jetbrains.kotlin.jvm` plugin, Gradle exposes a configuration block called the `kotlin {}` extension. This is the single, official place to tell the Kotlin Gradle Plugin how your code should be compiled: which JDK to build against, which JVM bytecode version to emit, and which extra flags to pass to the compiler. Everything you configure here is read by the Kotlin compiler tasks, so it applies consistently to your main sources, your tests, and any other source set the plugin manages.
The most important line for most projects is `jvmToolchain(17)`. A toolchain is Gradle's abstraction for 'the exact JDK used to compile and run this code'. When you request a toolchain, Gradle does not blindly use whatever `java` happens to be on the developer's `PATH`; instead it locates an installed JDK matching that version (or, with the Foojay resolver plugin, downloads one automatically) and pins both the Kotlin and Java compilation tasks to it. This is the key to reproducible builds: a teammate on JDK 21, a CI runner on JDK 17, and a brand-new laptop all compile against the very same JDK 17, so they produce identical bytecode instead of subtly different output that 'works on my machine'.
Inside `compilerOptions { }` you fine-tune what the compiler does. `jvmTarget.set(JvmTarget.JVM_17)` controls the bytecode version that is actually written to `.class` files โ it is conceptually separate from the toolchain (which JDK runs the compiler) even though you normally keep them aligned. `freeCompilerArgs.add("-Xjsr305=strict")` lets you pass raw compiler flags that have no dedicated DSL property, such as opt-ins for experimental features or strict nullability handling for Java interop. And `allWarningsAsErrors.set(true)` promotes every compiler warning into a hard build failure, which is an excellent hygiene setting for libraries and shared modules because it stops deprecated APIs and risky casts from silently accumulating. Note that `compilerOptions` is the modern API that replaces the older, now-deprecated `kotlinOptions` block.
It helps to understand why the toolchain and `jvmTarget` are distinct knobs. The toolchain answers 'with which JDK do we run javac and kotlinc?', while `jvmTarget` answers 'what is the minimum JVM that can run the resulting class files?'. You could, for example, compile with a JDK 21 toolchain but target JVM 17 bytecode so the artifact still runs on Java 17 servers. In practice, when you set `jvmToolchain(17)` the Kotlin plugin already infers a matching `jvmTarget`, so you only override `jvmTarget` explicitly when you deliberately want the two to differ. Keeping them equal is the safest default and avoids surprising `UnsupportedClassVersionError`s at runtime from newer bytecode landing on an older JVM.
In a modern Gradle 8 setup these settings live in `build.gradle.kts` using the Kotlin DSL, while plugin and dependency versions are centralized in a `gradle/libs.versions.toml` version catalog that Gradle picks up automatically, and `settings.gradle.kts` wires repositories and resolution together. Adding the `org.gradle.toolchains.foojay-resolver-convention` plugin in `settings.gradle.kts` is highly recommended: it teaches Gradle to auto-provision the requested JDK when it is not already installed, so a fresh clone builds without anyone manually downloading Java first. Together these pieces turn 'install the right JDK, then build' into a single `./gradlew build` that behaves the same everywhere.
The payoff is a build that is portable, deterministic, and self-documenting. Anyone reading `build.gradle.kts` can see exactly which JDK and bytecode level the project commits to, CI and local machines converge on identical compiler behavior, and warning-as-error policies keep the codebase clean over time. Because Gradle caches toolchains and the compilation tasks are deterministic given the same inputs, you also get faster incremental builds and reliable build caching โ reproducibility and speed end up reinforcing each other.
// build.gradle.kts โ configuring the kotlin {} extensionimport org.jetbrains.kotlin.gradle.dsl.JvmTargetplugins {alias(libs.plugins.kotlin.jvm) // version comes from the catalog}kotlin {jvmToolchain(17) // pin compile + run to JDK 17 for reproducible buildscompilerOptions {jvmTarget.set(JvmTarget.JVM_17) // bytecode version emitted to .class filesfreeCompilerArgs.add("-Xjsr305=strict") // strict nullability for Java interopallWarningsAsErrors.set(true) // fail the build on any compiler warning}}
The kotlin {} block pins the JDK, the bytecode target, and compiler flags in one place. compilerOptions is the modern replacement for the deprecated kotlinOptions.
# gradle/libs.versions.toml โ centralized version catalog (auto-detected by Gradle)[versions]kotlin = "2.1.0"[plugins]# referenced as libs.plugins.kotlin.jvm in build.gradle.ktskotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
Keep the Kotlin version in one catalog so every module stays in sync.
// settings.gradle.kts โ auto-provision the requested JDK toolchainplugins {// downloads the requested JDK automatically if it isn't installed locallyid("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0"}dependencyResolutionManagement {repositories { mavenCentral() } // where dependencies resolve from}rootProject.name = "my-kotlin-app"
The Foojay resolver lets a fresh clone build without manually installing Java first.
๐ง Check your understanding
0/1 ยท 0/1 answered1. Why does calling jvmToolchain(17) make a Kotlin build more reproducible across different machines?