Java interop, JVM tooling and build performance
Platform types at the boundary, JvmStatic and JvmOverloads, SAM conversions, annotation processing, and making the build faster.
Calling Java and being called from it
// platform types: Java returns String!, which is neither String nor String?
val javaName: String = legacy.getName() // compiles, may throw at runtime
val safeName: String? = legacy.getName() // honest about the risk
val checked = requireNotNull(legacy.getName()) { "name missing from legacy API" }
// make Java callers comfortable with the Kotlin API
class ReportService @JvmOverloads constructor(
private val title: String,
private val pageSize: Int = 50,
private val includeCharts: Boolean = false,
) {
@JvmStatic
fun default(): ReportService = ReportService("untitled")
@JvmField
val created: Long = System.currentTimeMillis()
}
// SAM conversion works for Java interfaces
val runnable = Runnable { println("from a lambda") }
// Kotlin fun interfaces declared with "fun interface" get the same treatment
fun interface Validator { fun isValid(value: String): Boolean }
val nonEmpty = Validator { it.isNotBlank() }- Every value coming from Java is a platform type. Annotate the Java side with
@Nullableand@NotNullso Kotlin can check it. @JvmStaticputs a companion function on the class instead of the Companion object, which is what Java callers expect.@JvmOverloadsgenerates overloads for default parameters; without it, Java sees only the full signature.- Kotlin properties do not become fields for Java unless you add
@JvmField, which forbids a custom getter.
Modern JVM types
| Java | Kotlin equivalent | Note |
|---|---|---|
record Point(int x, int y) | data class Point(val x: Int, val y: Int) | Kotlin sees records as classes with accessors |
| Sealed interface | sealed interface | Both give exhaustive switches |
Optional<T> | T? | Do not use Optional in Kotlin APIs |
| Checked exception | None | Kotlin has no checked exceptions; Java callers still see them |
var | val / var | Avoid var in API signatures |
Kotlin compiles records to a normal class from the language's point of view. Use @JvmRecord on a Kotlin data class only when a Java framework requires the record shape.
Annotation processing and build speed
// gradle.properties: the settings that actually move the needle
org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
kotlin.incremental=true
kotlin.incremental.useClasspathSnapshot=true
// Prefer KSP over kapt: it runs ahead of compilation and is several times faster
plugins {
id("com.google.devtools.ksp") version "2.1.0-1.0.29"
}
dependencies {
ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.1")
}💡
Use the build scan (
./gradlew build --scan) before changing anything. It tells you which task dominates and whether the cost is compilation, testing, or configuration — most "slow build" work is spent optimising the wrong phase.FAQ
How do I stop a Java library from crashing my Kotlin code?
Wrap the boundary: convert platform types into explicit nullable or non-null types immediately, and add a test that exercises the null and error paths. Never let a platform type travel through the codebase unchecked.
kapt or KSP?
KSP wherever a processor supports it. It understands Kotlin symbols directly instead of generating Java stubs first, so it is faster and plays better with incremental compilation.
Related
Setting up Kotlin: Gradle, the K2 compiler and project layout Kotlin Multiplatform: expect, actual and shared modules
Last refreshed 2026-09-18.