Skip to content

Java Interop

Practice two-way interop in a single project: call Java standard-library classes and your own Java code from Kotlin, then make Kotlin classes comfortable to use from Java. The JVM lets .java and .kt files compile together and call each other — the friction is mostly about nullability and naming, and a handful of @Jvm… annotations smooth it over.

  • Calling Java stdlib classes from Kotlin: java.time, BigDecimal, Optional
  • Platform types (T!) and null safety across the Kotlin-Java boundary
  • The @JvmStatic, @JvmOverloads, @JvmField, @JvmName annotations
  • SAM conversions with Java functional interfaces (Comparator, Predicate, Callable)
  • Mixed Kotlin/Java compilation in a single Gradle project
  1. Read Java standard-library types from Kotlin and handle their nullability safely.
  2. Call a custom Java service from Kotlin, treating its return values as platform types.
  3. Use SAM conversion to pass Kotlin lambdas where Java expects a functional interface.
  4. Write Kotlin classes and top-level functions, annotated so Java can call them naturally.
  5. Have the Java code call back into the Kotlin classes to prove the round trip works.

A single Gradle module with both .java and .kt source under the same package. The java plugin plus the Kotlin plugin let them compile together and reference each other freely.

  • Directoryjava-interop/
    • build.gradle.kts kotlin + java + application plugins
    • settings.gradle.kts project name
    • Directorysrc/main/
      • Directorykotlin/com/example/interop/
        • Main.kt entry point, drives every demo
        • MoneyUtils.kt BigDecimal money arithmetic
        • TimeUtils.kt java.time date calculations
        • KotlinForJava.kt Kotlin classes designed to be called from Java
      • Directoryjava/com/example/interop/
        • JavaService.java Java class called from Kotlin (platform types)
        • JavaCaller.java Java code that calls Kotlin (tests @Jvm annotations)

The mix only works because three plugins are applied together: kotlin("jvm") compiles the .kt files, java compiles the .java files, and application wires up ./gradlew run. The jvmToolchain(21) line pins both compilers to the same JDK so they agree on the bytecode target.

build.gradle.kts
plugins {
kotlin("jvm") version "2.1.0"
java
application
}
group = "com.example"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
}
kotlin {
jvmToolchain(21)
}
dependencies {
testImplementation(kotlin("test"))
}
tasks.test {
useJUnitPlatform()
}
application {
mainClass.set("com.example.interop.MainKt")
}
settings.gradle.kts
rootProject.name = "java-interop"

MoneyUtils.kt — calling java.math from Kotlin

Section titled “MoneyUtils.kt — calling java.math from Kotlin”

BigDecimal is a plain Java class, but Kotlin’s operator overloading makes it read like primitive math: price * quantity calls BigDecimal.multiply under the hood. The hard rule: build money values from the String constructor (BigDecimal("19.99")), never the Double one — BigDecimal(19.99) captures the binary float’s imprecision.

src/main/kotlin/com/example/interop/MoneyUtils.kt
package com.example.interop
import java.math.BigDecimal
import java.math.RoundingMode
// --- BigDecimal arithmetic for precise money calculations ---
// Rule: NEVER use Double for money. Use BigDecimal with String constructor.
object MoneyUtils {
// Helper to create money values with 2 decimal places
fun money(amount: String): BigDecimal =
BigDecimal(amount).setScale(2, RoundingMode.HALF_UP)
// Calculate tax
fun calculateTax(subtotal: BigDecimal, taxRate: BigDecimal): BigDecimal =
(subtotal * taxRate).setScale(2, RoundingMode.HALF_UP)
// Calculate discount
fun calculateDiscount(amount: BigDecimal, discountPercent: BigDecimal): BigDecimal =
(amount * discountPercent).setScale(2, RoundingMode.HALF_UP)
fun demo() {
println("--- BigDecimal Money Arithmetic ---")
val price = money("19.99")
val quantity = BigDecimal("3")
val subtotal = price * quantity
val taxRate = BigDecimal("0.08")
val tax = calculateTax(subtotal, taxRate)
val total = subtotal + tax
val discountRate = BigDecimal("0.10")
val discount = calculateDiscount(total, discountRate)
val finalAmount = total - discount
println("Price: $price")
println("Quantity: $quantity")
println("Subtotal: ${subtotal.setScale(2, RoundingMode.HALF_UP)}")
println("Tax (8%): $tax")
println("Total: ${total.setScale(2, RoundingMode.HALF_UP)}")
println("Discount (10%): $discount")
println("Final: ${finalAmount.setScale(2, RoundingMode.HALF_UP)}")
// Demonstrate why Double is bad for money
println()
println("Why not Double?")
println(" Double: 0.1 + 0.2 = ${0.1 + 0.2}")
println(" BigDecimal: 0.1 + 0.2 = ${BigDecimal("0.1") + BigDecimal("0.2")}")
// compareTo vs equals
val a = BigDecimal("1.0")
val b = BigDecimal("1.00")
println(" 1.0 == 1.00 (equals): ${a == b}") // false -- scale differs
println(" 1.0 compareTo 1.00: ${a.compareTo(b) == 0}") // true -- value is same
}
}

TimeUtils.kt — calling java.time from Kotlin

Section titled “TimeUtils.kt — calling java.time from Kotlin”

java.time (JSR-310) is the modern date/time API and it works unchanged from Kotlin. The only Kotlin flavour here is current.dayOfWeek in listOf(...), which compiles to a contains call — the in operator is Kotlin sugar over a plain Java collection. Note parseFlexible returns LocalDate?: a Kotlin nullable, because parsing can fail and we want the caller to handle it.

src/main/kotlin/com/example/interop/TimeUtils.kt
package com.example.interop
import java.time.*
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
// --- java.time usage from Kotlin ---
// The java.time API (JSR-310) is the modern way to handle dates in JVM code.
object TimeUtils {
fun demo() {
println("--- java.time Date/Time Operations ---")
// Current timestamps
val now = Instant.now()
val today = LocalDate.now()
val zoned = ZonedDateTime.now(ZoneId.of("UTC"))
println("Now (UTC): $zoned")
println("Today: $today")
// Formatting
val sampleDate = LocalDate.of(2024, 1, 15)
val formatted = sampleDate.format(DateTimeFormatter.ofPattern("MMMM d, yyyy"))
println("Formatted: $formatted")
// Parsing
val parsed = LocalDate.parse("2024-03-15")
println("Parsed: $parsed")
// Date arithmetic
val thirtyDaysLater = today.plusDays(30)
println("30 days from now: $thirtyDaysLater")
// Days until end of year
val endOfYear = LocalDate.of(today.year, 12, 31)
val daysUntilNewYear = ChronoUnit.DAYS.between(today, endOfYear)
println("Days until new year: $daysUntilNewYear")
// Business days calculation
val startDate = LocalDate.of(2024, 1, 1)
val endDate = LocalDate.of(2024, 1, 31)
val businessDays = countBusinessDays(startDate, endDate)
println("Business days between $startDate and $endDate: $businessDays")
// Weekend check -- `in` compiles to a contains() call on the Java list
val isWeekend = today.dayOfWeek in listOf(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY)
println("Is weekend: $isWeekend")
// Duration between two times
val duration = Duration.between(LocalTime.of(9, 0), LocalTime.of(17, 30))
println("Work day duration: ${duration.toHours()}h ${duration.toMinutesPart()}m")
}
// Calculate business days (Monday-Friday) between two dates
fun countBusinessDays(start: LocalDate, end: LocalDate): Long {
var count = 0L
var current = start
while (current.isBefore(end) || current == end) {
if (current.dayOfWeek !in listOf(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY)) {
count++
}
current = current.plusDays(1)
}
return count
}
// Parse a date string with multiple format attempts -> nullable on total failure
fun parseFlexible(dateStr: String): LocalDate? {
val formats = listOf(
DateTimeFormatter.ISO_LOCAL_DATE, // 2024-01-15
DateTimeFormatter.ofPattern("MM/dd/yyyy"), // 01/15/2024
DateTimeFormatter.ofPattern("dd-MM-yyyy"), // 15-01-2024
DateTimeFormatter.ofPattern("yyyy.MM.dd"), // 2024.01.15
)
for (fmt in formats) {
try {
return LocalDate.parse(dateStr, fmt)
} catch (_: Exception) {
continue
}
}
return null
}
}

JavaService.java — the Java code Kotlin will call

Section titled “JavaService.java — the Java code Kotlin will call”

This is plain Java with no nullability annotations, which is exactly the point. findUserName can return null (one user is mapped to null on purpose), but nothing in the signature says so. Kotlin therefore sees its return as the platform type String! and won’t force a null check — so it’s on you to treat it as nullable. The Transformer<T, R> interface is marked @FunctionalInterface; because it has a single abstract method, Kotlin can pass a lambda where one is expected (SAM conversion).

src/main/java/com/example/interop/JavaService.java
package com.example.interop;
import java.util.*;
/**
* A Java service class that Kotlin code will call.
* Demonstrates platform types (T!) -- Kotlin does not know if these return null.
*/
public class JavaService {
private final Map<String, String> users = new HashMap<>();
public JavaService() {
users.put("u1", "Alice");
users.put("u2", "Bob");
users.put("u3", null); // Intentional null value
}
// Returns String (no @Nullable/@NotNull) -> Kotlin sees String! (platform type)
public String findUserName(String id) {
return users.get(id); // Can return null!
}
// Returns a list that may contain null elements
public List<String> getAllNames() {
return new ArrayList<>(users.values());
}
// Returns an Optional (Java 8+ pattern)
public Optional<String> findUserNameOptional(String id) {
return Optional.ofNullable(users.get(id));
}
// A functional interface defined in Java (for SAM conversion demo)
@FunctionalInterface
public interface Transformer<T, R> {
R transform(T input);
}
// Method accepting a functional interface
public <T, R> List<R> mapValues(List<T> items, Transformer<T, R> transformer) {
List<R> result = new ArrayList<>();
for (T item : items) {
result.add(transformer.transform(item));
}
return result;
}
// Method accepting Java's Predicate
public List<String> filterNames(java.util.function.Predicate<String> predicate) {
List<String> result = new ArrayList<>();
for (String name : users.values()) {
if (name != null && predicate.test(name)) {
result.add(name);
}
}
return result;
}
}

The entry point drives every demo. The interesting lines are where Java values cross into Kotlin. findUserName is declared into a String?, so the rest of the code is null-safe. getAllNames() returns a list that genuinely contains a null, so it’s typed List<String?> and cleaned with filterNotNull(). Each SAM block — sortWith, filterNames, mapValues, Callable — passes a Kotlin lambda straight into a Java single-method interface.

src/main/kotlin/com/example/interop/Main.kt
package com.example.interop
import java.util.concurrent.Callable
import java.util.concurrent.Executors
fun main() {
println("=== Java Interop Demo ===")
println()
MoneyUtils.demo()
println()
TimeUtils.demo()
println()
// 3. Calling Java from Kotlin -- platform types
println("--- Calling Java from Kotlin (platform types) ---")
val service = JavaService()
// findUserName returns String! (platform type) -- SAFE: capture as nullable
val name: String? = service.findUserName("u1")
println("Java service returned: $name (type: ${name?.let { it::class.simpleName }})")
// Handle potential null from Java
val missing: String? = service.findUserName("u999")
println("Null-safe handling: ${missing ?: "not found"}")
// Java list may contain nulls -> type it List<String?> and filter
val allNames: List<String?> = service.getAllNames()
println("Java list (watch for nulls): $allNames")
println("Filtered non-null: ${allNames.filterNotNull()}")
println()
// 4. Optional handling
println("--- Optional Handling ---")
val optionalName = service.findUserNameOptional("u1")
println("Present: ${optionalName.orElse("not found")}")
// Convert Optional to Kotlin nullable immediately with orElse(null)
val kotlinNullable: String? = service.findUserNameOptional("u1").orElse(null)
println("Kotlin-style (nullable): $kotlinNullable")
println()
// 5. SAM conversions -- pass Kotlin lambdas to Java single-method interfaces
println("--- SAM Conversions ---")
val names = mutableListOf("Alice", "Bob", "Charlie")
// Comparator<T> SAM conversion
names.sortWith { a, b -> a.length - b.length }
println("Sorted by length: $names")
// Java Predicate SAM conversion
val filtered = service.filterNames { it.length > 3 }
println("Filtered (Predicate SAM): $filtered")
// Custom Java functional interface (Transformer<T, R>) SAM conversion
val uppercased = service.mapValues(listOf("Alice", "Bob", "Charlie")) { it.uppercase() }
println("Mapped (Function SAM): $uppercased")
// Executor + Callable SAM conversion
val executor = Executors.newSingleThreadExecutor()
try {
val future = executor.submit(Callable {
"Task completed on ${Thread.currentThread().name}"
})
println("Executor result: ${future.get()}")
} finally {
executor.shutdown()
}
println()
// 6. Kotlin called from Java -- demonstrate() calls back into Kotlin classes
JavaCaller.demonstrate()
println()
// 7. @JvmName functions used from the Kotlin side
println("--- @JvmName Usage from Kotlin ---")
val intList = listOf(1, 2, 3, 4, 5)
val strList = listOf("hello", "world")
println("Sum of ints: ${intList.sumAll()}")
println("Join strings: ${strList.sumAll()}")
println("Slugified: ${"Hello World! From Kotlin".slugify()}")
}

KotlinForJava.kt — making Kotlin Java-friendly

Section titled “KotlinForJava.kt — making Kotlin Java-friendly”

This is the reverse direction: Kotlin written so Java can call it without ugly ceremony. Without these annotations Java sees odd shapes — companion methods become UserConfig.Companion.createDefault(), default parameters vanish, and a top-level function lands in a class named KotlinForJavaKt. The @Jvm… annotations fix each of those:

  • @file:JvmName("KotlinUtils") renames the synthetic class that holds the top-level functions, so Java calls KotlinUtils.greet(...) instead of KotlinForJavaKt.greet(...).
  • @JvmOverloads on the constructor and on greet generates one overload per default parameter, so Java can call new UserConfig() or greet("Alice").
  • @JvmStatic lifts companion functions to real static methods.
  • @JvmField exposes MAX_USERS as a bare field — no generated getter.
  • @JvmName("sumOfInts") / @JvmName("joinStrings") give the two sumAll extensions distinct names, since their List<Int> vs List<String> receivers erase to the same JVM signature and would otherwise clash.
src/main/kotlin/com/example/interop/KotlinForJava.kt
@file:JvmName("KotlinUtils")
package com.example.interop
// --- Kotlin classes designed to be called from Java ---
// Uses @JvmStatic, @JvmField, @JvmOverloads, @JvmName for Java-friendly APIs.
class KotlinForJava {
// @JvmOverloads on the constructor -- Java gets multiple constructors
data class UserConfig @JvmOverloads constructor(
val maxUsers: Int = 100,
val defaultRole: String = "viewer",
val debugMode: Boolean = false
) {
companion object {
// @JvmField -- accessible as UserConfig.MAX_USERS from Java (no getter)
@JvmField
val MAX_USERS = 1000
// @JvmStatic -- callable as UserConfig.createDefault() from Java
@JvmStatic
fun createDefault(): UserConfig = UserConfig()
// Without @JvmStatic, Java would need: UserConfig.Companion.fromEnv()
@JvmStatic
fun fromEnv(): UserConfig {
val maxUsers = System.getenv("MAX_USERS")?.toIntOrNull() ?: 100
val role = System.getenv("DEFAULT_ROLE") ?: "viewer"
val debug = System.getenv("DEBUG")?.toBoolean() ?: false
return UserConfig(maxUsers, role, debug)
}
}
}
}
// --- Top-level functions ---
// @file:JvmName("KotlinUtils") means these compile into class KotlinUtils
// instead of the default KotlinForJavaKt.
fun formatFullName(first: String, last: String): String {
return "$first $last".trim()
}
// @JvmOverloads on a top-level function -- Java gets overloaded static methods
@JvmOverloads
fun greet(name: String, greeting: String = "Hello"): String {
return "$greeting, $name!"
}
// Extension function -- from Java it becomes: KotlinUtils.slugify(String)
fun String.slugify(): String {
return this.lowercase()
.replace(Regex("[^a-z0-9\\s-]"), "")
.replace(Regex("\\s+"), "-")
.trim('-')
}
// @JvmName to resolve clashes -- both take List but return different types
@JvmName("sumOfInts")
fun List<Int>.sumAll(): Int = this.sum()
@JvmName("joinStrings")
fun List<String>.sumAll(): String = this.joinToString(", ")

JavaCaller.java — Java calling back into Kotlin

Section titled “JavaCaller.java — Java calling back into Kotlin”

Finally, plain Java exercises every annotation above. Note what it can do only because of those annotations: construct UserConfig with fewer than all three arguments (@JvmOverloads), call UserConfig.createDefault() as a static (@JvmStatic), read UserConfig.MAX_USERS as a field (@JvmField), and reach the top-level functions through the renamed KotlinUtils class (@JvmName). The extension function slugify arrives as a static method whose first parameter is the receiver.

src/main/java/com/example/interop/JavaCaller.java
package com.example.interop;
/**
* Demonstrates calling Kotlin code from Java.
* Shows how @JvmStatic, @JvmField, @JvmOverloads work from the Java side.
*/
public class JavaCaller {
public static void demonstrate() {
System.out.println("--- Kotlin Called from Java ---");
// @JvmOverloads: all defaults
KotlinForJava.UserConfig config1 = new KotlinForJava.UserConfig();
System.out.println("UserConfig: " + config1);
// Partial defaults -- only possible because of @JvmOverloads
KotlinForJava.UserConfig config2 = new KotlinForJava.UserConfig(50, "admin", true);
System.out.println("UserConfig (custom): " + config2);
// @JvmStatic: call companion method like a static method
KotlinForJava.UserConfig config3 = KotlinForJava.UserConfig.createDefault();
System.out.println("Static method: UserConfig instance created");
// @JvmField: access companion field directly (no getter)
int maxUsers = KotlinForJava.UserConfig.MAX_USERS;
System.out.println("Field access: " + maxUsers);
// Top-level function via @file:JvmName("KotlinUtils")
String formatted = KotlinUtils.formatFullName("John", "Doe");
System.out.println("Top-level function: " + formatted);
// @JvmOverloads on a top-level function
String greeting1 = KotlinUtils.greet("Alice"); // default greeting
String greeting2 = KotlinUtils.greet("Alice", "Hi"); // custom greeting
System.out.println("Greet (default): " + greeting1);
System.out.println("Greet (custom): " + greeting2);
// Extension function -- receiver becomes the first parameter
String slugified = KotlinUtils.slugify("Hello World From Java");
System.out.println("Extension (slugify): " + slugified);
}
}
  1. Run the demo (the application plugin invokes Main.kt, which in turn calls JavaCaller.demonstrate() to prove Java-calls-Kotlin works):

    Terminal window
    ./gradlew run
  2. The output walks through each section: BigDecimal money math, java.time operations, platform-type handling, Optional conversion, SAM conversions, and Kotlin-called-from-Java. Abbreviated:

    === Java Interop Demo ===
    --- BigDecimal Money Arithmetic ---
    Price: 19.99
    Subtotal: 59.97
    Total: 64.77
    Final: 58.29
    --- Calling Java from Kotlin (platform types) ---
    Java service returned: Alice (type: String)
    Java list (watch for nulls): [Alice, Bob, null]
    Filtered non-null: [Alice, Bob]
    --- SAM Conversions ---
    Sorted by length: [Bob, Alice, Charlie]
    Executor result: Task completed on pool-1-thread-1
    --- Kotlin Called from Java ---
    UserConfig: UserConfig(maxUsers=100, defaultRole=viewer, debugMode=false)
    Static method: UserConfig instance created
    Field access: 1000