Kotlin's idioms — data classes (auto-generated equals, hashCode, toString, copy), extension functions (add methods to existing types without inheritance), scope functions (let, run, apply, also, with) — and its rich collections API dramatically reduce boilerplate compared to Java while remaining 100% interoperable.
Java's data classes require: fields, constructor, getters, setters, equals(), hashCode(), toString() — easily 50+ lines. Kotlin's data class User(val name: String, val age: Int) generates all of that in one line. Extension functions let you add String.isValidEmail() without touching the String class. These are the two features that make Kotlin feel like a productivity multiplier.
Data classes
// One line replaces ~50 lines of Java
data class User(val name: String, val age: Int, val email: String)
fun main() {
val alice = User("Alice", 30, "alice@example.com")
val bob = User("Bob", 25, "bob@example.com")
// toString() auto-generated
println(alice) // User(name=Alice, age=30, email=alice@example.com)
// equals() compares by content, not reference
val alice2 = User("Alice", 30, "alice@example.com")
println(alice == alice2) // true (structural equality)
// copy(): create a modified version without mutation
val olderAlice = alice.copy(age = 31)
println(olderAlice) // User(name=Alice, age=31, email=alice@example.com)
println(alice) // unchanged
// Destructuring declarations — component functions auto-generated
val (name, age, email) = alice
println("$name is $age years old")
// Works in for loops
val users = listOf(alice, bob)
for ((n, a) in users) {
println("$n: $a")
}
}Extension functions
Extension functions add new methods to existing types without modifying the class or using inheritance. They are resolved statically at compile time — not virtual dispatch.
// Extend String with a new method
fun String.isValidEmail(): Boolean {
return contains("@") && contains(".")
}
// Extend Int with a range check
fun Int.isInRange(min: Int, max: Int) = this in min..max
// Extension on nullable type
fun String?.orEmpty(): String = this ?: ""
fun main() {
println("alice@example.com".isValidEmail()) // true
println("not-an-email".isValidEmail()) // false
println(42.isInRange(1, 100)) // true
val s: String? = null
println(s.orEmpty()) // "" (extension on nullable)
// Stdlib extensions you use constantly:
val nums = listOf(1, 2, 3, 4, 5)
println(nums.filter { it > 2 }) // [3, 4, 5]
println(nums.map { it * it }) // [1, 4, 9, 16, 25]
println(nums.sumOf { it }) // 15
println(nums.groupBy { it % 2 == 0 }) // {false=[1,3,5], true=[2,4]}
}Scope functions: let, run, apply, also, with
Scope functions execute a block of code in the context of an object. The five functions differ in two ways: how they refer to the object (it or this) and what they return (the object itself or the lambda result).
data class Config(var host: String = "", var port: Int = 0, var debug: Boolean = false)
fun main() {
// apply: configure an object — returns the object; "this" inside
val config = Config().apply {
host = "localhost" // this.host = ...
port = 8080
debug = true
}
println(config) // Config(host=localhost, port=8080, debug=true)
// let: transform a value — returns lambda result; "it" inside
// Most useful for null-safe operations
val name: String? = "Alice"
val greeting = name?.let { "Hello, $it!" } ?: "Hello!"
println(greeting) // Hello, Alice!
// also: side effect — returns the object; "it" inside
val nums = mutableListOf(1, 2, 3)
.also { println("Before: $it") }
.apply { add(4) }
.also { println("After: $it") }
// run: compute a result — returns lambda result; "this" inside
val length = "Hello Kotlin".run {
println("String: $this")
length // lambda result
}
println(length) // 12
// with: non-extension version of run — pass object as argument
val result = with(config) {
"$host:$port (debug=$debug)"
}
println(result) // localhost:8080 (debug=true)
}Collections: immutable by default
Kotlin distinguishes read-only collections (List, Map, Set) from mutable collections (MutableList, MutableMap, MutableSet). listOf() returns a read-only view — it doesn't expose add() or remove(). mutableListOf() returns a MutableList. The read-only interface is a compile-time contract — at runtime, listOf() may return a Java ArrayList, but since the reference is typed as List<T>, the mutable methods are inaccessible.
fun main() {
val immutable = listOf(3, 1, 4, 1, 5, 9)
// immutable.add(2) // COMPILE ERROR: unresolved reference: add
val mutable = mutableListOf(3, 1, 4, 1, 5, 9)
mutable.add(2)
// Rich collections API
val words = listOf("banana", "apple", "cherry", "apricot", "blueberry")
// filter, map, sorted, grouped
val aWords = words.filter { it.startsWith("a") }.sorted()
println(aWords) // [apple, apricot]
val byLength = words.groupBy { it.length }
println(byLength) // {6=[banana, cherry], 5=[apple], 7=[apricot], 9=[blueberry]}
// flatMap: flatten a list of lists
val sentences = listOf("hello world", "foo bar baz")
val allWords = sentences.flatMap { it.split(" ") }
println(allWords) // [hello, world, foo, bar, baz]
// associate: build a map from a collection
val wordLengths = words.associateWith { it.length }
println(wordLengths) // {banana=6, apple=5, ...}
// partition: split into two lists
val (short, long) = words.partition { it.length <= 5 }
println("Short: $short, Long: $long")
}open class Animal and class Dog : Animal, and define an extension fun Animal.speak(), calling val d: Animal = Dog(); d.speak() calls Animal.speak() even though the runtime type is Dog. Extensions are not virtual — they cannot be overridden. If you need polymorphic behaviour, define a member function (or use an interface) instead.apply when configuring an object and want the object back. Use let when transforming a value (especially null-safe). A quick mnemonic: apply → configure and return self; let → transform and return new value.List<T> to MutableList<T> — even if it works at runtime, it violates the contract and can cause unexpected mutations in shared collections. Use toMutableList() to create a mutable copy.Extension functions: dispatch and the inline keyword
Extension functions are syntactic sugar — at the JVM level, fun String.isValidEmail() compiles to a static method StringExtensionsKt.isValidEmail(String receiver). The compiler rewrites call sites to call this static method. Because they are static, extension functions cannot override member functions — if a class defines fun toString() and an extension fun Any.toString() exists, the member function always wins. The inline keyword on a function (often a lambda-accepting function) instructs the compiler to copy the function body and the lambda body to the call site — eliminating the overhead of lambda object creation and virtual dispatch. This is how filter, map, and the scope functions are implemented — they are inline, making collections pipelines have near-zero overhead compared to explicit loops.
Kotlin Multiplatform and expect/actual
Kotlin Multiplatform (KMP) allows sharing Kotlin code between JVM, Android, iOS (Kotlin/Native), JavaScript, and WebAssembly targets. The expect/actual mechanism is the bridge: expect fun currentTimeMs(): Long in shared code declares an API; each platform provides the actual fun currentTimeMs(): Long with the platform-specific implementation. Data classes, sealed classes, and the collections API work identically across all targets. Coroutines have multiplatform support — the same coroutine code runs on Android and iOS. KMP is increasingly used for sharing business logic between mobile platforms while keeping platform-specific UI code in Swift/SwiftUI (iOS) and Jetpack Compose (Android).
Kotlin documentation. kotlinlang.org/docs/. Sections: idioms, collections, extensions, scope functions. Jemerov, D. & Isakova, S. (2017). Kotlin in Action. Manning Publications. Chapters 3 (functions and lambdas), 5 (lambdas and collections), 11 (DSLs). Kotlin standard library API reference: kotlinlang.org/api/latest/jvm/stdlib/.