Calling Java From Kotlin (and Back) Without Friction
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 your 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.
This post 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 from Kotlin with no ceremony — you import the class and call it. Kotlin even improves the ergonomics: a Java getter/setter pair shows up as a Kotlin property:
val name = person.getName() // works
val name = person.name // also works — getter seen as a property
person.age = 30 // calls setAge(30)
The one rough spot is null. Java doesn’t tell Kotlin whether a returned String can be null, so Kotlin treats it as a platform type — it trusts you and skips the forced check. Pin the type down as you receive it (val name: String to assert non-null, String? to stay safe), and if the Java code carries @Nullable/@NonNull annotations, Kotlin honors them and the ambiguity disappears.
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 writing an anonymous class:
executor.submit { doWork() } // Runnable
button.addActionListener { handleClick() } // ActionListener
This SAM conversion applies to Java interfaces automatically. (For Kotlin’s own types you use function types instead, so the conversion is specifically a Java-interop convenience.)
Calling Kotlin from Java
Going the other way is where the annotations come in, because some Kotlin features have no direct 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 lets you pick a nicer name:
@file:JvmName("StringUtils") // Java calls StringUtils.capitalize(...)
package text
fun capitalize(s: String): String = /* ... */
@JvmStatic exposes a companion object member as a real Java static, instead of forcing Java through the Companion instance:
class Config {
companion object {
@JvmStatic fun load(): Config = /* ... */ // Config.load() in Java
}
}
@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:
@Throws(IOException::class)
fun read(): String = /* ... */ // Java sees the throws clause
Kotlin properties seen from Java
The mirror of “Java getters look like properties in Kotlin” is that Kotlin properties become getters and setters in Java. A val name exposes getName(); a var adds setName():
class User(val id: Int, var name: String)
From Java that’s user.getId() and user.setName(...), the accessors generated for you — no boilerplate on either side of the boundary.
Read-only collections are only read-only in Kotlin
A subtle boundary gotcha: Kotlin’s read-only List is a compile-time distinction, not a runtime one. On the JVM it’s still java.util.List. So Java code handed a Kotlin “read-only” list can call .add() on it anyway — the guarantee evaporates the moment the reference crosses into Java. If immutability has to survive the trip, pass a genuine copy.
Unit, not void
A Kotlin function returning Unit compiles to a void method when called from Java — you won’t see Unit objects floating around. The reverse matters more: a Java void method shows up in Kotlin as returning Unit, which is what lets you treat it as a function value like any other.
Naming clashes
Two Kotlin functions can differ only by generic types that erase to the same JVM signature — legal in Kotlin, a duplicate-method error in the generated bytecode. @JvmName renames one at the bytecode level so both can coexist:
fun List<Int>.sum(): Int = fold(0) { a, b -> a + b }
@JvmName("sumDoubles") // without this, the two clash on the JVM
fun List<Double>.sum(): Double = fold(0.0) { a, b -> a + b }
Both functions erase to sum(List) in bytecode; @JvmName gives the second a distinct name so they coexist. 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, while calling Kotlin from Java needs a handful of @Jvm hints to paper over features Java doesn’t have — statics, default arguments, top-level functions, the absence of checked exceptions. Learn those five annotations and a mixed codebase stops feeling like two languages bolted together.
The last three posts are the features that go beyond the basics. First, the small standard-library functions that show up in every idiomatic Kotlin file: the scope functions.
Practice: reinforce this with the companion workbook — short, click-to-reveal problems.