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.
What you’ll practice
Section titled “What you’ll practice”- 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,@JvmNameannotations - SAM conversions with Java functional interfaces (
Comparator,Predicate,Callable) - Mixed Kotlin/Java compilation in a single Gradle project
Requirements
Section titled “Requirements”- Read Java standard-library types from Kotlin and handle their nullability safely.
- Call a custom Java service from Kotlin, treating its return values as platform types.
- Use SAM conversion to pass Kotlin lambdas where Java expects a functional interface.
- Write Kotlin classes and top-level functions, annotated so Java can call them naturally.
- Have the Java code call back into the Kotlin classes to prove the round trip works.
The worked solution
Section titled “The worked solution”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)
build.gradle.kts
Section titled “build.gradle.kts”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.
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")}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.
package com.example.interop
import java.math.BigDecimalimport 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.
package com.example.interop
import java.time.*import java.time.format.DateTimeFormatterimport 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).
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; }}Main.kt — calling Java from Kotlin
Section titled “Main.kt — calling Java from Kotlin”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.
package com.example.interop
import java.util.concurrent.Callableimport 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 callsKotlinUtils.greet(...)instead ofKotlinForJavaKt.greet(...).@JvmOverloadson the constructor and ongreetgenerates one overload per default parameter, so Java can callnew UserConfig()orgreet("Alice").@JvmStaticlifts companion functions to real static methods.@JvmFieldexposesMAX_USERSas a bare field — no generated getter.@JvmName("sumOfInts")/@JvmName("joinStrings")give the twosumAllextensions distinct names, since theirList<Int>vsList<String>receivers erase to the same JVM signature and would otherwise clash.
@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@JvmOverloadsfun 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.
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); }}Run it
Section titled “Run it”-
Run the demo (the
applicationplugin invokesMain.kt, which in turn callsJavaCaller.demonstrate()to prove Java-calls-Kotlin works):Terminal window ./gradlew run -
The output walks through each section: BigDecimal money math,
java.timeoperations, platform-type handling,Optionalconversion, SAM conversions, and Kotlin-called-from-Java. Abbreviated:=== Java Interop Demo ===--- BigDecimal Money Arithmetic ---Price: 19.99Subtotal: 59.97Total: 64.77Final: 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 createdField access: 1000