Calling Java From Kotlin (and Back) Without Friction
Java getters seen as properties, platform types and nullability annotations, SAM conversion, arrays across the boundary, and the @Jvm annotations that make Kotlin code pleasant to call from Java.
Kotlin’s entire reason for existing is to share a runtime with Java. You can drop it into an existing Java codebase one file at a time, call the old code from the new, and ship the mix. Most of that just works — but a few rough spots in each direction have dedicated tools, and knowing them is what makes a mixed codebase feel seamless instead of merely possible. Everything here was run against Kotlin 2.4.10.
This chapter follows exceptions. It’s where the series turns outward, to the language Kotlin lives next to.
Calling Java from Kotlin
Most Java APIs are usable with no ceremony — import and call. Kotlin even improves the ergonomics: a Java getter/setter pair shows up as a Kotlin property, and a boolean isX()/setX() pair maps to a property named x:
val name = person.name // calls getName()
person.age = 30 // calls setAge(30)
if (task.isDone) { } // calls isDone()
The rough spot is null. Java’s type doesn’t say whether a returned String can be null, so Kotlin treats it as a platform type, written String! — it trusts you and skips the forced check, but a null still crashes at the point of use. Pin the type down as you receive it:
val name: String = person.name // assert non-null — fail fast at the boundary
val name: String? = person.name // or treat it as nullable and handle it
If the Java code carries nullability annotations — @Nullable/@NonNull, or a JSpecify @NullMarked package — Kotlin honors them and the platform type disappears, turning the ambiguity into an enforced type. Annotating your Java, or wrapping it thinly in Kotlin, is the durable fix for a boundary that produces NPEs.
SAM conversion
Java is full of single-abstract-method interfaces — Runnable, Comparator, listeners of every kind. When a Java method expects one, Kotlin lets you pass a lambda directly instead of an anonymous class:
executor.submit { doWork() } // Runnable
button.addActionListener { handleClick() } // ActionListener
This SAM conversion applies automatically to Java interfaces. For Kotlin’s own types you use function types instead, so the conversion is specifically a Java-interop convenience (and the reason fun interface exists for the rare Kotlin type that wants the same treatment).
Arrays across the boundary
Arrays are the one place the mapping needs attention. A Java String[] is Array<String> in Kotlin, but Kotlin’s primitive arrays are distinct types: int[] is IntArray, not Array<Int>. When calling a Java varargs method, use the spread operator to pass an existing array:
val parts: Array<String> = arrayOf("a", "b")
String.join(", ", *parts) // spread into the Java vararg
Match the array type to what the Java signature wants — hand an IntArray where it expects int[], an Array<Int> where it expects Integer[] — and the boxing distinction from the types chapter follows you across the boundary.
Calling Kotlin from Java
The other direction is where the annotations come in, because some Kotlin features have no Java equivalent. Each @Jvm annotation tells the compiler to generate a shape Java can call comfortably.
Top-level functions don’t belong to a class, but the JVM requires one, so Kotlin puts them in a synthetic FileNameKt class. @file:JvmName picks a nicer name:
@file:JvmName("StringUtils") // Java calls StringUtils.capitalize(...)
package text
fun capitalize(s: String): String = /* ... */ s
@JvmStatic exposes a companion object member as a real Java static, instead of forcing Java through the Companion instance (Config.load() rather than Config.Companion.load()).
@JvmOverloads generates the overload pile that default arguments made unnecessary in Kotlin, because Java can’t call a function and skip parameters:
@JvmOverloads
fun connect(host: String, port: Int = 443) { /* ... */ }
// Java gets connect(host) and connect(host, port)
@JvmField exposes a property as a plain field rather than a getter/setter pair, and @Throws declares the checked exceptions Java’s compiler wants to see — necessary because Kotlin has none.
Two boundary gotchas
The guarantees Kotlin adds are mostly compile-time, so a couple of them don’t survive the trip into Java.
Read-only collections aren’t read-only in Java. A Kotlin read-only List is java.util.List on the JVM, so Java code handed one can call .add() anyway — the guarantee evaporates the instant the reference crosses into Java (the same “it’s a compile-time contract” point that chapter made about casts). If immutability must survive the trip, pass a genuine copy.
internal isn’t hidden from Java. As the visibility chapter noted, internal compiles to public with a mangled name, so Java can reach an internal member if it spells the mangled name — the module boundary is enforced only within Kotlin.
Unit, not void
A Kotlin function returning Unit compiles to a void method seen from Java — no Unit objects floating around. The reverse matters more: a Java void method appears in Kotlin as returning Unit, which is what lets you treat it as a function value like any other.
Naming clashes from erasure
Two Kotlin functions can differ only by generic types that erase to the same JVM signature — legal in Kotlin, a duplicate-method error in bytecode. @JvmName renames one at the bytecode level so both coexist:
fun List<Int>.sum(): Int = fold(0) { a, b -> a + b }
@JvmName("sumDoubles")
fun List<Double>.sum(): Double = fold(0.0) { a, b -> a + b }
Both erase to sum(List); @JvmName gives the second a distinct name. It’s the same annotation that renamed the top-level file class above — a general tool for controlling the names Java sees.
Final thoughts
The interop story is lopsided on purpose: calling Java from Kotlin is nearly free, because Kotlin was designed to consume it — with platform-type nullability the one thing to pin down at the boundary — while calling Kotlin from Java needs a handful of @Jvm hints to paper over features Java lacks: statics, default arguments, top-level functions, checked-exception metadata. Learn those annotations, remember that read-only and internal are Kotlin-side fictions, and a mixed codebase stops feeling like two languages bolted together.
The last three chapters go beyond the basics. First, the small standard-library functions in every idiomatic Kotlin file: the scope functions.
Practice: reinforce this with the companion workbook — short, click-to-reveal problems.
Comments