PONY λ M2 Modula-2

Java.CodeCompared.To/Kotlin

An interactive executable cheatsheet comparing Java and Kotlin

Java 26 Kotlin 2.3
Variables & Types
val vs var — immutability by default
class Main { public static void main(String[] args) { final int maxRetries = 3; // Java: final prevents reassignment int attemptCount = 0; // mutable attemptCount = 1; System.out.println(maxRetries); System.out.println(attemptCount); } }
fun main() { val maxRetries = 3 // val: immutable binding — cannot be reassigned var attemptCount = 0 // var: mutable binding attemptCount = 1 println(maxRetries) println(attemptCount) }
Kotlin uses val for immutable bindings and var for mutable ones — a direct replacement for Java's final. Idiomatic Kotlin strongly prefers val; reach for var only when mutation is genuinely required. Unlike Java where final feels optional, in Kotlin immutability is the default and mutability is the exception.
Type inference
class Main { public static void main(String[] args) { var count = 42; // Java 10+: local variable type inference var message = "hello"; // count = "oops"; // compile error — type is still fixed System.out.println(count); System.out.println(message); } }
fun main() { val count = 42 // inferred as Int val message = "hello" // inferred as String // count = "oops" // compile error — type is fixed at declaration println(count) println(message) }
Both Java 10+ and Kotlin infer the type of local variables from the right-hand side. Kotlin extends inference further: it works in function return types, lambda parameters, and many other positions where Java still requires explicit types. Both languages fix the inferred type at compile time — it is not dynamic.
Smart casts — no explicit cast after type check
class Main { public static void main(String[] args) { Object value = "Hello, Java"; if (value instanceof String text) { // Java 16+ pattern variable System.out.println(text.length()); System.out.println(text.toUpperCase()); } } }
fun main() { val value: Any = "Hello, Kotlin" if (value is String) { // Kotlin smart-casts value to String inside this branch println(value.length) // no cast needed — already String println(value.uppercase()) } }
Java 16+ introduced pattern variables in instanceof to avoid the explicit cast. Kotlin's smart cast is more pervasive: after any is type check, the compiler automatically narrows the type everywhere that check is guaranteed to hold — in if-branches, when-expressions, and after null checks. No separate binding variable is needed.
Destructuring declarations
class Main { record Point(int x, int y) {} public static void main(String[] args) { var point = new Point(3, 4); // Java: no destructuring — must access each component individually int xCoord = point.x(); int yCoord = point.y(); System.out.println("x=" + xCoord + ", y=" + yCoord); } }
fun main() { val point = Point(3, 4) val (xCoord, yCoord) = point // destructuring — works for data classes println("x=$xCoord, y=$yCoord") } data class Point(val x: Int, val y: Int)
Kotlin data classes auto-generate componentN() methods that power destructuring declarations. val (x, y) = point unpacks both components in one line. Java records have no destructuring syntax — components must be accessed individually via accessor methods.
Null Safety
Nullable vs non-nullable types
class Main { public static void main(String[] args) { String name = "Alice"; // can be null — Java doesn't enforce String nullableName = null; // compiles fine, runtime NPE risk System.out.println(name.length()); // nullableName.length() → NullPointerException at runtime } }
fun main() { val name: String = "Alice" // non-nullable: null is a compile error val nullableName: String? = null // nullable: must handle null explicitly println(name.length) // safe — String is guaranteed non-null println(nullableName?.length) // safe — ?. returns null instead of throwing }
Kotlin encodes nullability directly in the type system. String can never hold null; String? can. The compiler enforces safe handling — calling a method on a nullable without a null guard is a compile error. Java's type system has no such distinction: String can always be null, and NullPointerException is a perennial runtime hazard.
Safe-call operator ?.
class Main { record Address(String city) {} record Person(String name, Address address) {} public static void main(String[] args) { var person = new Person("Alice", null); // Must guard every step explicitly String city = (person.address() != null) ? person.address().city() : null; System.out.println(city); } }
fun main() { val person = Person("Alice", null) val city = person.address?.city // ?. short-circuits to null when address is null println(city) } data class Address(val city: String) data class Person(val name: String, val address: Address?)
Kotlin's ?. operator evaluates to null immediately when the left side is null, without evaluating the right side. Chaining person.address?.city is safe regardless of whether address is null — no guard clause needed. Java requires an explicit null check at every step of a property chain.
Elvis operator ?: — null fallback
class Main { static String findUserName() { return null; } public static void main(String[] args) { String userName = findUserName(); String displayName = (userName != null) ? userName : "Guest"; System.out.println(displayName); } }
fun main() { val userName: String? = findUserName() val displayName = userName ?: "Guest" // ?: returns right side when left is null println(displayName) } fun findUserName(): String? = null
The Elvis operator ?: returns the left operand if it is non-null, otherwise the right operand — a concise null-coalescing expression equivalent to Java's value != null ? value : fallback. It is especially useful chained with safe calls: person.address?.city ?: "Unknown" reads naturally and handles both a missing address and a missing city.
?.let { } — run a block only when non-null
class Main { static String findEmail() { return null; } public static void main(String[] args) { String email = findEmail(); if (email != null) { System.out.println("Sending to: " + email.toLowerCase()); } } }
fun main() { val email: String? = findEmail() email?.let { address -> println("Sending to: ${address.lowercase()}") } // Block is skipped entirely when email is null — no println } fun findEmail(): String? = null
?.let { } combines a null check with a scope function: when the value is non-null, it calls the lambda with the non-null value as the parameter. Inside the lambda, the parameter is a guaranteed String — no null checks needed. This replaces Java's if (value != null) { use(value); } idiom with a more functional style.
Strings
String templates — $ instead of String.format()
class Main { public static void main(String[] args) { var firstName = "Alice"; var age = 30; // Java: String.format() or String.formatted() for interpolation var message = "Hello, %s! You are %d years old.".formatted(firstName, age); System.out.println(message); } }
fun main() { val firstName = "Alice" val age = 30 val message = "Hello, $firstName! You are $age years old." println(message) // Expressions in ${ } println("In 5 years: ${age + 5}") }
Kotlin string templates use $variable for simple variable substitution and ${expression} for arbitrary expressions. Java requires String.format() or String.formatted() with format specifiers — a less readable approach. Java 21+ text blocks support \{} in a limited form, but standard Java strings have no interpolation.
Raw (multiline) strings
class Main { public static void main(String[] args) { // Java 15+: text blocks var json = """ { "name": "Alice", "age": 30 } """; System.out.println(json.strip()); } }
fun main() { // Kotlin: triple-quoted raw strings, trimIndent() removes leading whitespace val json = """ { "name": "Alice", "age": 30 } """.trimIndent() println(json) }
Both languages support multiline raw strings with triple quotes. Java 15+ text blocks re-indent automatically based on the closing """ position. Kotlin raw strings require an explicit .trimIndent() call to strip common leading whitespace. Kotlin raw strings can also contain string templates: """Hello, $name""".
== compares content, not identity
class Main { public static void main(String[] args) { String greeting1 = new String("hello"); String greeting2 = new String("hello"); System.out.println(greeting1.equals(greeting2)); // true — content System.out.println(greeting1 == greeting2); // false — identity } }
fun main() { val greeting1 = "hello" val greeting2 = "hello" println(greeting1 == greeting2) // true — structural equality (like .equals()) println(greeting1 === greeting2) // true — same interned instance (referential) }
In Kotlin, == calls equals() by default — it compares content structurally. Referential identity uses ===. In Java, == on objects compares references, and forgetting to use .equals() for strings is a classic bug. Kotlin's behavior matches what developers usually want.
String functions
class Main { public static void main(String[] args) { var text = " Hello, World! "; System.out.println(text.trim()); System.out.println(text.trim().toLowerCase()); System.out.println("hello world".replace(' ', '-')); System.out.println("a,b,c".split(",").length); } }
fun main() { val text = " Hello, World! " println(text.trim()) println(text.trim().lowercase()) // lowercase() not toLowerCase() println("hello world".replace(' ', '-')) println("a,b,c".split(",").size) // .size not .length for Kotlin collections }
Kotlin strings have idiomatic equivalents for all Java string methods, with a few naming differences: lowercase()/uppercase() instead of toLowerCase()/toUpperCase(), and split() returns a Kotlin List<String> (with a .size property) rather than a Java array (with a .length field).
Collections
Immutable vs mutable collections
class Main { public static void main(String[] args) { var immutable = java.util.List.of(1, 2, 3); // immutable var mutable = new java.util.ArrayList<>(immutable); mutable.add(4); System.out.println(immutable); System.out.println(mutable); } }
fun main() { val immutable = listOf(1, 2, 3) // read-only — no add/remove val mutable = mutableListOf(1, 2, 3) // MutableList — add/remove available mutable.add(4) println(immutable) println(mutable) }
Kotlin's collection API is split at the type level: List<T> is read-only; MutableList<T> adds mutating operations. The functions listOf() and mutableListOf() create each kind. Java's List.of() returns an immutable list, but the type is still java.util.List — the immutability is only a runtime contract, not a compile-time guarantee.
filter / map / reduce — no .stream() needed
class Main { public static void main(String[] args) { var numbers = java.util.List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); var result = numbers.stream() .filter(n -> n % 2 == 0) .map(n -> n * n) .collect(java.util.stream.Collectors.toList()); System.out.println(result); } }
fun main() { val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) val result = numbers .filter { number -> number % 2 == 0 } .map { number -> number * number } println(result) }
Kotlin's filter, map, reduce, and other collection operations are extension functions directly on List, Set, and Map — no .stream() conversion step is needed. Java Streams are lazy by default; Kotlin's collection operations are eager. For large datasets, Kotlin Sequence provides the same lazy evaluation as Java Streams.
Maps — mapOf() and mutableMapOf()
class Main { public static void main(String[] args) { var scores = new java.util.HashMap<String, Integer>(); scores.put("Alice", 95); scores.put("Bob", 87); System.out.println(scores.get("Alice")); System.out.println(scores.getOrDefault("Carol", 0)); } }
fun main() { val scores = mutableMapOf("Alice" to 95, "Bob" to 87) println(scores["Alice"]) // subscript access — same as .get() println(scores.getOrDefault("Carol", 0)) scores["Dave"] = 92 // subscript assignment — same as .put() println(scores) }
Kotlin maps are created with mapOf() (read-only) or mutableMapOf() (mutable). The to infix function creates key-value pairs: "Alice" to 95. Subscript notation map[key] replaces map.get(key), and map[key] = value replaces map.put(key, value). Map access returns a nullable type: scores["Alice"] is Int?, not Int.
Sequences — lazy evaluation like Java Streams
class Main { public static void main(String[] args) { // Java: .stream() provides lazy evaluation var firstEvenSquare = java.util.stream.IntStream.rangeClosed(1, 1_000_000) .filter(n -> n % 2 == 0) .map(n -> n * n) .findFirst() .orElse(-1); System.out.println(firstEvenSquare); } }
fun main() { // Kotlin: .asSequence() enables lazy evaluation val firstEvenSquare = (1..1_000_000) .asSequence() .filter { number -> number % 2 == 0 } .map { number -> number * number } .first() println(firstEvenSquare) }
Kotlin collection operations are eager by default — each step processes all elements before the next. .asSequence() switches to lazy evaluation, where elements flow through the entire pipeline one at a time. This mirrors Java's .stream(): use sequences when working with large collections and you only need a subset of the results.
Control Flow
when expression — switch replacement
class Main { public static void main(String[] args) { int day = 3; String name = switch (day) { // Java 14+ switch expression case 1 -> "Monday"; case 2 -> "Tuesday"; case 3 -> "Wednesday"; default -> "Other"; }; System.out.println(name); } }
fun main() { val day = 3 val name = when (day) { 1 -> "Monday" 2 -> "Tuesday" 3 -> "Wednesday" else -> "Other" } println(name) }
Kotlin's when is an expression that produces a value — like Java 14's switch expression. The key difference is that when is also a statement and does not require exhaustive cases when used that way. When used as an expression (assigned to a variable or returned), Kotlin requires an else branch unless the subject is a sealed class or enum that covers all cases.
when with ranges, types, and conditions
class Main { public static void main(String[] args) { int score = 85; // Java: no when-ranges; must use if/else chain String grade; if (score >= 90) grade = "A"; else if (score >= 80) grade = "B"; else if (score >= 70) grade = "C"; else grade = "F"; System.out.println(grade); } }
fun main() { val score = 85 val grade = when (score) { in 90..100 -> "A" in 80..89 -> "B" in 70..79 -> "C" else -> "F" } println(grade) }
Kotlin's when supports range checks (in 80..89), type checks (is String), and arbitrary boolean expressions in the same construct. Java's switch only matches on exact values for primitives, enums, or strings — range checks require a separate if/else chain. The in keyword in when calls contains() on the range.
if is an expression — no ternary needed
class Main { public static void main(String[] args) { int temperature = 28; // Java: ternary operator for inline conditionals String description = (temperature > 25) ? "hot" : "comfortable"; System.out.println(description); } }
fun main() { val temperature = 28 // Kotlin: if is an expression — no ternary needed val description = if (temperature > 25) "hot" else "comfortable" println(description) println(if (temperature > 30) "very hot" else if (temperature > 25) "hot" else "comfortable") }
In Kotlin, if/else is an expression that produces a value, so there is no ternary operator (? :). The if expression is more readable than the ternary for multi-branch conditions. Java added switch expressions in Java 14, but still retains the ternary for simple binary conditionals.
Ranges and for loops
class Main { public static void main(String[] args) { // Java: IntStream or traditional for loop for ranges for (int i = 1; i <= 5; i++) System.out.print(i + " "); System.out.println(); for (String language : java.util.List.of("Java", "Kotlin", "Scala")) { System.out.println(language); } } }
fun main() { for (i in 1..5) print("$i ") // 1..5 inclusive range println() for (language in listOf("Java", "Kotlin", "Scala")) { println(language) } for (i in 10 downTo 1 step 2) print("$i ") // countdown by 2 println() }
Kotlin ranges (1..5, 1 until 5, 10 downTo 1 step 2) integrate naturally with for. 1..5 is inclusive; 1 until 5 excludes the upper bound (like Java's i < 5). The for (item in collection) syntax works on any Iterable — the same targets as Java's enhanced for loop.
Functions
Single-expression functions
class Main { static int square(int number) { return number * number; } static String greet(String name) { return "Hello, " + name + "!"; } public static void main(String[] args) { System.out.println(square(5)); System.out.println(greet("Alice")); } }
fun square(number: Int) = number * number // no return keyword or braces fun greet(name: String) = "Hello, $name!" fun main() { println(square(5)) println(greet("Alice")) }
When a function body is a single expression, Kotlin allows the = expression form — omitting the return type (inferred), the return keyword, and the braces. This is not just syntactic sugar: it signals to readers that the function has no side effects and directly computes a result. Java has no equivalent shorthand; even trivial getters require a full method body.
Default parameters — no overload pyramids
class Main { // Java: no default parameters — overloads simulate the behavior static void connect(String host, int port, boolean ssl) { System.out.println(host + ":" + port + " ssl=" + ssl); } static void connect(String host, int port) { connect(host, port, false); } static void connect(String host) { connect(host, 8080); } public static void main(String[] args) { connect("localhost"); connect("example.com", 443, true); } }
fun connect(host: String, port: Int = 8080, ssl: Boolean = false) { println("$host:$port ssl=$ssl") } fun main() { connect("localhost") // uses all defaults connect("example.com", 443, true) // all explicit connect("example.com", ssl = true) // named argument — skips port }
Kotlin default parameter values eliminate Java's overload pyramids. A single function declaration covers all call sites. Named arguments (ssl = true) let callers skip intervening parameters without positional ambiguity, making call sites self-documenting. Java has no equivalent: callers must provide values for all non-overloaded parameters in positional order.
vararg functions
class Main { static int sum(int... numbers) { int total = 0; for (int number : numbers) total += number; return total; } public static void main(String[] args) { System.out.println(sum(1, 2, 3)); int[] values = {4, 5, 6}; System.out.println(sum(values)); // pass array directly } }
fun sum(vararg numbers: Int): Int { var total = 0 for (number in numbers) total += number return total } fun main() { println(sum(1, 2, 3)) val values = intArrayOf(4, 5, 6) println(sum(*values)) // spread operator * unpacks array into vararg }
Kotlin uses the vararg keyword rather than Java's ... postfix. The critical difference is when passing an array: Java accepts an array directly, but Kotlin requires the spread operator *array to unpack the array into individual arguments. The spread operator only works with varargs — not with regular function parameters.
Infix functions
class Main { // Java: no infix syntax — method calls always need dot and parentheses static boolean between(int value, int lower, int upper) { return value >= lower && value <= upper; } public static void main(String[] args) { System.out.println(between(5, 1, 10)); // must be called as a static method } }
infix fun Int.between(range: IntRange) = this in range fun main() { println(5 between 1..10) // infix call — reads like natural language println(15 between 1..10) }
Kotlin infix functions are single-parameter extension functions that can be called without a dot or parentheses: a infixFun b instead of a.infixFun(b). The standard library uses infix functions for to (creating Pair), and/or/xor on numeric types, and until/downTo for ranges. Java has no infix call syntax.
Data Classes
Data classes vs POJOs and records
class Main { record Person(String name, int age) {} // Java 16+: records replace POJO boilerplate public static void main(String[] args) { var person = new Person("Alice", 30); System.out.println(person); // Person[name=Alice, age=30] System.out.println(person.name()); // accessor — called with () } }
fun main() { val person = Person("Alice", 30) println(person) // Person(name=Alice, age=30) println(person.name) // property — accessed without () println(person.age) } data class Person(val name: String, val age: Int)
Both Java records and Kotlin data classes auto-generate equals, hashCode, and toString. The key syntactic difference: Java record components are accessed as methods (person.name()), while Kotlin data class properties are accessed without parentheses (person.name). Kotlin data classes also auto-generate a copy() method for creating modified copies.
copy() — non-destructive updates
class Main { record Person(String name, int age) {} public static void main(String[] args) { var person = new Person("Alice", 30); // Java records: must construct a new one manually var updated = new Person(person.name(), person.age() + 1); System.out.println(person); System.out.println(updated); } }
fun main() { val person = Person("Alice", 30) val updated = person.copy(age = 31) // only changed fields need to be specified println(person) println(updated) } data class Person(val name: String, val age: Int)
Kotlin data classes auto-generate a copy() method that produces a modified shallow copy, with only the changed fields specified. Java records have no copy() method — you must call the constructor with all fields, spelling out unchanged ones explicitly. Kotlin's copy() avoids that repetition and is especially valuable for classes with many fields.
Structural equality with ==
class Main { record Point(int x, int y) {} public static void main(String[] args) { var point1 = new Point(3, 4); var point2 = new Point(3, 4); System.out.println(point1.equals(point2)); // true — structural System.out.println(point1 == point2); // false — reference identity } }
fun main() { val point1 = Point(3, 4) val point2 = Point(3, 4) println(point1 == point2) // true — == calls equals() for data classes println(point1 === point2) // false — different instances } data class Point(val x: Int, val y: Int)
For Kotlin data classes, == calls the auto-generated equals() method, comparing all constructor properties structurally. This also applies to Java records through Kotlin's general rule: == always calls equals(). In Java, forgetting .equals() and using == for object comparison is a classic bug — Kotlin's design makes this mistake impossible.
Extension Functions
Extension functions — adding methods without subclassing
class Main { // Java: no extension functions — static utility class is the only option static class StringUtils { static boolean isPalindrome(String input) { var reversed = new StringBuilder(input).reverse().toString(); return input.equals(reversed); } } public static void main(String[] args) { System.out.println(StringUtils.isPalindrome("racecar")); System.out.println(StringUtils.isPalindrome("hello")); } }
fun String.isPalindrome(): Boolean = this == this.reversed() fun main() { println("racecar".isPalindrome()) // called as if it were a String method println("hello".isPalindrome()) }
Kotlin extension functions add new methods to existing classes without subclassing or modifying the source. The this inside refers to the receiver instance. Extension functions are resolved statically — they are syntactic sugar over static methods, not true virtual dispatch. Java must use static utility classes like StringUtils.isPalindrome(str); Kotlin lets you write str.isPalindrome().
Scope functions — let, apply, also, run, with
class Main { static class Config { String host = ""; int port = 8080; boolean ssl = false; } public static void main(String[] args) { // Java: must reference object repeatedly for setup var config = new Config(); config.host = "example.com"; config.port = 443; config.ssl = true; System.out.println(config.host + ":" + config.port + " ssl=" + config.ssl); } }
fun main() { // apply: sets up an object and returns it — receiver is 'this' val config = Config().apply { host = "example.com" port = 443 ssl = true } println("${config.host}:${config.port} ssl=${config.ssl}") } class Config { var host = ""; var port = 8080; var ssl = false }
Kotlin's scope functions (let, run, with, apply, also) execute a block in the context of an object. apply — the most common for initialization — sets the receiver as this inside the block and returns the receiver. This replaces Java's verbose repeated object references during setup. The other scope functions differ in how they expose the receiver (this vs it) and what they return.
Extension properties
class Main { // Java: computed values must be static methods — no property-style access static boolean isEven(int number) { return number % 2 == 0; } static boolean isOdd(int number) { return number % 2 != 0; } public static void main(String[] args) { System.out.println(isEven(4)); System.out.println(isOdd(7)); } }
val Int.isEven: Boolean get() = this % 2 == 0 val Int.isOdd: Boolean get() = this % 2 != 0 fun main() { println(4.isEven) // accessed as a property — no parentheses println(7.isOdd) }
Extension properties let you add computed properties to existing types. The get() accessor is called when the property is read. Extension properties cannot have backing fields — they must derive their value from the receiver. Java has no property syntax; computed values on existing types are always static methods.
Classes & OOP
Primary constructor — concise class syntax
class Main { static class Animal { private final String name; private final String sound; Animal(String name, String sound) { this.name = name; this.sound = sound; } void speak() { System.out.println(name + " says " + sound); } } public static void main(String[] args) { var dog = new Animal("Dog", "Woof"); dog.speak(); } }
fun main() { val dog = Animal("Dog", "Woof") dog.speak() } class Animal(val name: String, val sound: String) { fun speak() = println("$name says $sound") }
Kotlin's primary constructor is declared directly in the class header. Properties declared with val/var in the constructor are automatically assigned from the constructor arguments — no this.name = name boilerplate. Java requires explicit field declarations, constructor parameters, and assignment statements for each property.
open — classes are final by default
class Main { // Java: classes are open for extension by default; final closes them static class Base { void greet() { System.out.println("Hello from Base"); } } static final class Closed extends Base {} // final: cannot be extended static class Child extends Base { @Override void greet() { System.out.println("Hello from Child"); } } public static void main(String[] args) { new Child().greet(); } }
fun main() { val child = Child() child.greet() } open class Base { open fun greet() = println("Hello from Base") // open: allows override } class Child : Base() { override fun greet() = println("Hello from Child") }
In Kotlin, classes and functions are final by default — subclassing and overriding require explicit open keywords. Java is the opposite: classes are open for extension unless marked final. The Kotlin approach encourages composition over inheritance and prevents accidental subclassing, a common source of fragile base class problems in Java codebases.
Interfaces with default implementations
class Main { interface Drawable { void draw(); default String describe() { return "A drawable object"; } // Java 8+ default method } static class Circle implements Drawable { @Override public void draw() { System.out.println("Drawing circle"); } } public static void main(String[] args) { var circle = new Circle(); circle.draw(); System.out.println(circle.describe()); } }
fun main() { val circle = Circle() circle.draw() println(circle.describe()) } interface Drawable { fun draw() fun describe() = "A drawable object" // default implementation — no 'default' keyword } class Circle : Drawable { override fun draw() = println("Drawing circle") }
Kotlin interfaces support default implementations directly — no default keyword is needed. Implementing an interface uses : (same syntax as inheritance). An important difference: Kotlin interfaces can have properties, but they cannot have backing fields — property implementations in interfaces must use computed getters.
Companion Objects & Singletons
Companion objects — replacing static members
class Main { static class Counter { static int instanceCount = 0; static int getInstanceCount() { return instanceCount; } Counter() { instanceCount++; } } public static void main(String[] args) { new Counter(); new Counter(); new Counter(); System.out.println(Counter.getInstanceCount()); } }
fun main() { Counter(); Counter(); Counter() println(Counter.instanceCount) } class Counter { companion object { var instanceCount = 0 } init { instanceCount++ } }
Kotlin has no static keyword. Class-level members live in a companion object — a singleton object scoped to the class. Companion object members are accessed with the class name (Counter.instanceCount), just like Java static members. The companion object can also implement interfaces, something Java static members cannot do.
object declarations — first-class singletons
class Main { // Java: singleton pattern requires boilerplate static final class Registry { private static final Registry INSTANCE = new Registry(); private Registry() {} static Registry getInstance() { return INSTANCE; } private java.util.List<String> items = new java.util.ArrayList<>(); void register(String item) { items.add(item); } void list() { items.forEach(System.out::println); } } public static void main(String[] args) { Registry.getInstance().register("alpha"); Registry.getInstance().list(); } }
fun main() { Registry.register("alpha") Registry.list() } object Registry { // Kotlin singleton — initialized once, lazily, thread-safe private val items = mutableListOf<String>() fun register(item: String) { items.add(item) } fun list() = items.forEach(::println) }
Kotlin's object declaration creates a singleton directly — no static factory, no private constructor, no INSTANCE field. The object is lazily initialized on first access and is thread-safe. Java requires the entire boilerplate private-constructor + static-field + factory-method pattern to achieve the same. Kotlin objects can extend classes and implement interfaces.
Sealed Classes & Pattern Matching
Sealed classes — closed type hierarchies
class Main { sealed interface Result<T> permits Success, Failure {} record Success<T>(T value) implements Result<T> {} record Failure<T>(String message) implements Result<T> {} static <T> void show(Result<T> result) { switch (result) { case Success<T> success -> System.out.println("OK: " + success.value()); case Failure<T> failure -> System.out.println("Error: " + failure.message()); } } public static void main(String[] args) { show(new Success<>("data")); show(new Failure<>("not found")); } }
fun main() { show(Success("data")) show(Failure("not found")) } fun <T> show(result: Result<T>) = when (result) { is Success -> println("OK: ${result.value}") is Failure -> println("Error: ${result.message}") } sealed class Result<out T> data class Success<T>(val value: T) : Result<T>() data class Failure(val message: String) : Result<Nothing>()
Both Java sealed interfaces and Kotlin sealed classes restrict the subtype hierarchy to a known set. In Kotlin, when on a sealed class is exhaustive — no else branch needed when all subtypes are covered. Java's sealed switch also enforces exhaustiveness. After a successful is Success check, Kotlin smart-casts the receiver to Success automatically.
enum class
class Main { enum Direction { NORTH, SOUTH, EAST, WEST; public boolean isVertical() { return this == NORTH || this == SOUTH; } } public static void main(String[] args) { Direction direction = Direction.NORTH; System.out.println(direction); System.out.println(direction.isVertical()); } }
fun main() { val direction = Direction.NORTH println(direction) println(direction.isVertical()) } enum class Direction { NORTH, SOUTH, EAST, WEST; fun isVertical() = this == NORTH || this == SOUTH }
Kotlin enum classes mirror Java enums in structure and capability — both support properties and methods on each constant. Kotlin enum classes work naturally with when expressions: when (direction) { NORTH -> ... } without the class prefix inside the block. Kotlin enums can also implement interfaces, giving them a capability Java enums share.
Higher-Order Functions & Lambdas
Lambda syntax — -> vs { }
class Main { public static void main(String[] args) { java.util.function.Function<Integer, Integer> doubleValue = number -> number * 2; java.util.function.BiFunction<Integer, Integer, Integer> add = (first, second) -> first + second; System.out.println(doubleValue.apply(5)); System.out.println(add.apply(3, 4)); } }
fun main() { val doubleValue: (Int) -> Int = { number -> number * 2 } val add: (Int, Int) -> Int = { first, second -> first + second } println(doubleValue(5)) println(add(3, 4)) }
Java lambdas use params -> expression; Kotlin lambdas use { params -> expression } in curly braces. Kotlin function types are written as (ParamType) -> ReturnType, replacing Java's functional interfaces (Function<T, R>, Consumer<T>, Supplier<T>, etc.). Kotlin lambdas are invoked with lambda(args) or lambda.invoke(args), not lambda.apply(args).
it — implicit single parameter
class Main { public static void main(String[] args) { var numbers = java.util.List.of(1, 2, 3, 4, 5); numbers.stream() .filter(number -> number % 2 == 0) .forEach(number -> System.out.println(number)); } }
fun main() { val numbers = listOf(1, 2, 3, 4, 5) numbers .filter { it % 2 == 0 } // 'it' is the implicit name for single-parameter lambdas .forEach { println(it) } }
When a Kotlin lambda has exactly one parameter and you don't give it a name, it is automatically available as it. This makes short transformations like { it * 2 } or { it.length } concise. Java always requires naming the parameter, even for trivial lambdas. Use an explicit name when the lambda body is long enough that it becomes ambiguous.
Trailing lambdas — DSL-style syntax
class Main { static void repeat(int times, Runnable action) { for (int i = 0; i < times; i++) action.run(); } public static void main(String[] args) { // Java: lambda always inside parentheses repeat(3, () -> System.out.println("hello")); } }
fun repeat(times: Int, action: () -> Unit) { for (i in 0 until times) action() } fun main() { repeat(3) { println("hello") } // trailing lambda — outside the parentheses repeat(3) { println("world") } // reads like a built-in control structure }
When the last parameter of a Kotlin function is a function type, the lambda can be placed outside the parentheses — trailing lambda syntax. If the lambda is the only argument, the parentheses can be omitted entirely. This powers Kotlin DSLs: constructs like buildList { add(1) }, transaction { insert(row) }, and Gradle Kotlin scripts all use trailing lambdas to read like language syntax.
Higher-order functions
class Main { static <T, R> java.util.List<R> transform( java.util.List<T> items, java.util.function.Function<T, R> mapper ) { var result = new java.util.ArrayList<R>(); for (T item : items) result.add(mapper.apply(item)); return result; } public static void main(String[] args) { var words = java.util.List.of("hello", "world"); System.out.println(transform(words, String::toUpperCase)); } }
fun <T, R> transform(items: List<T>, mapper: (T) -> R): List<R> = items.map(mapper) fun main() { val words = listOf("hello", "world") println(transform(words, String::uppercase)) println(transform(words) { it.length }) // trailing lambda }
Kotlin's function types ((T) -> R) replace Java's functional interfaces. Method references use :: in both languages, but Kotlin method references are more flexible: String::uppercase matches (String) -> String without the String.CASE_INSENSITIVE_ORDER ceremony. Kotlin also inlines higher-order functions at call sites with the inline modifier, eliminating the lambda allocation overhead.
Coroutines
suspend functions vs CompletableFuture
class Main { // Java: CompletableFuture for async — chains become deeply nested static java.util.concurrent.CompletableFuture<String> fetchUser(int userId) { return java.util.concurrent.CompletableFuture.supplyAsync( () -> "User #" + userId ); } public static void main(String[] args) throws Exception { fetchUser(42) .thenApply(user -> user + " (processed)") .thenAccept(System.out::println) .get(); } }
// suspend functions look and read like synchronous code suspend fun fetchUser(userId: Int): String { // kotlinx.coroutines.delay(100) would be a non-blocking delay return "User #$userId" } fun main() { // runBlocking bridges synchronous and coroutine worlds kotlinx.coroutines.runBlocking { val user = fetchUser(42) // 'await' is implicit — just call it println("$user (processed)") } }
Kotlin coroutines make asynchronous code read like sequential code. A suspend function can be paused and resumed without blocking a thread. Java's CompletableFuture chains are functional but become difficult to read with error handling and multiple await points. Kotlin's async/await pattern is implicit — calling a suspend function automatically awaits its result.
Parallel execution — async { } and await()
class Main { static java.util.concurrent.CompletableFuture<Integer> compute(int value) { return java.util.concurrent.CompletableFuture.supplyAsync(() -> value * value); } public static void main(String[] args) throws Exception { // Java: thenCombine for parallel execution of two futures var result = compute(3) .thenCombine(compute(4), Integer::sum) .get(); System.out.println(result); } }
import kotlinx.coroutines.* suspend fun compute(value: Int): Int = value * value suspend fun main() = coroutineScope { val first = async { compute(3) } // starts immediately, doesn't block val second = async { compute(4) } // starts in parallel val result = first.await() + second.await() println(result) }
async { } starts a coroutine and returns a Deferred<T> — Kotlin's equivalent of Java's CompletableFuture<T>. Calling .await() on a Deferred suspends the current coroutine until the result is ready, without blocking a thread. coroutineScope ensures all launched coroutines finish before the function returns.
Error Handling
No checked exceptions
class Main { // Java: checked exceptions must be declared with 'throws' or caught static void readConfig() throws java.io.IOException { throw new java.io.IOException("Config not found"); } public static void main(String[] args) { try { readConfig(); } catch (java.io.IOException error) { System.out.println("Caught: " + error.getMessage()); } } }
fun readConfig() { throw Exception("Config not found") // no 'throws' declaration needed } fun main() { try { readConfig() } catch (error: Exception) { println("Caught: ${error.message}") } }
Kotlin has no checked exceptions — no method is required to declare the exceptions it might throw. Callers decide what to handle based on documentation and context. Java's checked exceptions enforce handling at the call site but lead to throws Exception cascade or try/catch boilerplate that swallows errors silently. Kotlin shares this design with C#, Python, and most modern languages.
try is an expression
class Main { public static void main(String[] args) { String input = "abc"; int value; try { value = Integer.parseInt(input); } catch (NumberFormatException e) { value = -1; // Java: try is a statement — need pre-declared variable } System.out.println(value); } }
fun main() { val input = "abc" val value = try { input.toInt() } catch (error: NumberFormatException) { -1 // last expression in catch is the result } println(value) }
In Kotlin, try/catch is an expression that produces a value — the last expression in the try block or the catch block. This eliminates the Java pattern of declaring a mutable variable before the try block and assigning it in each branch. The resulting code is more concise and avoids the need for a mutable var.
runCatching — Result type
class Main { static int parse(String input) { return Integer.parseInt(input); } public static void main(String[] args) { // Java Optional-based approach — lossy, ignores the exception var parsed = java.util.Optional.ofNullable(null) .or(() -> { try { return java.util.Optional.of(parse("42")); } catch (Exception e) { return java.util.Optional.empty(); } }); parsed.ifPresent(System.out::println); } }
fun parse(input: String) = input.toInt() fun main() { val goodResult = runCatching { parse("42") } val badResult = runCatching { parse("abc") } println(goodResult.getOrElse { -1 }) // 42 println(badResult.getOrElse { -1 }) // -1 println(goodResult.isSuccess) println(badResult.isFailure) }
runCatching { } wraps a block in a Result<T> — either Success(value) or Failure(exception). It is a clean way to convert exception-based APIs into value-based error handling. getOrElse { fallback }, map { }, and fold { } let you transform the result without explicit try/catch. Java has no built-in Result type equivalent.
Custom exceptions
class Main { static class InsufficientFundsException extends RuntimeException { private final double shortfall; InsufficientFundsException(double shortfall) { super("Need $" + shortfall + " more"); this.shortfall = shortfall; } double getShortfall() { return shortfall; } } static void withdraw(double balance, double amount) { if (amount > balance) throw new InsufficientFundsException(amount - balance); System.out.println("Withdrew $" + amount); } public static void main(String[] args) { try { withdraw(50.0, 100.0); } catch (InsufficientFundsException error) { System.out.println(error.getMessage()); } } }
class InsufficientFundsException(val shortfall: Double) : Exception("Need $$shortfall more") fun withdraw(balance: Double, amount: Double) { if (amount > balance) throw InsufficientFundsException(amount - balance) println("Withdrew $$amount") } fun main() { try { withdraw(50.0, 100.0) } catch (error: InsufficientFundsException) { println(error.message) println("Shortfall: ${error.shortfall}") } }
Custom Kotlin exceptions inherit from Exception (all unchecked). The primary constructor syntax and the val shortfall property declaration combine what Java requires in three separate steps: a field, a constructor parameter, and a getter. The type annotation in catch (error: ExceptionType) is mandatory — Kotlin's catch clause requires an explicit type.