Kotlin and Java Interop

Kotlin compiles to the same JVM bytecode that javac produces, which means Kotlin and Java classes can call each other directly inside the same project — no wrappers, no bridges, no serialization layer. This is why Kotlin could take over as Android’s primary language and slot into existing Spring Boot backends without a rewrite: a Kotlin file can import a Java class, and a Java file can import a Kotlin class, as if both were written in the same language. But the two languages don’t model types identically — most importantly, Java has no compile-time concept of nullability — so interop is also where Kotlin’s biggest safety guarantee, null safety, gets tested. This lesson covers how calls cross the boundary in both directions, what changes, and where the sharp edges are.

Overview: How Java Interop Works

Both kotlinc and javac target the same output format: JVM .class files made of bytecode, constant pools, and method descriptors. The JVM itself has no idea which source language produced a given class — it just sees methods, fields, and types. That shared target is the entire reason interop is possible: a Kotlin class and a Java class sitting in the same compiled module look, to the runtime, like two classes written in the same language. Two big differences leak across that boundary anyway.

Platform types and the null-safety gap

Kotlin’s type system distinguishes String (never null) from String? (may be null), and the compiler enforces the distinction everywhere in pure Kotlin code. Java’s type system has no such distinction — a Java String getName() could return an actual string or null, and nothing in its signature says which. When Kotlin calls an unannotated Java method, it cannot know the answer, so it assigns the result a special type written internally as String! and called a platform type. A platform type is Kotlin’s way of saying "this came from Java, we don’t know if it’s nullable — you decide." You’re allowed to treat it as String or as String?; the compiler won’t stop you either way. If you treat it as non-null and it turns out to be null, you get a runtime NullPointerException — the exact failure Kotlin’s null safety exists to prevent, sneaking back in at the one place the compiler can’t see far enough. Some modern Java libraries annotate their APIs with @Nullable/@NonNull (JSR-305, JetBrains annotations, or JSpecify); Kotlin reads those annotations and turns the platform type into a real String or String? automatically. Unannotated JDK and third-party APIs are still common, though, so treat every unannotated Java return value as nullable until you’ve checked.

Properties, getters, and setters

Kotlin doesn’t have a separate "property" concept at the bytecode level — a Kotlin val/var property compiles to a private backing field plus getX()/setX() methods, exactly the JavaBeans convention. That symmetry runs both ways: Java code calls a Kotlin property through its generated getter/setter, and Kotlin code sees a Java class following the getX()/setX() convention as if it were a native Kotlin property (obj.x instead of obj.getX()).

No checked exceptions

Java’s compiler forces you to catch or declare checked exceptions (throws IOException); Kotlin has no such mechanism at all — every exception in Kotlin is effectively unchecked. Calling a Java method that declares checked exceptions from Kotlin never requires a try/catch; the compiler won’t complain if you skip it, so it’s on you to know when a Java API can throw.

Making Kotlin comfortable to call from Java

Kotlin has JVM-specific annotations, all in the kotlin.jvm package, whose entire purpose is to make Kotlin APIs feel natural from Java: @JvmStatic generates a real static method for a companion object member (otherwise Java must go through Companion.method()); @JvmOverloads generates overloaded methods for a function with default parameter values, since Java has no concept of default parameters; @JvmField exposes a property as a plain public field with no getter/setter; and @JvmName renames the generated method, most often to resolve a signature clash caused by JVM type erasure. On top of that, any Kotlin lambda can be passed directly to a Java method expecting a single-abstract-method (SAM) functional interface — the compiler converts it automatically.

Syntax

The interop features covered in this lesson aren’t a single syntax form — they’re a handful of annotations and a type notation. Here’s the reference:

Feature Where it’s written What it does
Type! Compiler-internal notation, never written by hand A platform type: an unannotated Java type Kotlin couldn’t classify as nullable or non-null.
@JvmStatic On a function or property inside a companion object Generates a true JVM static member so Java can call ClassName.method() instead of ClassName.Companion.method().
@JvmOverloads On a function with default parameter values Generates one overload per trailing default parameter, since Java has no default arguments.
@JvmField On a property Exposes the backing field directly to Java, skipping the generated getter/setter.
@JvmName("name") On a function or file Renames the generated bytecode method/class, usually to dodge a signature clash from type erasure.

These annotations are typically combined on a class meant to be called from both languages:

class Example {
    companion object {
        @JvmStatic
        fun factory(): Example = Example()
    }

    @JvmField
    val id: Int = 0

    @JvmOverloads
    fun configure(name: String, retries: Int = 3) {
    }
}

factory() becomes a real static method thanks to @JvmStatic; id becomes a plain public field with no getId() thanks to @JvmField; and configure gets a Java-callable overload that fills in retries thanks to @JvmOverloads.

Examples

Example 1: Calling JDK Classes Directly

Kotlin ships no collections or date library of its own that replaces the JDK’s — it uses the JDK’s classes directly. Here Kotlin code calls straight into java.time.LocalDate, part of the Java standard library, with no wrapper:

import java.time.LocalDate

fun main() {
    val launch: LocalDate = LocalDate.of(2026, 1, 15)
    val followUp: LocalDate = launch.plusDays(30)
    println("Launch: $launch")
    println("Follow-up: $followUp")
}

Output:

Launch: 2026-01-15
Follow-up: 2026-02-14

Nothing here is Kotlin-specific: LocalDate is a plain Java class, of and plusDays are its ordinary methods, and Kotlin calls them with normal method-call syntax. The only Kotlin flavor is the string template ("$launch") instead of concatenation — everything else is standard Java API usage reached through Kotlin syntax.

Example 2: Platform Types in Practice

System.getProperty is a Java standard library method with no nullability annotation, so from Kotlin it returns a platform type. Declaring the receiving variable as String? is the safe move — it forces you to handle the missing case instead of assuming a value is always there:

fun main() {
    val value: String? = System.getProperty("kotlin.lesson.demo")
    val message = value ?: "property not set"
    println(message)
}

Output:

property not set

Because kotlin.lesson.demo was never set on the JVM, getProperty returns null at runtime. Declaring value as String? makes that possibility visible in the type, so the Elvis operator (?:) can supply a fallback safely. Had value been declared as plain String instead, the code would still compile — platform types are assignable to either — but it would crash the moment getProperty actually returned null. See Common Mistake 1 below for exactly that failure.

Example 3: Exposing a Companion Function to Java with @JvmStatic

By default, a function inside a Kotlin companion object compiles to an instance method on a generated Companion object, not a real static method — fine for Kotlin callers, awkward for Java callers. @JvmStatic fixes that:

class MathUtils {
    companion object {
        @JvmStatic
        fun square(x: Int): Int = x * x
    }
}

fun main() {
    println(MathUtils.square(5))
    println(MathUtils.Companion.square(5))
}

Output:

25
25

From Kotlin, both call styles already work and print the same result. The difference only shows up on the Java side: without @JvmStatic, Java code is forced to write MathUtils.Companion.square(5); with it, @JvmStatic additionally generates a genuine static int square(int) method on MathUtils itself, so Java can call MathUtils.square(5) directly, matching how a Java developer would expect a utility method to look.

Example 4: Default Parameters and @JvmOverloads

Kotlin functions can give parameters default values; Java has no equivalent, so a Kotlin function with defaults compiles to a single method that always requires every argument when called from Java. @JvmOverloads generates the missing overloads:

class Greeter {
    @JvmOverloads
    fun greet(name: String, greeting: String = "Hello"): String {
        return "$greeting, $name!"
    }
}

fun main() {
    val greeter = Greeter()
    println(greeter.greet("Ada"))
    println(greeter.greet("Ada", "Hi"))
}

Output:

Hello, Ada!
Hi, Ada!

Kotlin callers could already omit greeting without the annotation. @JvmOverloads is what makes an equivalent greeter.greet("Ada") call resolve from Java source: it generates a second, overloaded greet(String) method that fills in "Hello" and delegates to the full one.

How It Works Step by Step

When a Kotlin file in a mixed project calls a Java class (or vice versa), the toolchain performs these steps:

  1. The Kotlin compiler reads the compiled Java class files (or, in a mixed-source build, compiles Java and Kotlin together) to learn each Java class’s public signatures.
  2. For every Java member it references, Kotlin checks for nullability annotations. Annotated types become a real Type or Type?; everything else becomes a platform type Type!.
  3. Java members that follow the getX()/setX()/isX() convention are exposed to Kotlin code as properties (obj.x), while still existing as ordinary methods if called that way.
  4. Kotlin declarations are compiled to plain JVM bytecode: properties become a field plus getter/setter, top-level functions become static methods on a generated FileNameKt class, and companion object members become instance methods on a Companion object unless annotated with @JvmStatic.
  5. The JVM links and runs the resulting classes exactly as it would a Java-only or Kotlin-only program — by the time bytecode exists, the source language is no longer tracked anywhere.

Common Mistakes

Mistake 1: Trusting a Platform Type as Non-Null

Declaring a variable with an explicit non-null type forces Kotlin to insert a runtime null-check right where the platform-typed value is assigned. If the Java call actually returns null, the program crashes there — not later, and not gracefully:

fun main() {
    val home: String = System.getProperty("kotlin.lesson.missing")
    println(home.length)
}

Output:

Exception in thread "main" java.lang.NullPointerException
(thrown by Kotlin's inserted platform-type null-check, because
getProperty returned null but home was declared as the non-null
type String)

This compiles cleanly — platform types are assignable to non-null Kotlin types — which is exactly what makes it dangerous. Declare the variable as nullable and handle the missing case explicitly instead:

fun main() {
    val home: String? = System.getProperty("kotlin.lesson.missing")
    println(home?.length ?: -1)
}

Output:

-1

Mistake 2: Assuming a Read-Only List Is Immutable Across a Cast

Kotlin’s List<T> vs. MutableList<T> split is enforced by the compiler only — at runtime both are ordinary java.util.List objects, and a cast can bypass the restriction entirely:

fun main() {
    val readOnly: List<Int> = listOf(1, 2, 3)
    val mutable = readOnly as MutableList<Int>
    mutable.add(4)
    println(readOnly)
}

Output:

Exception in thread "main" java.lang.UnsupportedOperationException
(thrown because listOf's backing list is fixed-size; casting it
to MutableList does not make it resizable)

The cast compiles because Kotlin can’t verify at compile time whether the underlying object truly supports mutation — that’s a runtime property of whichever java.util.List implementation backs it. Never cast a read-only collection to its mutable interface; make an independent, genuinely mutable copy instead:

fun main() {
    val readOnly: List<Int> = listOf(1, 2, 3)
    val mutable = readOnly.toMutableList()
    mutable.add(4)
    println(readOnly)
    println(mutable)
}

Output:

[1, 2, 3]
[1, 2, 3, 4]

Mistake 3: Forgetting @JvmStatic and Breaking Java Callers

A companion object member with no @JvmStatic compiles fine from Kotlin, but a Java caller expecting an ordinary static method won’t find one:

// Kotlin -- compiles, but only reachable from Java via Companion
class MathUtilsNoAnnotation {
    companion object {
        fun cube(x: Int): Int = x * x * x
    }
}
// Java -- does not compile
public class Caller {
    public static void main(String[] args) {
        int result = MathUtilsNoAnnotation.cube(3); // error: cannot find symbol
    }
}

Java has no way to call cube without going through MathUtilsNoAnnotation.Companion.cube(3), which most Java developers won’t expect or discover on their own. Add @JvmStatic (as in Example 3) whenever a companion function is meant to be part of a public API that Java code will call.

Best Practices

  • Treat every unannotated Java return value as nullable until you’ve checked the source or documentation — declare the receiving Kotlin type as Type? by default.
  • Prefer Java libraries and JDK APIs that ship with @Nullable/@NonNull annotations (JSR-305, JSpecify); Kotlin turns those into real nullable/non-null types automatically instead of platform types.
  • Add @JvmStatic to any companion object member meant to be called from Java as a plain static method.
  • Add @JvmOverloads to any public Kotlin function with default parameters that Java code needs to call with fewer arguments.
  • Never cast a Kotlin read-only List/Map/Set to its mutable counterpart — call toMutableList()/toMutableMap()/toMutableSet() to get a real, independent mutable copy.
  • Wrap risky Java calls (I/O, parsing, anything documented as throwing) in try/catch even though Kotlin doesn’t force you to — Kotlin’s lack of checked exceptions doesn’t mean the exception can’t happen.
  • When designing a Kotlin library meant for Java consumers, write and compile a small Java sample against it before shipping — it’s the fastest way to catch an awkward Companion.method() call or a missing overload.

Practice Exercises

  • Write a Kotlin fun main() that creates a java.util.Random with a fixed seed (for example Random(42)) and prints three calls to nextInt(100). Check the JDK documentation to confirm what, if anything, is nullable about the methods you use.
  • Write a class ConfigReader with a function fun readOrDefault(key: String, default: String): String that looks up key with System.getProperty(key) and returns default if the property is missing. Handle the platform type correctly — the function should never crash even if the property doesn’t exist.
  • Add a companion object to a class of your choice with a factory function, mark it @JvmStatic, and write one sentence explaining, in your own words, what changes in the generated bytecode versus leaving the annotation off.

Summary

  • Kotlin and Java compile to the same JVM bytecode, so classes from either language can call each other directly in a mixed project.
  • Unannotated Java types become Kotlin platform types (Type!) — treat them as nullable until proven otherwise, or risk a runtime NullPointerException the compiler couldn’t catch.
  • Kotlin properties compile to getter/setter pairs, so property syntax and method-call syntax are interchangeable across the boundary depending on which language is calling.
  • Kotlin has no checked exceptions, so calling a Java API that declares throws never forces a try/catch — that safety net is gone at the interop boundary.
  • @JvmStatic, @JvmOverloads, @JvmField, and @JvmName exist specifically to make Kotlin APIs feel natural from Java.
  • Kotlin’s read-only vs. mutable collection interfaces are a compile-time-only distinction; the runtime object is the same java.util class, so casting across that boundary is unsafe.