PONY λ M2 Modula-2

Kotlin.CodeCompared.To/Java

An interactive executable cheatsheet comparing Kotlin and Java

Kotlin 2.3 Java 26
Basics & Ceremony
Hello, World
fun main() { println("Hello, World!") }
class Main { public static void main(String[] args) { System.out.println("Hello, World!"); } }
Nothing in Java lives outside a class, so the smallest program needs one — plus the public static void main(String[] args) incantation the JVM looks for by name, plus semicolons, plus System.out.println where Kotlin gives you a top-level println. Every line of this is ceremony that Kotlin either inferred or provided for you, and the ratio does not improve much from here.
val and var become final and var
Kotlin makes you choose between val and var on every declaration, and nudges hard toward val. Java has the same two ideas, but the default is reversed and one of them is barely used.
fun main() { val fixed = 1 // immutable, and the default you reach for var counter = 0 // mutable, opt-in counter += 1 // fixed = 2 ← will not compile val explicit: Long = 42 val greeting = "hi" // type inferred println("${fixed + counter} $explicit $greeting") }
class Main { static final int MAX_RETRIES = 3; // fields ALWAYS need an explicit type public static void main(String[] args) { final int fixed = 1; // "val" is spelled "final", and almost nobody writes it var counter = 0; // "var" is inferred (Java 10+) — and always MUTABLE counter += 1; // fixed = 2; ← will not compile long explicit = 42L; var greeting = "hi"; // inferred String System.out.println((fixed + counter) + " " + explicit + " " + greeting + " (max " + MAX_RETRIES + ")"); } }
Java's var is Kotlin's var exactly — inferred and mutable — but there is no val. Immutability is spelled final, it is opt-in rather than the default, and in practice most Java code omits it, so you lose the at-a-glance guarantee that a local never changes. The inference is also narrower than it looks: var is legal only for local variables. Fields, parameters, and return types always spell out their type.
No top-level functions
Kotlin lets a function or constant sit at the top of a file with no class around it. On the JVM that is an illusion the compiler maintains for you — and in Java, you maintain it yourself.
const val GOLDEN_RATIO = 1.618 fun twice(number: Int) = number * 2 fun main() { println(twice(21)) println(GOLDEN_RATIO) }
// Every function is a method, and every method needs a class to live in. // The convention for "just a bag of functions" is a final class with a private // constructor and nothing but static members — java.lang.Math, java.util.Objects // and java.util.Collections are all built this way. final class MathUtilities { private MathUtilities() {} // nobody may instantiate this static final double GOLDEN_RATIO = 1.618; static int twice(int number) { return number * 2; } } class Main { public static void main(String[] args) { System.out.println(MathUtilities.twice(21)); System.out.println(MathUtilities.GOLDEN_RATIO); } }
The JVM has no concept of a top-level function, so kotlinc quietly wraps your file in a synthetic class named after it — Utilities.kt becomes UtilitiesKt, and twice becomes a static method on it. Java simply makes you name that class and write static on every member. The good news: your Kotlin top-level functions were already static methods, so nothing about the calling convention changes. The bad news: MathUtilities.twice(21) is now the shortest form there is, unless you add a import static.
Types, Boxing & Equality
int vs Integer: boxing becomes visible
Kotlin has one Int type and decides behind your back whether it compiles to a primitive int or a boxed Integer. Java makes that choice part of the type, and hands you a famous bug along with it.
fun main() { val primitive: Int = 127 // compiles to a primitive int val boxed: Int? = 127 // nullable, so it compiles to a java.lang.Integer val first: Int = 127 val second: Int = 127 println(first == second) // true — == is always structural val large: Int = 128 val alsoLarge: Int = 128 println(large == alsoLarge) // true — still structural, no surprise at 128 val numbers: List<Int> = listOf(1, 2, 3) // boxed underneath, invisible here println(numbers.sum() + primitive + (boxed ?: 0)) }
import java.util.List; class Main { public static void main(String[] args) { int primitive = 127; // a value: no methods, cannot be null Integer boxed = 127; // an object: has methods, can be null // The JVM caches boxed Integers in the range -128..127 and hands out the // SAME object for each one. So reference equality accidentally works... Integer first = 127, second = 127; System.out.println(first == second); // true — same cached object // ...right up until it does not. Integer large = 128, alsoLarge = 128; System.out.println(large == alsoLarge); // false — two distinct objects! System.out.println(large.equals(alsoLarge)); // true — what you meant List<Integer> numbers = List.of(1, 2, 3); // generics CANNOT hold int int total = 0; for (int number : numbers) total += number; // auto-unboxing System.out.println(total + primitive + boxed); } }
Java generics cannot hold primitives, so a List<int> is impossible and List<Integer> boxes every element — which is why the stream API has separate IntStream, LongStream and DoubleStream types. Autoboxing hides the conversion but not the consequences: == on two Integers compares references, the JVM caches the boxes from −128 to 127, and so the comparison silently reports true for small numbers and false for large ones. Kotlin has this same JVM behavior underneath and simply never exposes it, because Kotlin's == compiles to equals.
== is reference equality again
This is the single habit most likely to produce a bug in your first Java week. In Kotlin, == means equals and === means identity. In Java the symbols mean the other thing.
fun main() { val first = "hello" val rebuilt = String(charArrayOf('h', 'e', 'l', 'l', 'o')) println(first == rebuilt) // true — == calls .equals() println(first === rebuilt) // false — === is identity val missing: String? = null println(missing == "x") // false — no crash; == handles null on both sides }
import java.util.Objects; class Main { public static void main(String[] args) { String first = "hello"; String folded = "hel" + "lo"; // constant-folded to the same literal String rebuilt = new String(new char[]{'h', 'e', 'l', 'l', 'o'}); System.out.println(first == folded); // true — both are the interned literal System.out.println(first == rebuilt); // false — a different object System.out.println(first.equals(rebuilt)); // true — what you meant String missing = null; // missing.equals("x") ← throws NullPointerException System.out.println(Objects.equals(missing, "x")); // false, and no throw } }
The literal pool is what makes this so dangerous: string literals are interned, so == appears to work for as long as your strings come from source code, and starts failing the moment one arrives from a file, a socket, or substring. Use .equals() always, and reach for Objects.equals(a, b) when either side may be null — it is the null-safe version, and the closest thing Java has to Kotlin's ==. Java's identity comparison is ==; Kotlin's is ===. There is no way around learning this one by muscle memory.
Inference stops at the signature
// Return type inferred from the expression body. fun describe(count: Int) = if (count == 1) "one" else "$count items" fun main() { val names = mutableListOf("Ada") // MutableList<String>, inferred val scores = mutableMapOf("Ada" to listOf(9, 10)) // MutableMap<String, List<Int>> println("${describe(names.size)} $scores") }
import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; class Main { // Parameter and return types are mandatory. 'var' is not allowed here. static String describe(int count) { return count == 1 ? "one" : count + " items"; } public static void main(String[] args) { var names = new ArrayList<String>(); // inferred: ArrayList<String> names.add("Ada"); // The "diamond" <> infers the type arguments on the right from the left. Map<String, List<Integer>> scores = new HashMap<>(); scores.put("Ada", List.of(9, 10)); System.out.println(describe(names.size()) + " " + scores); } }
Java's inference is strictly local. var works for a local variable, the diamond <> infers type arguments on a constructor call, and lambdas infer their parameter types from the target interface — but a method signature is always fully spelled out, and there is no expression-bodied function whose return type can be inferred. In practice this means every API boundary in Java is explicitly typed, which is more readable to a stranger and more tedious to write.
Null Safety, Removed
Every reference is nullable
Kotlin's single best feature is not in Java at all. There is no String?, no String-that-cannot-be-null, and no compiler check between you and the exception.
fun findName(id: Int): String? = if (id == 1) "Ada" else null fun main() { val found = findName(1) // println(found.length) ← will not compile: found may be null println(found?.length) // 3 println(findName(99)?.length) // null — and there is no path to an NPE here }
class Main { // The signature says String. It may return null anyway, and not one caller // will be warned — by javac, at least. static String findName(int id) { return id == 1 ? "Ada" : null; } public static void main(String[] args) { String found = findName(1); System.out.println(found.length()); // fine, this time String missing = findName(99); try { System.out.println(missing.length()); // NullPointerException } catch (NullPointerException error) { System.out.println("NPE: " + error.getMessage()); } } }
Nullability is not in Java's type system, so it lives in documentation, in annotations, and in your head. The @Nullable / @NonNull annotations (JSpecify is the emerging standard) are checked by IDEs and static-analysis plugins, never by javac — an annotated violation compiles and ships. The one consolation is helpful NullPointerException messages (Java 14+), which name the exact expression that was null. This is also why Kotlin has "platform types" (String!): when Kotlin calls Java, the compiler has no idea whether null is possible and stops checking, which is the one hole in Kotlin's null safety and the reason a Kotlin/Java mixed codebase still throws NPEs.
?. becomes a null check or an Optional chain
class Address(val city: String?) class Person(val address: Address?) fun main() { val known = Person(Address("Oslo")) val unknown = Person(null) println(known.address?.city?.uppercase()) // OSLO println(unknown.address?.city?.uppercase()) // null — the whole chain short-circuits }
import java.util.Optional; class Address { private final String city; Address(String city) { this.city = city; } String getCity() { return city; } } class Person { private final Address address; Person(Address address) { this.address = address; } Address getAddress() { return address; } } class Main { public static void main(String[] args) { var known = new Person(new Address("Oslo")); var unknown = new Person(null); // The literal translation: guard every hop yourself. if (known.getAddress() != null && known.getAddress().getCity() != null) { System.out.println(known.getAddress().getCity().toUpperCase()); } // Or wrap and chain — Optional.map is the closest thing to ?. there is. System.out.println(Optional.ofNullable(unknown.getAddress()) .map(Address::getCity) .map(String::toUpperCase) .orElse("null")); } }
Both translations cost something. The explicit guard evaluates getAddress() twice (a real bug if it is not a pure getter) and grows quadratically with the depth of the chain. The Optional version reads closer to the original but allocates a wrapper per hop and only works if you are willing to convert at the boundary. Neither is checked: forget the guard and you get a NullPointerException where Kotlin would have refused to compile.
The Elvis operator becomes Objects.requireNonNullElse
fun greet(name: String?) = "Hello, ${name ?: "stranger"}" fun lengthOrFail(text: String?): Int { val checked = text ?: throw IllegalArgumentException("text must not be null") return checked.length } fun main() { println(greet(null)) println(greet("Ada")) println(lengthOrFail("Kotlin")) try { lengthOrFail(null) } catch (error: IllegalArgumentException) { println("rejected: ${error.message}") } }
import java.util.Objects; class Main { static String greet(String name) { // name ?: "stranger" String safe = Objects.requireNonNullElse(name, "stranger"); return "Hello, " + safe; } static int lengthOrFail(String text) { // text ?: throw IllegalArgumentException(...) Objects.requireNonNull(text, "text must not be null"); return text.length(); } public static void main(String[] args) { System.out.println(greet(null)); System.out.println(greet("Ada")); System.out.println(lengthOrFail("Kotlin")); try { lengthOrFail(null); } catch (NullPointerException error) { System.out.println("rejected: " + error.getMessage()); } } }
The pieces are all there, just spelled out: Objects.requireNonNullElse(value, fallback) is ?:, the plain ternary value != null ? value : fallback works too, and Objects.requireNonNull(value, message) is the throwing form — though note it throws NullPointerException, not IllegalArgumentException, which is the JDK convention for a null argument. What you lose is the ?: return form: Elvis works because Kotlin's throw and return are expressions of type Nothing, and Java has neither.
Optional: a nullable type you have to allocate
Java's answer to absence is a library type, not a language feature. It is genuinely useful, and it is used correctly only about half the time.
fun firstLongName(names: List<String>): String? = names.firstOrNull { it.length > 4 } fun main() { val names = listOf("Ada", "Grace", "Alan") println(firstLongName(names) ?: "(none)") println(firstLongName(listOf("Ada")) ?: "(none)") firstLongName(names)?.let { println("found $it") } println(firstLongName(names)!!.uppercase()) // !! — assert it is there }
import java.util.List; import java.util.Optional; class Main { static Optional<String> firstLongName(List<String> names) { return names.stream().filter(name -> name.length() > 4).findFirst(); } public static void main(String[] args) { var names = List.of("Ada", "Grace", "Alan"); System.out.println(firstLongName(names).orElse("(none)")); System.out.println(firstLongName(List.of("Ada")).orElse("(none)")); firstLongName(names).ifPresent(name -> System.out.println("found " + name)); System.out.println(firstLongName(names).map(String::toUpperCase).orElseThrow()); } }
The translation table is short: ?: is orElse, ?.let is ifPresent or map, and !! is orElseThrow. The difference is that String? costs nothing at run time — it is the same reference, and the check is erased — while Optional<String> is a real object wrapped around your value on every call. That cost drives the convention: use Optional for return types only, never for fields or parameters. And note the trap Kotlin does not have — Optional is itself a reference, so a badly written method can return a null Optional.
Strings
No string interpolation, at all
Java has never had string templates. A preview feature was proposed, previewed, and then withdrawn — so concatenation and format are still the whole toolkit.
fun main() { val name = "Ada" val age = 36 println("$name is $age") println("Next year: ${age + 1}") println("Formatted: %s is %d".format(name, age)) }
class Main { public static void main(String[] args) { String name = "Ada"; int age = 36; System.out.println(name + " is " + age); System.out.println("Next year: " + (age + 1)); // the parens are load-bearing System.out.printf("%s is %d%n", name, age); System.out.println("%s is %d".formatted(name, age)); // Java 15+ // The bug you will write in your first week: + is left-associative and // becomes concatenation as soon as either side is a String. System.out.println("Next year: " + age + 1); // "Next year: 361" } }
Every ${…} becomes either a + or a format slot, and the + form has a genuine footgun: once one operand is a String the whole left-to-right chain is concatenation, so "Next year: " + age + 1 produces Next year: 361 rather than 37. Wrap arithmetic in parentheses, or use formatted. The compiler turns a + chain into an efficient StringBuilder-equivalent for you, so the performance folklore about concatenation is out of date — except inside a loop, where you should still build a StringBuilder yourself.
Raw strings become text blocks
fun main() { val name = "Ada" val letter = """ Dear $name, Kotlin strips the common indent when you ask it to. Regards """.trimIndent() println(letter) }
class Main { public static void main(String[] args) { String name = "Ada"; // The indentation is measured from the CLOSING delimiter, and stripped // automatically — there is no trimIndent() to remember. String letter = """ Dear %s, Java strips the common indent for you. Regards """.formatted(name); System.out.print(letter); } }
Text blocks (Java 15+) are a straight upgrade on Kotlin's raw strings in one respect: the incidental indentation is stripped by the compiler based on where you put the closing """, so you never forget a trimIndent(). They are worse in the one that matters more — no interpolation — so the values come in through formatted or +. Escapes still work inside them if you need them (\n, and \ at end of line to suppress the newline).
Collections & Streams
Read-only lists become runtime-immutable ones
Kotlin splits List from MutableList in the type system: a read-only list has no add to call. Java has one List interface, and the distinction moves from compile time to run time.
fun main() { val fixed = listOf("Ada", "Grace") // type List<String>: no add() exists // fixed.add("Alan") ← will not compile val growable = fixed.toMutableList() // type MutableList<String> growable.add("Alan") println(growable) println(growable.toList()) // back to read-only }
import java.util.ArrayList; import java.util.List; class Main { public static void main(String[] args) { List<String> fixed = List.of("Ada", "Grace"); // immutable var growable = new ArrayList<>(fixed); // ArrayList == mutableListOf growable.add("Alan"); // fixed.add(...) COMPILES — List has an add() method. It throws instead. try { fixed.add("Alan"); } catch (UnsupportedOperationException error) { System.out.println("List.of is immutable: add() throws"); } System.out.println(growable); System.out.println(List.copyOf(growable)); // an immutable snapshot } }
The List interface declares add, and immutable implementations satisfy it by throwing UnsupportedOperationException — so the mistake Kotlin catches at compile time becomes a crash in production. In exchange, Java's immutability is stronger where it exists: List.of and List.copyOf are genuinely immutable, while Kotlin's listOf returns a read-only view that someone holding the underlying MutableList can still change beneath you. Watch also for Arrays.asList and Collections.unmodifiableList, two older half-measures that behave differently again.
Collection operators become Streams
Kotlin puts map, filter and friends directly on the collection. Java puts them on a separate pipeline object that you have to open and close.
fun main() { val names = listOf("Ada", "Grace", "Alan", "Barbara") val shouted = names.filter { it.length > 3 }.map { it.uppercase() } println(shouted) println(names.sumOf { it.length }) println(names.joinToString(", ")) println(names.groupBy { it.length }) }
import java.util.List; import java.util.stream.Collectors; class Main { public static void main(String[] args) { var names = List.of("Ada", "Grace", "Alan", "Barbara"); List<String> shouted = names.stream() // open the pipeline .filter(name -> name.length() > 3) .map(String::toUpperCase) .toList(); // terminal op: nothing runs without one System.out.println(shouted); System.out.println(names.stream().mapToInt(String::length).sum()); System.out.println(String.join(", ", names)); System.out.println(names.stream().collect(Collectors.groupingBy(String::length))); } }
Every transformation is bracketed by .stream() and a terminal operation, which is a lot of ceremony for a single map. The upside is that the pipeline is lazy and fused — one pass over the data, no intermediate lists — where Kotlin's eager operators allocate a new list at every step (which is exactly what asSequence() exists to avoid). mapToInt is the boxing tax made visible: generics cannot hold int, so summing needs the primitive-specialized IntStream. And Collectors is a whole vocabulary to learn — groupingBy, partitioningBy, toMap, joining — where Kotlin just has methods.
Maps: no "to", no [] operator
fun main() { val ages = mapOf("Ada" to 36) // read-only println(ages["Ada"]) println(ages["Nobody"]) // null — no exception println(ages.getOrDefault("Nobody", 0)) val counts = mutableMapOf<String, Int>() for (word in "a b a c a".split(" ")) { counts[word] = counts.getOrDefault(word, 0) + 1 } println(counts.toSortedMap()) }
import java.util.HashMap; import java.util.Map; import java.util.TreeMap; class Main { public static void main(String[] args) { Map<String, Integer> ages = Map.of("Ada", 36); // immutable: put() throws System.out.println(ages.get("Ada")); System.out.println(ages.get("Nobody")); // null — same story System.out.println(ages.getOrDefault("Nobody", 0)); var counts = new HashMap<String, Integer>(); for (String word : "a b a c a".split(" ")) { counts.merge(word, 1, Integer::sum); // the counter idiom, and a good one } System.out.println(new TreeMap<>(counts)); // sorted, for a stable printout } }
The to infix and the [] operator are both Kotlin conveniences with no Java equivalent: you write Map.of(key, value, key, value, …) (up to ten pairs, then Map.ofEntries) and call get/put by name. In return, Java's merge and computeIfAbsent are genuinely excellent — counts.merge(word, 1, Integer::sum) is a whole word-count in one line. One trap: Map.of deliberately randomizes its iteration order (to stop you depending on it), so never print or iterate one and expect stability.
Sequences: laziness is no longer opt-in
In Kotlin, laziness is a decision you make with asSequence(). In Java there is no eager pipeline API at all — Stream is the only one, and it is lazy.
fun main() { // Without asSequence()/sequenceOf(), each step would build a whole list first. val firstTwo = sequenceOf(1, 2, 3, 4, 5) .onEach { println("seeing $it") } .map { it * it } .take(2) .toList() println(firstTwo) println(generateSequence(1) { it * 2 }.take(6).toList()) // infinite, lazily }
import java.util.List; import java.util.stream.Stream; class Main { public static void main(String[] args) { // Streams are lazy by construction: the trace interleaves with the mapping, // and evaluation stops the moment limit(2) has what it needs. List<Integer> firstTwo = Stream.of(1, 2, 3, 4, 5) .peek(number -> System.out.println("seeing " + number)) .map(number -> number * number) .limit(2) .toList(); System.out.println(firstTwo); System.out.println(Stream.iterate(1, number -> number * 2).limit(6).toList()); } }
Everything you learned about sequences transfers directly — take is limit, onEach is peek, generateSequence is Stream.iterate — and you no longer have to decide, because Java has no eager alternative. The flip side is that there is no cheap path for a one-step transformation: names.map { … } in Kotlin is a single method call, while in Java the same thing pays for a stream, a lambda, and a collector every time.
Control Flow
when becomes a switch expression
Modern Java's arrow-form switch (Java 14+) is a real expression — no fallthrough, no break — and it is the closest thing to when in the language.
fun describe(code: Int) = when (code) { 200 -> "OK" 301, 302 -> "Redirect" 404 -> "Not Found" in 500..599 -> "Server Error ($code)" // a range condition — Java has no answer else -> "Unknown ($code)" } fun main() { println(describe(200)) println(describe(302)) println(describe(503)) }
class Main { static String describe(int code) { return switch (code) { case 200 -> "OK"; case 301, 302 -> "Redirect"; // multiple labels, no fallthrough case 404 -> "Not Found"; default -> { // A block arm needs 'yield' to produce its value. String kind = code >= 500 ? "Server Error" : "Unknown"; yield kind + " (" + code + ")"; } }; } public static void main(String[] args) { System.out.println(describe(200)); System.out.println(describe(302)); System.out.println(describe(503)); } }
The arrow form fixed the two historical horrors — implicit fallthrough and the mandatory break — and yield is the block arm's return. Two things do not come across. Java's switch must have a subject, so Kotlin's subject-less when { cond -> … } (a chain of arbitrary boolean tests) has to become an if/else if chain. And case labels must be constants, so a range test like in 500..599 becomes a guard, an if, or a default arm that does the check itself.
is-checks and smart casts become type patterns
This is where Java has most nearly caught up. Pattern matching for switch (Java 21) gives you the type test, the cast, and the exhaustiveness check in one construct — and record patterns go further than Kotlin does.
sealed interface Shape data class Circle(val radius: Double) : Shape data class Rectangle(val width: Double, val height: Double) : Shape fun describe(value: Any?): String = when { value is Int && value > 100 -> "big number $value" value is Int -> "number $value" value is String -> "string of ${value.length}" // smart cast: value IS a String here value is Circle -> "circle r=${value.radius}" value == null -> "nothing" else -> "something else" } fun main() { println(describe(7)) println(describe(1000)) println(describe("Kotlin")) println(describe(Circle(2.0))) println(describe(null)) }
sealed interface Shape permits Circle, Rectangle {} record Circle(double radius) implements Shape {} record Rectangle(double width, double height) implements Shape {} class Main { static String describe(Object value) { return switch (value) { case Integer number when number > 100 -> "big number " + number; // guard case Integer number -> "number " + number; case String text -> "string of " + text.length(); // already cast, like a smart cast case Circle(double radius) -> "circle r=" + radius; // record pattern: destructured case null -> "nothing"; // must be written, or null throws! default -> "something else"; }; } public static void main(String[] args) { System.out.println(describe(7)); System.out.println(describe(1000)); System.out.println(describe("Kotlin")); System.out.println(describe(new Circle(2.0))); System.out.println(describe(null)); } }
case Integer number is is Int plus its smart cast in a single token, and when guards are the &&. Record deconstruction — case Circle(double radius) — actually goes beyond Kotlin, which has no destructuring inside a when. The one thing to burn into memory: a switch on a null subject throws NullPointerException unless you write case null explicitly. It does not fall through to default. Kotlin's when handles null like any other value.
if is a statement again
fun main() { val score = 87 val grade = if (score >= 90) "A" else if (score >= 80) "B" else "C" println(grade) val verdict = if (score >= 80) { println("computing...") "pass" // the last expression IS the value } else { "fail" } println(verdict) }
class Main { public static void main(String[] args) { int score = 87; // The ternary is the only expression form, and it takes no block. String grade = score >= 90 ? "A" : score >= 80 ? "B" : "C"; System.out.println(grade); // When a branch needs to do work first, declare the variable unassigned // and assign it in every branch. The compiler checks you covered them all. final String verdict; if (score >= 80) { System.out.println("computing..."); verdict = "pass"; } else { verdict = "fail"; } System.out.println(verdict); } }
Java's if produces no value, so the assignment moves inside the branches. The final String verdict; declaration-then-assign pattern is worth adopting anyway: definite-assignment analysis will reject the code if any path leaves the variable unset, which recovers a weak version of the guarantee Kotlin gives by making if an expression. The ternary covers the simple cases, and nests legally but illegibly — three levels deep is where reviewers start objecting.
Ranges become C-style for loops
fun main() { for (index in 1..5) print("$index ") println() for (index in 10 downTo 0 step 2) print("$index ") println() println((1..5).sum()) println((0 until 5).toList()) val score = 87 println(score in 80..89) // a range is a value you can test against }
import java.util.stream.IntStream; class Main { public static void main(String[] args) { for (int index = 1; index <= 5; index++) System.out.print(index + " "); System.out.println(); // downTo and step have no keywords: it is all in the three clauses. for (int index = 10; index >= 0; index -= 2) System.out.print(index + " "); System.out.println(); System.out.println(IntStream.rangeClosed(1, 5).sum()); // 1..5 System.out.println(IntStream.range(0, 5).boxed().toList()); // 0 until 5 int score = 87; System.out.println(score >= 80 && score <= 89); // no range value to test against } }
The three-clause for is back, and it is the only way to count down or step by twos. IntStream.range (exclusive) and rangeClosed (inclusive) cover the functional cases, but they are streams, not values — so x in 1..5 becomes a hand-written x >= 1 && x <= 5. That is the real loss: a Kotlin range is an object you can store, pass, and test membership against; Java has no such type outside of a stream you can only consume once.
Functions & Parameters
No expression bodies, and no Unit
fun twice(number: Int) = number * 2 // return type inferred from the body fun shout(text: String): Unit = println(text.uppercase()) fun main() { println(twice(21)) shout("done") val nothing: Unit = shout("again") // Unit is a real value you can hold println(nothing) }
class Main { // Braces, an explicit return type, and the word 'return' — even for one line. static int twice(int number) { return number * 2; } // 'void' is not a type; it is the absence of one. Nothing can hold it. static void shout(String text) { System.out.println(text.toUpperCase()); } public static void main(String[] args) { System.out.println(twice(21)); shout("done"); // var nothing = shout("again"); ← will not compile: void is not a value } }
The one-line function is three lines now, and that is most of Java's reputation for verbosity in one sentence. The subtler difference is Unit versus void: Unit is an ordinary type with exactly one value, so it can be a generic argument — Function<String, Unit> is fine, and that is why Kotlin lambdas returning nothing compose like every other lambda. void is not a type, which is precisely why java.util.function needs a separate Consumer<T> interface instead of reusing Function<T, Void>.
Default arguments become overloads
fun greet(name: String, salutation: String = "Hello", punctuation: String = "!") = "$salutation, $name$punctuation" fun main() { println(greet("Ada")) println(greet("Ada", "Hi")) println(greet("Ada", punctuation = "?")) // a named argument skips the middle one }
class Main { // No defaults. You write the telescoping overloads by hand, each delegating // to the fullest one. static String greet(String name) { return greet(name, "Hello", "!"); } static String greet(String name, String salutation) { return greet(name, salutation, "!"); } static String greet(String name, String salutation, String punctuation) { return salutation + ", " + name + punctuation; } public static void main(String[] args) { System.out.println(greet("Ada")); System.out.println(greet("Ada", "Hi")); System.out.println(greet("Ada", "Hello", "?")); // no way to skip the middle one } }
Each default becomes another overload, and because the overloads are positional you can only omit a suffix of the parameter list — that last Kotlin call, which keeps the default salutation but changes the punctuation, has no Java equivalent short of passing "Hello" explicitly. This is also why so much Java uses the builder pattern. (Going the other way, @JvmOverloads on a Kotlin function tells the compiler to generate exactly these overloads for Java callers.)
Named arguments become the builder pattern
Java has no call-site parameter names — new Pizza("large", false, true) is all a reader gets. The builder pattern exists to work around exactly the two features Kotlin has in the language: named arguments and defaults.
data class Pizza( val size: String, val extraCheese: Boolean = false, val thinCrust: Boolean = false, ) fun main() { val pizza = Pizza(size = "large", thinCrust = true) println(pizza) }
class Pizza { private final String size; private final boolean extraCheese; private final boolean thinCrust; private Pizza(Builder builder) { this.size = builder.size; this.extraCheese = builder.extraCheese; this.thinCrust = builder.thinCrust; } static Builder builder(String size) { return new Builder(size); } @Override public String toString() { return size + " pizza (extraCheese=" + extraCheese + ", thinCrust=" + thinCrust + ")"; } static class Builder { private final String size; private boolean extraCheese = false; // here are the defaults private boolean thinCrust = false; Builder(String size) { this.size = size; } Builder extraCheese(boolean value) { this.extraCheese = value; return this; } Builder thinCrust(boolean value) { this.thinCrust = value; return this; } Pizza build() { return new Pizza(this); } } } class Main { public static void main(String[] args) { var pizza = Pizza.builder("large").thinCrust(true).build(); System.out.println(pizza); // The unreadable alternative the builder exists to avoid: // new Pizza("large", false, true) ← which boolean was which? } }
Forty lines of builder against one data class, and the builder is the recommended Java style. Parameter names are not even in the bytecode unless the class was compiled with -parameters, which is part of why Java never added named arguments: there was nothing reliable to name. Libraries like Lombok generate the builder for you with @Builder, which is what most large codebases do — a code generator standing in for a language feature.
vararg becomes ... and loses the spread operator
fun total(vararg numbers: Int): Int = numbers.sum() fun main() { println(total(1, 2, 3)) val existing = intArrayOf(4, 5, 6) println(total(*existing)) // the spread operator is required }
class Main { static int total(int... numbers) { // must be the last parameter int sum = 0; for (int number : numbers) sum += number; return sum; } public static void main(String[] args) { System.out.println(total(1, 2, 3)); int[] existing = {4, 5, 6}; System.out.println(total(existing)); // an array IS the parameter — no spread needed } }
The mechanics are identical — inside the method, numbers is a plain array either way — but the call site differs: Java's varargs parameter simply is an array, so passing one needs no operator, while Kotlin distinguishes the two and requires * to spread. Java has no spread operator at all, which becomes annoying the moment you want to pass an array plus one extra element (you build a new array). Both require the varargs parameter to come last.
Classes, Records & Objects
data class becomes record
Records (Java 16+) are Java's data classes, and this is one of the pages where the two languages nearly meet.
data class Person(val name: String, val age: Int) { init { require(age >= 0) { "age must be >= 0" } } fun greeting() = "Hi, $name" } fun main() { val ada = Person("Ada", 36) println(ada) // Person(name=Ada, age=36) println(ada.name) // a property println(ada == Person("Ada", 36)) // true — structural equality, generated println(ada.greeting()) val (name, age) = ada // destructuring, from componentN() println("$name / $age") }
record Person(String name, int age) { // The "compact constructor": validation only, no field assignment needed. Person { if (age < 0) throw new IllegalArgumentException("age must be >= 0"); } String greeting() { return "Hi, " + name; } } class Main { public static void main(String[] args) { var ada = new Person("Ada", 36); System.out.println(ada); // Person[name=Ada, age=36] System.out.println(ada.name()); // accessor is name(), not getName() System.out.println(ada.equals(new Person("Ada", 36))); // true — generated equals System.out.println(ada.greeting()); } }
You get the generated constructor, accessors, equals, hashCode and toString — the whole point of a data class. Four differences matter. A record is always final and always immutable (there is no var component), where a Kotlin data class can be neither. The accessors are name(), not getName(), which surprises older frameworks. There is no copy(). And destructuring works only inside a pattern switch, not in an assignment — Java has no componentN convention.
copy() has to be written by hand
data class Person(val name: String, val age: Int, val city: String) fun main() { val ada = Person("Ada", 36, "London") println(ada) println(ada.copy(age = 37)) // change one field, keep the rest println(ada.copy(city = "Oslo")) }
record Person(String name, int age, String city) { // No copy(). The convention is a "wither" per field you actually change. Person withAge(int newAge) { return new Person(name, newAge, city); } Person withCity(String newCity) { return new Person(name, age, newCity); } } class Main { public static void main(String[] args) { var ada = new Person("Ada", 36, "London"); System.out.println(ada); System.out.println(ada.withAge(37)); System.out.println(ada.withCity("Oslo")); // Or rebuild it in full — and hope nobody ever reorders the components. System.out.println(new Person(ada.name(), ada.age(), "Oslo")); } }
copy() is built out of named arguments and defaults, so a language with neither cannot have it. The workaround is one hand-written wither per field, which is code that must be updated every time the record gains a component. Rebuilding the record in full is worse than it looks: add a field, and every call site still compiles while silently dropping the new value — or, if two components share a type, quietly swaps them. A language-level "derived record creation" (ada with { age = 37; }) has been proposed for Java but has not shipped.
Properties become fields, constructors, and getters
Kotlin's primary constructor declares the class's state, its constructor, and its accessors all at once. Java gives you the three separately, and you write all three.
class Rectangle(val width: Double, var height: Double) { // A computed property — no backing field, just a getter. val area: Double get() = width * height } fun main() { val box = Rectangle(3.0, 4.0) println(box.area) box.height = 10.0 // calls the generated setter println(box.area) }
class Rectangle { private final double width; // 'val' → private final field + a getter private double height; // 'var' → private field + a getter AND a setter Rectangle(double width, double height) { this.width = width; this.height = height; } double getWidth() { return width; } double getHeight() { return height; } void setHeight(double height) { this.height = height; } double getArea() { return width * height; } // a computed property is just a method } class Main { public static void main(String[] args) { var box = new Rectangle(3, 4); System.out.println(box.getArea()); box.setHeight(10); System.out.println(box.getArea()); } }
Properties are a Kotlin feature, not a JVM one: val width already compiles to a private final field plus a getWidth() method, so this Java class is roughly what kotlinc was emitting all along — you are just writing it out. What is genuinely gone is the syntax: box.area becomes box.getArea(), assignment becomes setHeight(…), and there is no way to add a computed property to look like a field. The getX/setX naming is the JavaBeans convention, and frameworks (Jackson, Hibernate, Spring) find your state by reflecting over exactly those names — so the convention is load-bearing, not cosmetic.
Classes are open by default
Kotlin makes every class and method final unless you write open. Java's default is the exact opposite, and it changes how you have to think about your own API.
open class Animal { // 'open' is required — classes are final by default open fun speak() = "..." // and so are methods } class Cat : Animal() { // Cat itself is final: nothing may extend it override fun speak() = "meow" // 'override' is mandatory } fun main() { val animal: Animal = Cat() println(animal.speak()) }
class Animal { // extendable by default; no keyword needed String speak() { return "..."; } // overridable by default too } final class Cat extends Animal { // 'final' here == Kotlin's DEFAULT @Override // an OPTIONAL annotation — but always write it String speak() { return "meow"; } } class Main { public static void main(String[] args) { Animal animal = new Cat(); System.out.println(animal.speak()); // class Kitten extends Cat {} ← will not compile: Cat is final } }
In Java every non-final class is an extension point whether you designed it as one or not, so "design for inheritance or prohibit it" is advice you must follow deliberately rather than a default the compiler enforces. The sharper trap is @Override: it is an annotation, not a keyword, and it is optional. Leave it off and misspell the method name and you have silently added a new method rather than overriding an existing one — a bug Kotlin makes impossible by requiring override. Always write @Override; every linter will insist anyway.
companion object and object become static
class User private constructor(val name: String) { companion object { private var created = 0 fun of(name: String): User { created++ return User(name) } fun createdCount() = created } override fun toString() = "User($name)" } object Registry { // a singleton, guaranteed by the language private var hits = 0 fun record() = ++hits } fun main() { println(User.of("Ada")) println(User.of("Alan")) println(User.createdCount()) Registry.record() println(Registry.record()) }
class User { private final String name; private static int created = 0; // companion state → a static field private User(String name) { this.name = name; created++; } static User of(String name) { return new User(name); } // factory → a static method static int createdCount() { return created; } @Override public String toString() { return "User(" + name + ")"; } } // A singleton: the enum trick. Thread-safe, serialization-proof, and the // recommended Java idiom — there is no 'object' keyword. enum Registry { INSTANCE; private int hits = 0; int record() { return ++hits; } } class Main { public static void main(String[] args) { System.out.println(User.of("Ada")); System.out.println(User.of("Alan")); System.out.println(User.createdCount()); Registry.INSTANCE.record(); System.out.println(Registry.INSTANCE.record()); } }
The call sites look nearly the same, but the machinery underneath is not. A companion object is a real object — it can implement an interface, be assigned to a variable, and be passed to a function; static members are not values and cannot do any of that. Kotlin's object declaration gives you a singleton the language guarantees is initialized once and lazily; in Java you pick between the enum trick (the safest), a static holder class, or a hand-written getInstance() with all the double-checked-locking folklore that entails. And static initialization order is back on your list of things to worry about.
Extension & Scope Functions
Extension functions become static utility methods
This is the feature you will miss most, and there is no substitute — only a workaround that reads inside-out.
fun String.shout() = uppercase() + "!" fun String.isPalindrome(): Boolean { val cleaned = lowercase().filter { it.isLetter() } return cleaned == cleaned.reversed() } fun main() { println("hello".shout()) println("A man, a plan, a canal: Panama".isPalindrome()) println("hi".shout().shout()) // it chains, because it reads like a method }
// You cannot add a method to String. The convention is a utility class of static // methods, and the call reads inside-out instead of left-to-right. final class StringUtilities { private StringUtilities() {} static String shout(String text) { return text.toUpperCase() + "!"; } static boolean isPalindrome(String text) { String cleaned = text.toLowerCase().replaceAll("[^a-z]", ""); return cleaned.equals(new StringBuilder(cleaned).reverse().toString()); } } class Main { public static void main(String[] args) { System.out.println(StringUtilities.shout("hello")); System.out.println(StringUtilities.isPalindrome("A man, a plan, a canal: Panama")); // Chaining is impossible: the calls nest. System.out.println(StringUtilities.shout(StringUtilities.shout("hi"))); } }
Nothing is lost at run time, because nothing was ever there: fun String.shout() already compiles to a static method taking the receiver as its first argument, which is literally StringUtilities.shout(text). What is lost is everything the syntax bought you — the value goes first so calls chain left to right, autocomplete on the value discovers the function, and the whole thing reads as if the type had grown a method. Java's reverse-Polish Utilities.of(Utilities.of(x)) is why Java codebases have so much less of this kind of small, composable helper in the first place.
let, apply and also have no equivalent
class Server { var host = "localhost" var port = 80 override fun toString() = "$host:$port" } fun main() { val server = Server().apply { // configure, and return the receiver host = "example.com" port = 8080 } println(server) val name: String? = null println(name?.let { it.length } ?: -1) // run only if non-null val numbers = listOf(1, 2, 3) .also { println("size ${it.size}") } // a side effect, mid-expression println(numbers) }
import java.util.List; import java.util.Optional; class Server { String host = "localhost"; int port = 80; @Override public String toString() { return host + ":" + port; } } class Main { public static void main(String[] args) { // apply { } → a named variable and a run of assignments var server = new Server(); server.host = "example.com"; server.port = 8080; System.out.println(server); // let { } on a nullable → Optional.map, or a plain null check String name = null; System.out.println(Optional.ofNullable(name).map(String::length).orElse(-1)); // also { } → there is nowhere to put it. Write another statement. var numbers = List.of(1, 2, 3); System.out.println("size " + numbers.size()); System.out.println(numbers); } }
Scope functions are extension functions that take a lambda with a receiver, and Java has neither extensions nor receivers — so there is no way to write them, not even as a library. Each one decomposes into ordinary statements: apply becomes a local variable plus assignments, let on a nullable becomes Optional.ofNullable(…).map(…) or an if, also becomes a separate line. The code still works; it just stops being a single expression, and the "configure this object right here" idiom that makes Kotlin initialization so compact is gone.
Sealed Types & Enums
Sealed interfaces, with an explicit permits list
Sealed types (Java 17) plus pattern-matching switch (Java 21) plus records (Java 16) add up to Kotlin's sealed-class-and-when, feature for feature. This is the strongest page in modern Java.
sealed interface Payment data class Card(val number: String, val cents: Int) : Payment data class Cash(val cents: Int) : Payment data class Transfer(val iban: String, val cents: Int) : Payment // No 'else' branch: the compiler knows the subtypes, and will FAIL this when // if a fourth Payment is added and not handled here. fun describe(payment: Payment): String = when (payment) { is Card -> "card ending ${payment.number} for ${payment.cents}c" is Cash -> "cash for ${payment.cents}c" is Transfer -> "transfer of ${payment.cents}c from ${payment.iban}" } fun main() { println(describe(Card("4242", 500))) println(describe(Cash(200))) println(describe(Transfer("NO93", 1000))) }
sealed interface Payment permits Card, Cash, Transfer {} record Card(String number, int cents) implements Payment {} record Cash(int cents) implements Payment {} record Transfer(String iban, int cents) implements Payment {} class Main { static String describe(Payment payment) { // No 'default' needed, and adding a fourth Payment breaks this switch // at compile time — exactly like Kotlin's exhaustive 'when'. return switch (payment) { case Card card -> "card ending " + card.number() + " for " + card.cents() + "c"; case Cash cash -> "cash for " + cash.cents() + "c"; case Transfer(String iban, int cents) -> "transfer of " + cents + "c from " + iban; }; } public static void main(String[] args) { System.out.println(describe(new Card("4242", 500))); System.out.println(describe(new Cash(200))); System.out.println(describe(new Transfer("NO93", 1000))); } }
The permits clause is the one piece of extra bookkeeping — Kotlin infers the permitted subtypes from the file, while Java asks you to list them (you may omit permits only if every subtype is in the same file). The exhaustiveness guarantee is the same and just as valuable: add a fourth Payment, and every switch that does not handle it stops compiling. One limit worth knowing: exhaustiveness applies to switch, never to a chain of if (x instanceof …), so the check only protects the code that opted into the construct.
enum class is already a Java enum
enum class Planet(val mass: Double, val radius: Double) { MERCURY(3.303e+23, 2.4397e6), EARTH(5.976e+24, 6.37814e6); fun surfaceGravity() = 6.673e-11 * mass / (radius * radius) } fun main() { for (planet in Planet.entries) { // entries, not values() — Kotlin 1.9+ println("%s %.2f".format(planet, planet.surfaceGravity())) } println(Planet.valueOf("EARTH").ordinal) }
enum Planet { MERCURY(3.303e+23, 2.4397e6), EARTH(5.976e+24, 6.37814e6); private final double mass; private final double radius; Planet(double mass, double radius) { // constructors are implicitly private this.mass = mass; this.radius = radius; } double surfaceGravity() { return 6.673e-11 * mass / (radius * radius); } } class Main { public static void main(String[] args) { for (Planet planet : Planet.values()) { System.out.printf("%s %.2f%n", planet, planet.surfaceGravity()); } System.out.println(Planet.valueOf("EARTH").ordinal()); } }
Kotlin's enum class is a Java enum — same bytecode, same valueOf, same ordinal, same ability to hold state and methods — so this is the smoothest translation on the site. The visible differences: the constructor parameters move out of the header and into a hand-written constructor plus fields, and Planet.entries becomes Planet.values(). That last one is not just a rename — values() allocates a fresh array on every call (it has to, since arrays are mutable), which is why Kotlin added entries. If you call it in a hot loop, hoist it into a static final list.
Lambdas & Functional Interfaces
No braces, no it, no trailing lambda
fun main() { val square: (Int) -> Int = { number -> number * number } println(square(5)) // called like a function val names = listOf("Ada", "Grace") names.forEach { println(it.uppercase()) } // implicit 'it', trailing lambda names.forEach(::println) // a function reference val task = { println("ran") } task() }
import java.util.List; import java.util.function.Function; class Main { public static void main(String[] args) { Function<Integer, Integer> square = number -> number * number; System.out.println(square.apply(5)); // .apply(), not () var names = List.of("Ada", "Grace"); names.forEach(name -> System.out.println(name.toUpperCase())); // no 'it' names.forEach(System.out::println); // method reference Runnable task = () -> System.out.println("ran"); task.run(); // .run(), because it is a Runnable } }
Three habits to unlearn. There is no it, so every lambda names its parameter. There is no trailing-lambda syntax, so the lambda stays inside the parentheses — which is the reason Java has no DSL-style builders (html { body { … } } is simply not expressible). And a lambda is not a value of a function type; it is an instance of an interface, so you invoke it by calling that interface's single method: apply for Function, test for Predicate, accept for Consumer, run for Runnable. Method references (String::toUpperCase) are the one place Java is arguably nicer than Kotlin's ::, since they work on instance methods of the parameter.
Function types become functional interfaces
Kotlin has structural function types: (Int) -> Boolean is a type. Java has none — it has interfaces with exactly one abstract method, and a lambda is a shorthand for implementing one.
fun <T> keep(items: List<T>, test: (T) -> Boolean): List<T> = items.filter(test) fun adder(amount: Int): (Int) -> Int = { number -> number + amount } fun main() { println(keep(listOf(1, 2, 3, 4)) { it % 2 == 0 }) println(adder(10)(5)) val add: (Int, Int) -> Int = { first, second -> first + second } val lazily: () -> String = { "computed" } println("${add(2, 3)} ${lazily()}") }
import java.util.List; import java.util.function.BiFunction; import java.util.function.Function; import java.util.function.Predicate; import java.util.function.Supplier; class Main { // (T) -> Boolean is spelled Predicate<T>. static <T> List<T> keep(List<T> items, Predicate<T> test) { return items.stream().filter(test).toList(); } // (Int) -> Int is spelled Function<Integer, Integer> — note the boxing. static Function<Integer, Integer> adder(int amount) { return number -> number + amount; } public static void main(String[] args) { System.out.println(keep(List.of(1, 2, 3, 4), number -> number % 2 == 0)); System.out.println(adder(10).apply(5)); System.out.println(adder(1).andThen(adder(2)).apply(0)); // composition BiFunction<Integer, Integer, Integer> add = Integer::sum; Supplier<String> lazily = () -> "computed"; System.out.println(add.apply(2, 3) + " " + lazily.get()); } }
The translation table: (T) -> Boolean is Predicate<T>, (T) -> R is Function<T, R>, (T) -> Unit is Consumer<T>, () -> T is Supplier<T>, and two arguments means BiFunction (there is no TriFunction — past two, you declare your own @FunctionalInterface). Because the types are nominal rather than structural, two interfaces with identical shapes are not interchangeable, and generics force boxing unless you reach for the primitive specializations (IntPredicate, IntUnaryOperator, …). Kotlin's inline higher-order functions compile the lambda away entirely; every Java lambda is an object.
Captured variables must be effectively final
fun main() { var counter = 0 val increment = { counter += 1 } // a 'var' can be captured AND mutated increment() increment() println(counter) // 2 val names = mutableListOf<String>() listOf("Ada", "Grace").forEach { names.add(it) } println(names) }
import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; class Main { public static void main(String[] args) { // int counter = 0; // Runnable increment = () -> counter += 1; // ← will not compile: a captured local must be effectively final. // The workaround: capture a mutable BOX instead of a mutable variable. var counter = new AtomicInteger(0); Runnable increment = () -> counter.incrementAndGet(); increment.run(); increment.run(); System.out.println(counter.get()); // 2 // Capturing a mutable OBJECT was always fine — it is the local variable // itself that may not be reassigned. List<String> names = new ArrayList<>(); List.of("Ada", "Grace").forEach(names::add); System.out.println(names); } }
Java captures locals by value, so a captured variable must be final or effectively final (never reassigned after initialization) — otherwise the lambda and the enclosing method would disagree about its value. Kotlin captures by reference: it quietly wraps a captured var in a Ref object so the closure really can mutate it. Java makes you do that wrapping yourself, with an AtomicInteger, a one-element array, or a mutable holder object. The rule only applies to the variable, not to what it points at, so mutating a captured List is fine — which is why the names example needs no workaround.
Concurrency
Coroutines become virtual threads
Virtual threads (Java 21) are the reason this comparison is no longer embarrassing. They are cheap enough to launch by the thousand, which is exactly the property that made coroutines worth having.
import kotlinx.coroutines.* fun main() = runBlocking { coroutineScope { for (worker in 1..3) { launch { delay(10L * worker) println("worker $worker done") } } } // coroutineScope waits for every child before returning println("all done") }
import java.util.concurrent.Executors; class Main { public static void main(String[] args) { // A virtual thread is a real Thread, scheduled onto carrier threads by the // JVM. Thread.sleep no longer pins an OS thread, so blocking code is cheap. try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int index = 1; index <= 3; index++) { int worker = index; // must be effectively final to capture executor.submit(() -> { try { Thread.sleep(10L * worker); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } System.out.println("worker " + worker + " done"); }); } } // close() BLOCKS until every submitted task has finished — the join System.out.println("all done"); } }
The shape survives: the try-with-resources block is coroutineScope (closing the executor waits for its children), and submit is launch. What does not survive is the type system's knowledge of it. suspend marks a function as one that can pause, and the compiler rewrites it into a state machine; Thread.sleep is an ordinary blocking call that nothing in the signature announces, so you cannot tell a blocking method from a fast one by looking. Cancellation is also different in kind: coroutines have a cancellation tree that propagates automatically, while Java has cooperative interrupts you have to check for and re-assert by hand, as above.
async/await becomes CompletableFuture
import kotlinx.coroutines.* suspend fun compute(number: Int): Int { delay(10) return number * number } fun main() = runBlocking { val results = coroutineScope { (1..3).map { number -> async { compute(number) } }.awaitAll() } println(results) println(compute(4) + 1) // sequential code — no chaining, no callbacks }
import java.util.List; import java.util.concurrent.CompletableFuture; class Main { static CompletableFuture<Integer> compute(int number) { return CompletableFuture.supplyAsync(() -> { try { Thread.sleep(10); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } return number * number; }); } public static void main(String[] args) { var futures = List.of(compute(1), compute(2), compute(3)); // already running CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); System.out.println(futures.stream().map(CompletableFuture::join).toList()); // thenApply is map; thenCompose is flatMap. The chain IS the control flow. System.out.println(compute(4).thenApply(value -> value + 1).join()); } }
async is supplyAsync, await() is join(), and awaitAll is allOf(…).join(). Three things bite. A CompletableFuture starts running the moment it is created — there is no lazy async, and no scope that owns it, so a forgotten future leaks instead of being canceled with its parent. Its exceptions arrive wrapped in CompletionException. And thenApply/thenCompose chains bring back exactly the callback style that suspend exists to eliminate — compare the last line of each column. In new code, prefer virtual threads to CompletableFuture: blocking is cheap now, so the sequential style is once again the right one.
Errors & Exceptions
Checked exceptions come back
Kotlin has exceptions but no checked exceptions — nothing forces a caller to handle anything. Java's compiler forces the issue, and this is the single biggest philosophical difference in error handling between the two.
import java.io.IOException // No 'throws' clause exists. Callers are not forced to catch this, and calling a // Java method that declares 'throws IOException' needs no try/catch either. fun read(fail: Boolean): String { if (fail) throw IOException("disk on fire") return "contents" } fun main() { try { println(read(false)) println(read(true)) } catch (error: IOException) { println("caught: ${error.message}") } }
import java.io.IOException; class Main { // The 'throws' clause is part of the signature, and the compiler enforces it: // every caller must catch IOException or declare 'throws' in turn, all the way up. static String read(boolean fail) throws IOException { if (fail) throw new IOException("disk on fire"); return "contents"; } public static void main(String[] args) { try { System.out.println(read(false)); System.out.println(read(true)); } catch (IOException error) { System.out.println("caught: " + error.getMessage()); } // RuntimeException and its subclasses are UNCHECKED: no clause, no catch. try { Integer.parseInt("nope"); } catch (NumberFormatException error) { System.out.println("unchecked: " + error.getMessage()); } } }
The exception hierarchy splits in two: anything under RuntimeException or Error is unchecked and behaves exactly like every Kotlin exception, while everything else — IOException, SQLException, InterruptedException — is checked and must be declared or handled. The consequence you will hit fastest is in lambdas: a lambda cannot throw a checked exception unless its functional interface declares one, and none of the java.util.function interfaces do. That is why so much Java code wraps IOException in a RuntimeException inside a Stream.map — the language feature and the library are working against each other.
No try-expression, no Result, no runCatching
fun main() { val parsed = try { "nope".toInt() } catch (error: NumberFormatException) { -1 } println(parsed) println(runCatching { "42".toInt() }.getOrDefault(-1)) println(runCatching { "nope".toInt() }.getOrElse { -1 }) println("nope".toIntOrNull() ?: -1) // best of all: no exception is thrown }
class Main { // 'try' is a statement, so the expression version becomes a helper method — // one per fallible operation you want a fallback for. static int parseOr(String text, int fallback) { try { return Integer.parseInt(text); } catch (NumberFormatException error) { return fallback; } } public static void main(String[] args) { System.out.println(parseOr("42", -1)); System.out.println(parseOr("nope", -1)); // Inline, it means declaring first and assigning in every branch. int parsed; try { parsed = Integer.parseInt("nope"); } catch (NumberFormatException error) { parsed = -1; } System.out.println(parsed); } }
There is no Result type in the JDK and no runCatching, so "try this, fall back to that" becomes a small helper method or an assign-in-every-branch block. The deeper difference is in the standard library: Kotlin gives you toIntOrNull, firstOrNull, getOrNull — a whole family of functions that return null rather than throwing — while Java's Integer.parseInt throws, and its equivalents mostly do too. Exceptions end up carrying ordinary control flow far more often, and that is a habit of the ecosystem as much as of the language.
use { } becomes try-with-resources
class Connection(private val name: String) : AutoCloseable { init { println("open $name") } fun query() = println("query on $name") override fun close() = println("close $name") } fun main() { try { Connection("db").use { connection -> // 'use' is a library function connection.query() throw IllegalStateException("boom") } } catch (error: IllegalStateException) { println("caught: ${error.message}") // close already ran } }
class Connection implements AutoCloseable { private final String name; Connection(String name) { this.name = name; System.out.println("open " + name); } void query() { System.out.println("query on " + name); } @Override public void close() { System.out.println("close " + name); } } class Main { public static void main(String[] args) { // try-with-resources: closed on EVERY exit path, in reverse order, // and you may declare several resources in the one header. try (var connection = new Connection("db")) { connection.query(); throw new IllegalStateException("boom"); } catch (IllegalStateException error) { System.out.println("caught: " + error.getMessage()); // close already ran } } }
One of the few Kotlin idioms that translates without any loss — and arguably improves. use is an inline extension function on Closeable; try-with-resources is syntax, and it handles several resources in one header (closed in reverse declaration order) and attaches any exception thrown by close() to the primary exception as a suppressed exception rather than losing it. Both are built on the same AutoCloseable interface, so a class written for one works with the other.