08 · Java Interop Deep Dive¶
Almost every real Kotlin codebase touches Java: an existing service being
migrated incrementally, a Java library with no Kotlin equivalent, or a
Kotlin library that needs to be usable from Java callers who've never
seen suspend or data class. Kotlin was designed for this — but the
seams show up in specific, learnable places. This module calls Java from
Kotlin and Kotlin from Java, compiling both directions for real.
Calling Java from Kotlin¶
// JavaLegacy.java
public class JavaLegacy {
public String name;
private int score;
public JavaLegacy(String name, int score) {
this.name = name;
this.score = score;
}
public int getScore() { return score; }
public void setScore(int score) { this.score = score; }
public String maybeNull(boolean giveNull) {
return giveNull ? null : "present";
}
}
fun main() {
val legacy = JavaLegacy("Ada", 90)
// Java getter/setter pair -> Kotlin property syntax
println("score via property: ${legacy.score}")
legacy.score = 95
println("score after set: ${legacy.score}")
// Platform type: Kotlin can't know from bytecode alone
// whether this return value is nullable
val maybe: String? = legacy.maybeNull(true)
println("maybe is null: ${maybe == null}")
}
$ javac JavaLegacy.java
$ kotlinc Interop.kt -cp . -include-runtime -d interop.jar
$ java -cp interop.jar:. InteropKt
score via property: 90
score after set: 95
maybe is null: true
Two things happened automatically: getScore()/setScore(int) became
legacy.score (Kotlin recognizes the Java getter/setter naming
convention and synthesizes a property), and maybeNull's return type
showed up as a platform type (String! in error messages) — neither
definitely nullable nor definitely non-null, because Java's type system
has no annotation-free way to express nullability. Kotlin trusts the
caller to declare the right type (String? here) rather than forcing a
choice; declaring it String instead would compile fine and then throw
NullPointerException at the println if maybeNull ever actually
returned null — the null-safety guarantee Kotlin gives for its own code
doesn't extend automatically across a Java boundary.
Calling Kotlin from Java¶
class Greeter {
companion object {
@JvmStatic
fun staticGreet(name: String): String = "Hello, $name (static)"
}
@JvmOverloads
fun greet(name: String, punctuation: String = "!"): String = "Hi $name$punctuation"
}
@JvmName("createGreeter")
fun factory(): Greeter = Greeter()
public class CallFromJava {
public static void main(String[] args) {
System.out.println(Greeter.staticGreet("Java"));
Greeter g = KotlinLibKt.createGreeter();
System.out.println(g.greet("World"));
System.out.println(g.greet("World", "?"));
}
}
$ kotlinc KotlinLib.kt -include-runtime -d kotlinlib.jar
$ javac -cp kotlinlib.jar CallFromJava.java
$ java -cp kotlinlib.jar:. CallFromJava
Hello, Java (static)
Hi World!
Hi World?
Three annotations did the work: without @JvmStatic, a companion
object function compiles to an instance method on a synthetic
Companion object (Greeter.Companion.staticGreet(...)), awkward from
Java — @JvmStatic generates a real static method instead. Without
@JvmOverloads, a default parameter (punctuation: String = "!")
compiles to one Java method requiring both arguments — Java has no
concept of default parameters — @JvmOverloads generates the overload
(greet(String) calling greet(String, String)) that lets Java callers
omit it. And top-level functions compile into a file class named
<Filename>Kt (KotlinLib.kt → KotlinLibKt) unless @JvmName
overrides it — factory() is called as KotlinLibKt.createGreeter()
from Java specifically because of the @JvmName("createGreeter")
annotation above it; without it, Java would call
KotlinLibKt.factory().
The platform type trap in more detail¶
Platform types are the single biggest interop hazard because they're
invisible at the call site — nothing in the Kotlin code marks
legacy.maybeNull(...) as different from a call to a Kotlin function
that's guaranteed non-null. The only signals are the IDE showing
String! in a hover tooltip, and the crash if the assumption is wrong.
Two defenses:
- Annotate Java APIs you control with
@Nullable/@NonNull(JSR-305 or JetBrains annotations) — Kotlin reads these and turns the platform type into a realString?/String, restoring compile-time checking. - For Java APIs you don't control (a third-party library), wrap the boundary explicitly: a thin Kotlin adapter function that calls the Java method once and returns a properly nullable/non-null Kotlin type, so every other Kotlin caller gets real null-safety instead of a fresh platform type at every call site.
Traps¶
equals/hashCodefrom a Java class are used as-is — a Java class with a broken or absentequalsoverride behaves the same way from Kotlin (reference equality via==maps to Java's.equals()in Kotlin, unlike Java where==is always reference equality) — Kotlin doesn't retroactively fix a Java class's identity semantics.- Java's checked exceptions are invisible to Kotlin. Kotlin has no
checked exceptions at all, so calling a Java method declared
throws IOExceptioncompiles with notry/catchrequired — the exception can still be thrown at runtime; only the compiler's enforcement is gone, so relying on "the compiler would have told me" for Java-thrown checked exceptions doesn't work from Kotlin call sites. - Java collections crossing into Kotlin are mutable regardless of the
Kotlin type used to reference them. A Java method returning
List<String>can be assigned to a Kotlinval list: List<String>(Kotlin's read-only interface), but the underlying object is still the same mutableArrayList— anything else holding a reference to it (including the Java code that returned it) can still mutate it out from under the Kotlin caller, sinceListvsMutableListis a Kotlin-only compile-time distinction with no enforcement at the JVM bytecode level. @JvmFieldvs. a Kotlin property matters for Java frameworks that need direct field access (some serialization/reflection libraries expect apublicfield, not a getter) — a plain Kotlinvalcompiles to a private field plus a getter, which such a framework won't find unless@JvmFieldis added to expose the field itself.
How It Actually Works¶
legacy.score resolving to getScore()/setScore(int) is a purely
syntactic convenience the Kotlin compiler applies while reading Java
bytecode: it recognizes the getX/setX method-pair naming convention on
any class it imports (Kotlin or Java) and lets you spell the pair as
property access — there's no new Java bytecode involved, legacy.score
literally compiles to legacy.getScore(), and legacy.score = 95 compiles
to legacy.setScore(95). This is why it also works for Kotlin classes:
"properties" in Kotlin are always this same getter/setter shape underneath
(as covered in the classes module), so the convention is symmetric in both
directions.
@JvmStatic on a companion-object function exists because, without it,
Greeter.staticGreet(...) wouldn't compile from Java the way you'd expect:
recall from the classes module that object/companion declarations compile
to an INSTANCE field on a real class, meaning a companion function is
normally an instance method on Greeter.Companion, reachable from Java
only as the verbose Greeter.Companion.staticGreet(...).
@JvmStatic tells the compiler to additionally generate a real static
method directly on Greeter itself (which just delegates to the
companion's instance method), giving Java callers the plain, static-style
call they'd expect from a factory or utility method. @JvmOverloads does
something similar for default arguments: recall that a function with
defaults compiles to a single method taking every parameter plus a hidden
bitmask (covered in the functions module) — @JvmOverloads makes the
compiler additionally emit one overload per suffix of trailing default
parameters, so Java sees greet(String) and greet(String, String) as two
real, separate methods instead of one method Java has no convenient way to
call with fewer arguments. @JvmName("createGreeter") similarly overrides
the synthetic <FileName>Kt static-method name Java would otherwise see
for a top-level function, letting you present a cleaner name across the
interop boundary without changing anything about how Kotlin callers see it.
Cheat sheet¶
| Concern | Annotation/pattern |
|---|---|
Companion function as real Java static |
@JvmStatic |
| Default parameters usable from Java | @JvmOverloads |
| Rename the generated file-class or method | @JvmName("...") |
| Expose a raw field (not getter) to Java | @JvmField |
| Trust a Java API's nullability | @Nullable/@NonNull on the Java side |
| Unknown Java nullability at a call site | Platform type (String!) — wrap it |
| Java getter/setter pair | Kotlin property syntax (obj.prop) |
Exercise¶
Add a second Java class, JavaConfig, with a public method
public static String env(String key, String fallback) that returns
System.getenv(key) or fallback if null. Call it from Kotlin as a
platform type, assign the result to an explicitly-typed val port:
String (non-nullable), and set an environment variable when running so
the non-null assumption holds; then remove the environment variable and
observe the NullPointerException at the assignment when the platform
type turns out to have been null after all.