PONY λ M2 Modula-2

Kotlin.CodeCompared.To/Scala

An interactive executable cheatsheet comparing Kotlin and Scala

Kotlin 2.3 Scala 2.13
Basics & Syntax
Hello, World
fun main() { println("Hello, World!") }
object Main { def main(args: Array[String]): Unit = { println("Hello, World!") } }
Scala 2 has no top-level def, so the entry point lives in an object — a singleton, which is exactly Kotlin's object declaration and in fact where Kotlin borrowed the idea. def is fun, Unit is Unit, semicolons are optional, and the body after = is an expression. Scala 3 adds top-level definitions and an @main annotation, which collapses this to almost exactly the Kotlin form.
val and var, unchanged — plus lazy val
Kotlin took val and var from Scala, so this is the one page where you can change nothing at all.
fun main() { val fixed = 1 var counter = 0 counter += 1 val expensive: String by lazy { println("computing...") "cached" } println(expensive) println(expensive) // the block ran once println("$fixed $counter") }
object Main { def main(args: Array[String]): Unit = { val fixed = 1 // the same keyword, the same meaning var counter = 0 counter += 1 // 'lazy val' is a language keyword, not a delegate: computed on first read, // memoized, and thread-safe. lazy val expensive: String = { println("computing...") "cached" } println(expensive) println(expensive) // the block ran once println(s"$fixed $counter") } }
Identical semantics, and by lazy { } becomes the keyword lazy val — same memoization, same thread safety, one less delegate. The difference underneath is that Kotlin's by is a general delegation mechanism (you can write your own), while Scala's lazy is baked into the language. Scala also allows val in a trait, and even lazy val as a class member that a subclass may override — which is how a lot of Scala configuration is wired.
Interpolators: s, f, and raw
fun main() { val name = "Ada" val score = 0.4567 println("$name is here") println("Next: ${name.length + 1}") println("%.2f".format(score)) val letter = """ Dear $name, Regards """.trimIndent() println(letter) }
object Main { def main(args: Array[String]): Unit = { val name = "Ada" val score = 0.4567 // The 's' prefix is not decoration: it names the INTERPOLATOR, and you can // define your own. Without a prefix, $ is just a dollar sign. println(s"$name is here") println(s"Next: ${name.length + 1}") println(f"$score%.2f") // f: type-checked printf formatting println(raw"no \n escape here") // raw: backslashes stay literal val letter = s"""|Dear $name, |Regards""".stripMargin println(letter) } }
Interpolation is a library feature wearing syntax: s"…" calls the s method on a StringContext, and because it is an ordinary method you can add your own (a json"…" or sql"…" interpolator that validates at compile time is a common trick, and has no Kotlin equivalent). f"…" is the one to remember day to day — it is String.format with the format string type-checked against the argument. Multi-line strings use stripMargin with a | rather than trimIndent(), which is uglier but more explicit about where the margin is.
Expressions & Operators
Everything is an expression — including the loop you cannot use
fun classify(score: Int): String { val grade = if (score >= 90) "A" else "B" val note = when { score >= 90 -> "excellent" score >= 80 -> "good" else -> "keep going" } // 'for' is a statement: it produces nothing. val doubled = mutableListOf<Int>() for (number in 1..3) doubled.add(number * 2) return "$grade/$note/$doubled" } fun main() = println(classify(87))
object Main { // No 'return', and no braces needed: a method body IS an expression, and the // last expression in a block is its value. def classify(score: Int): String = { val grade = if (score >= 90) "A" else "B" val note = score match { case s if s >= 90 => "excellent" case s if s >= 80 => "good" case _ => "keep going" } // 'for ... yield' is an EXPRESSION: it produces a collection. val doubled = for (number <- 1 to 3) yield number * 2 s"$grade/$note/${doubled.toList}" } def main(args: Array[String]): Unit = println(classify(87)) }
Kotlin got most of the way here — if and when are expressions — but loops are not, so building a list means a mutableListOf and a pile of add calls (or a switch to map). In Scala for … yield produces a value, so the imperative and the functional form are the same construct. The other habit to drop is return: it exists, but it is a non-local jump, it is discouraged, and writing one in a lambda will surprise you. The last expression of a block is the value; that is the whole rule.
Any method can be an operator
Kotlin allows operator overloading, but only for a fixed list of names (plus, times, get, invoke…). Scala has no such list, because it has no operators — only methods.
data class Money(val cents: Int) { // Only the names Kotlin blesses may be operators. operator fun plus(other: Money) = Money(cents + other.cents) operator fun times(factor: Int) = Money(cents * factor) // There is no way to define '<+>' or ':::' — the name is not allowed. infix fun combinedWith(other: Money) = this + other } fun main() { val total = Money(500) + Money(250) println(total) println(total * 2) println(Money(1) combinedWith Money(2)) // infix needs the keyword }
case class Money(cents: Int) { // '+' is just a method name. So is '*'. So is '<+>'. def +(other: Money): Money = Money(cents + other.cents) def *(factor: Int): Money = Money(cents * factor) def <+>(other: Money): Money = this + other } object Main { def main(args: Array[String]): Unit = { val total = Money(500) + Money(250) println(total) println(total * 2) println(Money(1) <+> Money(2)) // ANY single-argument method can be called infix, with no keyword at all. println(1 to 3) // 'to' is a method on Int println(List(2, 3).mkString("-")) println(1 :: List(2, 3)) // and '::' is a method on List } }
There is no operator table because there are no operators: a + b is a.+(b), and any method taking one argument can be written infix without Kotlin's infix keyword. That symmetry is what makes libraries like Cats and Akka read the way they do — and it is also the reason Scala has a reputation for line-noise, since nothing stops a library from naming a method <*> or |@|. Kotlin's fixed list is the deliberate reaction to exactly this. One rule worth knowing: a method whose name ends in : is right-associative, which is why 1 :: list prepends rather than appends.
Option Instead of null
There is no String? — there is Option[String]
Both languages solve the billion-dollar mistake; they solve it in completely different layers. Kotlin puts absence in the type checker. Scala puts it in a value.
fun findName(id: Int): String? = if (id == 1) "Ada" else null fun main() { println(findName(1)?.uppercase()) // safe call — compiler magic println(findName(99)?.uppercase()) // null println(findName(99) ?: "(none)") // Elvis val found = findName(1) if (found != null) println(found.length) // smart cast }
object Main { // Option[String] is Some(value) or None. It is an ordinary two-case data type, // not a compiler feature — so there is no ?. and no ?: , because they are not // needed: 'map' and 'getOrElse' are just methods. def findName(id: Int): Option[String] = if (id == 1) Some("Ada") else None def main(args: Array[String]): Unit = { println(findName(1).map(_.toUpperCase)) // Some(ADA) println(findName(99).map(_.toUpperCase)) // None — map on None does nothing println(findName(99).getOrElse("(none)")) // Elvis findName(1) match { case Some(name) => println(name.length) case None => println("nothing") } } }
The consequences run deep. Because Option is a value, it composes with every other value: it goes in a List, it is pattern-matched, it flows through a for-comprehension, and the same map/flatMap/getOrElse vocabulary works on List, Either, Try and Future. Kotlin's ? is erased at run time and costs nothing, but it is special-purpose syntax — ?. works on nullables and nothing else, and there is no way to write a function that is generic over "things that might be absent". The price Scala pays is an allocation per Some, and the fact that null still exists on the JVM underneath (it leaks in from Java, and idiomatic Scala wraps it immediately with Option(javaValue)).
Chaining: map, flatMap, filter, fold
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()) println(unknown.address?.city?.uppercase() ?: "(none)") val people = listOf(known, unknown) println(people.mapNotNull { it.address?.city }) }
case class Address(city: Option[String]) case class Person(address: Option[Address]) object Main { def main(args: Array[String]): Unit = { val known = Person(Some(Address(Some("Oslo")))) val unknown = Person(None) // flatMap is the safe call: it collapses Option[Option[A]] into Option[A]. println(known.address.flatMap(_.city).map(_.toUpperCase)) println(unknown.address.flatMap(_.city).map(_.toUpperCase).getOrElse("(none)")) // fold handles both cases at once: fold(ifEmpty)(ifPresent). println(known.address.flatMap(_.city).fold("(none)")(_.toUpperCase)) val people = List(known, unknown) println(people.flatMap(_.address).flatMap(_.city)) // mapNotNull println(Some(4).filter(_ > 10)) // None — filter works on Option too } }
The mapping is mechanical once you see it: ?. on a value-returning call is map, ?. on a call that itself returns an optional is flatMap, ?: is getOrElse, and mapNotNull is a flatMap. What is not mechanical is that these are the same methods a List has — List.flatMap and Option.flatMap mean the same thing, which is why people.flatMap(_.address) above drops the empty ones without a special combinator. And fold(ifEmpty)(ifPresent) is the total version: it forces you to handle both branches, and returns a plain value.
Collections
The same operators, and the underscore instead of it
fun main() { val names = listOf("Ada", "Grace", "Alan", "Barbara") println(names.filter { it.length > 3 }.map { it.uppercase() }) println(names.sumOf { it.length }) println(names.joinToString(", ")) println(names.groupBy { it.length }) println(names.partition { it.startsWith("A") }) println(names.zip(1..4)) }
object Main { def main(args: Array[String]): Unit = { val names = List("Ada", "Grace", "Alan", "Barbara") // '_' is the placeholder for a single-use lambda parameter — Kotlin's 'it'. println(names.filter(_.length > 3).map(_.toUpperCase)) println(names.map(_.length).sum) println(names.mkString(", ")) println(names.groupBy(_.length)) println(names.partition(_.startsWith("A"))) println(names.zip(1 to 4)) // 'collect' fuses filter and map through a partial function — no Kotlin // equivalent: only the names that MATCH the pattern come through. println(names.collect { case name if name.length == 3 => name.toUpperCase }) } }
The vocabulary is close enough to guess at — joinToString is mkString, sumOf { } is map(…).sum — and _ is it, with one catch: each _ refers to a different parameter, so list.reduce(_ + _) means (a, b) => a + b, and you cannot use _ twice for the same value. The genuinely new tool is collect, which takes a partial function and keeps only the elements the pattern matches — a filter and a map and a destructuring in one, and the thing you will reach for constantly once it clicks. Note also that List here is truly immutable and persistent (a cons list), not a read-only view over a mutable one.
Immutable by default, and mutable by import
fun main() { val readOnly = listOf(1, 2, 3) // read-only VIEW; no add() to call val mutable = mutableListOf(1, 2, 3) mutable.add(4) println(readOnly) println(mutable) val map = mutableMapOf("a" to 1) map["b"] = 2 println(map) }
import scala.collection.mutable // mutability is an IMPORT away, deliberately object Main { def main(args: Array[String]): Unit = { val immutable = List(1, 2, 3) // persistent: :+ returns a NEW list val grown = immutable :+ 4 println(immutable) // unchanged println(grown) val buffer = mutable.ListBuffer(1, 2, 3) buffer += 4 println(buffer.toList) // The immutable Map is the default one. Updating returns a new map. val scores = Map("a" -> 1) println(scores + ("b" -> 2)) println(scores) // still just a } }
Kotlin's List is a read-only interface over what may well be a mutable ArrayList — someone holding the other reference can still change it under you. Scala's List is persistent: adding an element returns a new list that structurally shares its tail with the old one, and no reference anywhere can mutate it. That is a real guarantee rather than a promise, and it is why Scala can hand collections between threads without a thought. The cost is that you must think about which operations are O(1) on a cons list (prepend) and which are O(n) (append, indexing) — reach for Vector when you need both ends.
Sequences become views
fun main() { val firstTwo = listOf(1, 2, 3, 4, 5) .asSequence() .onEach { println("seeing $it") } .map { it * it } .take(2) .toList() println(firstTwo) println(generateSequence(1) { it * 2 }.take(6).toList()) }
object Main { def main(args: Array[String]): Unit = { // '.view' is '.asSequence()': lazy, fused, one pass, nothing computed until // the terminal '.toList'. val firstTwo = List(1, 2, 3, 4, 5).view .map { number => println(s"seeing $number"); number * number } .take(2) .toList println(firstTwo) // LazyList is an infinite, memoized stream — Kotlin's generateSequence. val powers = LazyList.iterate(1)(_ * 2) println(powers.take(6).toList) } }
view is asSequence() and LazyList is generateSequence — same laziness, same fusion, same requirement to force it at the end. The one extra idea Scala has is that LazyList memoizes as it goes, so a value is computed once no matter how many times you traverse it; a Kotlin Sequence is single-pass and will happily re-run its work if you iterate it twice. (This was called Stream before 2.13, and you will still see that name in older code and in every Scala tutorial written before 2019.)
Tuples are first-class, up to 22
fun main() { val pair = Pair("Ada", 36) // Pair, Triple — and then you stop val (name, age) = pair println("$name $age ${pair.first}") // Beyond three, Kotlin makes you declare a data class. data class Row(val id: Int, val name: String, val age: Int, val city: String) println(Row(1, "Ada", 36, "London")) }
object Main { def main(args: Array[String]): Unit = { val pair = ("Ada", 36) // just parentheses: a Tuple2 val (name, age) = pair println(s"$name $age ${pair._1}") val row = (1, "Ada", 36, "London") // Tuple4, and so on up to Tuple22 println(row) // '->' builds a pair — it is a method, not syntax, which is why the same // arrow works in a Map literal. println("a" -> 1) println(Map("a" -> 1, "b" -> 2)) // Tuples pattern-match, which is what makes them worth using. List((1, "one"), (2, "two")).foreach { case (number, label) => println(s"$number = $label") } } }
Kotlin gives you Pair and Triple and then tells you to write a data class — sensible advice, and a real friction point when a function just needs to return three things. Scala has tuple literals up to 22 elements, they pattern-match, and a -> b is an ordinary method that builds one (which is why the same arrow appears in Map literals). The advice is still to name your types when they mean something; the difference is that Scala does not force it on you at element three.
Methods & Functions
def, default arguments, and named arguments
fun greet(name: String, salutation: String = "Hello", punctuation: String = "!") = "$salutation, $name$punctuation" fun main() { println(greet("Ada")) println(greet("Ada", punctuation = "?")) }
object Main { // Default and named arguments, exactly as in Kotlin. '=' before the body makes // it an expression method — no braces, no return. def greet(name: String, salutation: String = "Hello", punctuation: String = "!"): String = s"$salutation, $name$punctuation" def main(args: Array[String]): Unit = { println(greet("Ada")) println(greet("Ada", punctuation = "?")) } }
Nothing to relearn: defaults and named arguments work the same way, and skipping a middle argument by name works too. The one habit to add is the =: a method whose body is an expression is written def f(…): T = expr, which is Kotlin's expression body under the same sign. Omitting the = (the old "procedure syntax") makes the return type Unit, silently discarding your result — it is removed in Scala 3, for exactly that reason.
Multiple parameter lists and currying
Scala lets a method take several parameter lists. It looks like a curiosity and turns out to be load-bearing: it is how partial application, type inference, and the whole implicit mechanism are wired.
fun add(first: Int): (Int) -> Int = { second -> first + second } fun <T> transform(items: List<T>, function: (T) -> T): List<T> = items.map(function) fun main() { println(add(2)(3)) // The trailing-lambda syntax is Kotlin's answer, and it is a syntax rule // rather than a type-system one. println(transform(listOf(1, 2, 3)) { it * 2 }) }
object Main { // Two parameter lists. Applying only the first returns a function. def add(first: Int)(second: Int): Int = first + second def transform[A](items: List[A])(function: A => A): List[A] = items.map(function) def main(args: Array[String]): Unit = { println(add(2)(3)) val addTwo = add(2) _ // partial application: an (Int => Int) println(addTwo(10)) // Type inference flows LEFT TO RIGHT across parameter lists, so A is already // known to be Int by the time the lambda is checked — no annotation needed. println(transform(List(1, 2, 3))(number => number * 2)) // A single-argument list may use braces, which is how Scala gets DSL blocks. println(transform(List(1, 2, 3)) { number => number * 3 }) } }
The payoff is not the currying itself but what it enables. Type inference proceeds one parameter list at a time, so transform(list)(lambda) can infer the element type from the first list and use it to check the second — which is why Scala lambdas so rarely need type annotations. A brace-delimited final list is what makes Scala DSLs look like language constructs. And the mechanism generalizes: an implicit parameter list is just one more list, which the compiler fills in for you. Kotlin's trailing-lambda syntax buys the prettiest use case of this and none of the rest.
By-name parameters: an argument that is not evaluated
// Kotlin takes a lambda, and 'inline' removes its cost. inline fun unless(condition: Boolean, body: () -> Unit) { if (!condition) body() } inline fun logged(enabled: Boolean, message: () -> String) { if (enabled) println(message()) } fun expensive(): String { println("(computing the message)") return "done" } fun main() { unless(false) { println("ran") } logged(false) { expensive() } // expensive() never runs logged(true) { expensive() } }
object Main { // '=> Unit' marks a BY-NAME parameter: the argument expression is passed // unevaluated and re-evaluated at each use. No lambda syntax at the call site. def unless(condition: Boolean)(body: => Unit): Unit = if (!condition) body def logged(enabled: Boolean)(message: => String): Unit = if (enabled) println(message) def expensive(): String = { println("(computing the message)") "done" } def main(args: Array[String]): Unit = { unless(false) { println("ran") } logged(false)(expensive()) // expensive() never runs — it was never evaluated logged(true)(expensive()) } }
A by-name parameter is written x: => T and is not a lambda: the caller writes an ordinary expression, and the callee decides whether — and how often — it is evaluated. That is what lets Scala define control structures that look built in, and it is why logged(false)(expensive()) above prints nothing at all despite the call parentheses being right there. Kotlin gets the same laziness by taking a () -> T lambda and marking the function inline so the closure allocation disappears; the cost is that the call site must show the braces. Watch the trap in both languages: a by-name parameter used twice evaluates twice, so bind it to a lazy val if you need it memoized.
Partial functions: a lambda made of cases
fun main() { val numbers = listOf(1, 2, 3, 4, 5, 6) // Kotlin: filter, then map. Two passes, two lambdas. val labels = numbers .filter { it % 2 == 0 } .map { "even: $it" } println(labels) // A lambda cannot BE a set of cases — 'when' goes inside it. val describe: (Int) -> String = { number -> when { number % 2 == 0 -> "even" else -> "odd" } } println(numbers.map(describe)) }
object Main { def main(args: Array[String]): Unit = { val numbers = List(1, 2, 3, 4, 5, 6) // A block of 'case' clauses IS a function literal. 'collect' takes one and // keeps only the elements it is defined for. println(numbers.collect { case number if number % 2 == 0 => s"even: $number" }) // As a value, it is a PartialFunction: it knows where it is defined. val evens: PartialFunction[Int, String] = { case number if number % 2 == 0 => "even" } println(evens.isDefinedAt(2)) println(evens.isDefinedAt(3)) // orElse composes two partial functions into a total one. val odds: PartialFunction[Int, String] = { case _ => "odd" } println(numbers.map(evens orElse odds)) } }
A PartialFunction is a function that carries its own domain: it can be asked isDefinedAt, and it can be composed with orElse to cover the gaps. That is what makes collect possible, and it is why Akka actors are written as a block of case clauses — the actor's message handler literally is a partial function, and messages it is not defined for are handled elsewhere. Kotlin has no such concept: a lambda is total, so filtering and mapping stay two separate operations, and there is no way to ask a function which inputs it accepts.
Classes, Case Classes & Traits
data class becomes case class
Kotlin's data class is a direct descendant of this, so most of it transfers — including copy, which Java and Dart both made you hand-write.
data class Person(val name: String, val age: Int) { fun greeting() = "Hi, $name" } fun main() { val ada = Person("Ada", 36) println(ada) println(ada == Person("Ada", 36)) // structural equality println(ada.copy(age = 37)) val (name, age) = ada // componentN destructuring println("$name $age") }
case class Person(name: String, age: Int) { def greeting: String = s"Hi, $name" // no parens: a zero-arg method may omit them } object Main { def main(args: Array[String]): Unit = { val ada = Person("Ada", 36) // no 'new' — the companion's apply println(ada) println(ada == Person("Ada", 36)) // structural equality, generated println(ada.copy(age = 37)) // copy, generated val Person(name, age) = ada // destructuring — via a PATTERN println(s"$name $age") ada match { // and the same pattern works here case Person(_, years) if years > 30 => println(s"over thirty: $years") case Person(other, _) => println(other) } } }
Same generated members (equals, hashCode, toString, copy), same value semantics — the fields are val by default, so you do not even write the keyword. The difference is what a case class also gets: an unapply method, which is what makes case Person(name, age) work in a pattern. Kotlin's destructuring is component1()/component2() — positional, and usable only in a destructuring declaration or a for. Scala's is a real pattern, so it works in match, in val, in a lambda's case, and nested arbitrarily deep.
Companion objects, apply, and unapply
class Temperature private constructor(val celsius: Double) { companion object { fun fromFahrenheit(degrees: Double) = Temperature((degrees - 32) / 1.8) // 'operator fun invoke' lets you call the companion like a function. operator fun invoke(celsius: Double) = Temperature(celsius) } override fun toString() = "${celsius}C" } fun main() { println(Temperature(100.0)) println(Temperature.fromFahrenheit(212.0)) }
class Temperature private (val celsius: Double) { override def toString: String = s"${celsius}C" } // A companion object: same name, same file, privileged access to the class. // This is where Kotlin's 'companion object' came from. object Temperature { // 'apply' is called when you write Temperature(...) — no 'new', no keyword. def apply(celsius: Double): Temperature = new Temperature(celsius) def fromFahrenheit(degrees: Double): Temperature = apply((degrees - 32) / 1.8) // 'unapply' is the reverse: it makes Temperature usable as a PATTERN. def unapply(temperature: Temperature): Option[Double] = Some(temperature.celsius) } object Main { def main(args: Array[String]): Unit = { println(Temperature(100.0)) println(Temperature.fromFahrenheit(212.0)) Temperature(37.0) match { case Temperature(degrees) if degrees > 30 => println(s"warm: $degrees") case Temperature(degrees) => println(degrees) } } }
Kotlin's companion object and operator fun invoke are both borrowed from here, so apply will feel familiar. unapply is the half Kotlin did not take, and it is the more interesting one: defining it makes your type usable on the left of a case, so pattern matching is extensible by any class, not just data classes. That is how case Some(x), case head :: tail, and every regex or JSON extractor in the ecosystem work — they are ordinary objects with an unapply method.
Traits: interfaces with state, stacked by linearization
interface Greeter { fun greet(name: String) = "Hello, $name" } // An interface cannot hold state, so a counter must live in the class. interface Loud : Greeter { override fun greet(name: String) = super.greet(name).uppercase() + "!" } class Person : Loud { // Diamond conflicts are resolved explicitly: super<Loud>.greet(name) override fun greet(name: String) = super<Loud>.greet(name) } fun main() = println(Person().greet("Ada"))
trait Greeter { var greetings = 0 // a trait CAN hold state def greet(name: String): String = s"Hello, $name" } // 'super' inside a trait is not resolved until the trait is mixed in — which is // what makes traits STACKABLE. trait Loud extends Greeter { override def greet(name: String): String = super.greet(name).toUpperCase + "!" } trait Counting extends Greeter { override def greet(name: String): String = { greetings += 1 super.greet(name) } } class Person extends Greeter with Loud with Counting object Main { def main(args: Array[String]): Unit = { val person = new Person println(person.greet("Ada")) // Counting runs first, then Loud: linearization println(person.greet("Alan")) println(person.greetings) } }
Two differences that matter. A trait can hold state (var greetings), which a Kotlin interface cannot — Kotlin interfaces may declare a property but never store it, so shared state has to be re-implemented in every class. And super inside a trait is resolved at mix-in time, by linearizing the traits right to left: with Loud with Counting means Counting.greet runs first and its super call lands in Loud. That is the "stackable traits" pattern, and it makes each trait a composable decorator. It is also the sharpest edge in the language: reorder the with clauses and the behavior changes, silently.
Pattern Matching
when becomes match — and destructures
sealed interface Shape data class Circle(val radius: Double) : Shape data class Rectangle(val width: Double, val height: Double) : Shape fun area(shape: Shape): Double = when (shape) { // 'is' smart-casts, but you still reach through the value by name. is Circle -> 3.14159 * shape.radius * shape.radius is Rectangle -> shape.width * shape.height } fun main() { println(area(Circle(1.0))) println(area(Rectangle(2.0, 3.0))) }
sealed trait Shape case class Circle(radius: Double) extends Shape case class Rectangle(width: Double, height: Double) extends Shape object Main { // No 'else': 'sealed' means the compiler knows every subtype and warns on a // missing case. The pattern DESTRUCTURES as it matches. def area(shape: Shape): Double = shape match { case Circle(radius) => 3.14159 * radius * radius case Rectangle(width, height) => width * height } def main(args: Array[String]): Unit = { println(area(Circle(1.0))) println(area(Rectangle(2.0, 3.0))) // Patterns nest, bind with @, and take guards. val shapes = List(Circle(1.0), Rectangle(2.0, 3.0), Circle(10.0)) shapes.foreach { case circle @ Circle(radius) if radius > 5 => println(s"big $circle") case Circle(radius) => println(s"circle $radius") case Rectangle(width, height) => println(s"rect ${width * height}") } } }
Kotlin's when (shape) { is Circle -> … } tests the type and smart-casts, but the value stays whole — you write shape.radius. Scala's pattern takes it apart in the same breath, nests to any depth (case Some(Circle(r))), binds a whole subpattern with @, and takes a guard with if. A block of case clauses is also a function literal, which is why foreach { case … } above needs no lambda parameter at all. One caution: an inexhaustive match on a sealed type is a warning in Scala, not an error like Kotlin's — turn on -Xfatal-warnings, which every serious build does.
Extractors: patterns you define yourself
This is the part of pattern matching Kotlin has no version of. Any object with an unapply method can appear on the left of a case.
fun describe(text: String): String { // Kotlin can only match on types and constants, so parsing has to happen // BEFORE the when, in a variable. val number = text.toIntOrNull() return when { number != null && number > 100 -> "big number $number" number != null -> "number $number" text.startsWith("#") -> "tag ${text.drop(1)}" else -> "text $text" } } fun main() { println(describe("7")) println(describe("1000")) println(describe("#kotlin")) println(describe("hello")) }
import scala.util.Try // An extractor: unapply turns a String into an Option[Int]. Some => match, None => skip. object AsInt { def unapply(text: String): Option[Int] = Try(text.toInt).toOption } object Tag { def unapply(text: String): Option[String] = if (text.startsWith("#")) Some(text.drop(1)) else None } object Main { // The parsing happens INSIDE the pattern, and binds its result. def describe(text: String): String = text match { case AsInt(number) if number > 100 => s"big number $number" case AsInt(number) => s"number $number" case Tag(name) => s"tag $name" case other => s"text $other" } def main(args: Array[String]): Unit = { println(describe("7")) println(describe("1000")) println(describe("#scala")) println(describe("hello")) } }
An extractor is just an object with unapply(input): Option[Result] — returning Some means the pattern matched and the contents are bound, None means try the next case. That single convention makes pattern matching open: you can match on a parsed integer, a regex capture, a JSON shape, or a domain concept like case ValidEmail(user, host), and it reads as though the language knew about it. Kotlin's when is closed by comparison — it matches types, constants and boolean conditions, so any real parsing must be lifted out into a variable first, which is exactly what the left column has to do.
Matching the shape of a list
fun sum(numbers: List<Int>): Int = when { numbers.isEmpty() -> 0 // No list pattern: you take the head and the tail by hand. else -> numbers.first() + sum(numbers.drop(1)) } fun main() { println(sum(listOf(1, 2, 3, 4))) val numbers = listOf(10, 20, 30) println("${numbers.first()} then ${numbers.drop(1)}") }
object Main { // '::' is the cons pattern: head :: tail. Nil is the empty list. def sum(numbers: List[Int]): Int = numbers match { case Nil => 0 case head :: tail => head + sum(tail) } def main(args: Array[String]): Unit = { println(sum(List(1, 2, 3, 4))) // Match an exact shape, or a prefix with a wildcard rest. List(10, 20, 30) match { case first :: rest => println(s"$first then $rest") case Nil => println("empty") } List(1, 2, 3) match { case List(a, b, c) => println(s"exactly three: $a $b $c") case _ => println("some other length") } List(1, 2, 3, 4) match { case first +: _ :+ last => println(s"$first ... $last") // both ends case _ => println("too short") } } }
The head :: tail pattern is the reason recursive list code reads so cleanly in Scala, and it is the shape every functional-programming tutorial assumes you know. Kotlin can express the same recursion with first() and drop(1), but each call allocates a new list and nothing checks that you handled the empty case — the when is not exhaustive over a List, so an off-by-one is a runtime NoSuchElementException rather than a compile-time warning. The +: / :+ patterns match at either end, which is a nicety even most Scala developers forget exists.
Implicits & Type Classes
Implicit parameters: arguments the compiler supplies
The feature with no Kotlin counterpart, and the one that explains the rest of the language. An implicit parameter is filled in by the compiler from whatever unique value of that type is in scope.
data class Configuration(val verbose: Boolean, val prefix: String) // Kotlin has no implicit parameters. The context is threaded through by hand, // on every call, all the way down. fun log(message: String, configuration: Configuration) { if (configuration.verbose) println("${configuration.prefix} $message") } fun process(items: List<String>, configuration: Configuration) { log("processing ${items.size} items", configuration) items.forEach { log("item $it", configuration) } } fun main() { val configuration = Configuration(verbose = true, prefix = "[app]") process(listOf("a", "b"), configuration) }
case class Configuration(verbose: Boolean, prefix: String) object Main { // An 'implicit' parameter list. Callers do not pass it; the compiler finds it. def log(message: String)(implicit configuration: Configuration): Unit = if (configuration.verbose) println(s"${configuration.prefix} $message") // 'process' needs one too — but only to PASS IT ON, and it does that invisibly. def process(items: List[String])(implicit configuration: Configuration): Unit = { log(s"processing ${items.size} items") items.foreach(item => log(s"item $item")) } def main(args: Array[String]): Unit = { implicit val configuration: Configuration = Configuration(verbose = true, prefix = "[app]") process(List("a", "b")) // configuration is threaded through, untyped by hand } }
One implicit val at the top, and every call below it that needs a Configuration gets one — including calls three layers down, which is where the hand-threading in the left column becomes genuinely painful (add a parameter to a leaf function and every caller in the chain changes). This is how Scala passes an ExecutionContext to every Future, a transaction to every query, and a logger to everything. Kotlin's nearest attempt is context receivers, still experimental after several redesigns. The cost is the one everybody warns you about: when the compiler cannot find an implicit, the error tells you a value is missing but not why the one you expected was not eligible — and Scala 3 renamed the whole thing to given/using partly to make that failure legible.
Extension functions become implicit classes
fun String.shout() = "${uppercase()}!" val String.initials: String get() = split(" ").map { it.first() }.joinToString("") fun main() { println("hello".shout()) println("Ada Lovelace".initials) }
object Extensions { // An implicit class wraps the receiver. The compiler inserts the wrap for you // when you call a method String does not have. implicit class StringExtensions(val text: String) extends AnyVal { def shout: String = text.toUpperCase + "!" def initials: String = text.split(" ").map(_.head).mkString } } import Extensions._ // extensions must be IMPORTED into scope object Main { def main(args: Array[String]): Unit = { println("hello".shout) println("Ada Lovelace".initials) } }
Same result, one extra layer of machinery: Scala reaches its extension methods through an implicit conversion to a wrapper class, and extends AnyVal is what lets the compiler erase that wrapper so nothing is allocated. The practical difference is scope — a Kotlin extension is visible wherever it is imported as a function, while a Scala implicit class must be in implicit scope, which usually means an explicit import Extensions._. Scala 3 replaces all of this with a first-class extension (text: String) def shout: String = …, which is essentially Kotlin's syntax arriving fifteen years later.
Type classes: the thing interfaces cannot do
This is the destination the implicit machinery was built for. A type class lets you add behavior to a type you do not own, and dispatch on it at compile time — which an interface fundamentally cannot, because you cannot make Int implement your interface after the fact.
interface JsonWriter<T> { fun write(value: T): String } // Kotlin: the instance must be passed EXPLICITLY at every call site, because // there is no way to look it up from the type. object IntWriter : JsonWriter<Int> { override fun write(value: Int) = value.toString() } object StringWriter : JsonWriter<String> { override fun write(value: String) = "\"$value\"" } class ListWriter<T>(private val inner: JsonWriter<T>) : JsonWriter<List<T>> { override fun write(value: List<T>) = value.joinToString(",", "[", "]") { inner.write(it) } } fun <T> toJson(value: T, writer: JsonWriter<T>) = writer.write(value) fun main() { println(toJson(36, IntWriter)) println(toJson("Ada", StringWriter)) println(toJson(listOf(1, 2, 3), ListWriter(IntWriter))) // wired by hand }
trait JsonWriter[A] { def write(value: A): String } object JsonWriter { // The instances live in the companion, which is part of IMPLICIT SCOPE — so // they are found automatically, with no import. implicit val intWriter: JsonWriter[Int] = (value: Int) => value.toString implicit val stringWriter: JsonWriter[String] = (value: String) => "\"" + value + "\"" // An instance that DEPENDS on another instance: the compiler assembles the // chain for you, at compile time, however deep it goes. implicit def listWriter[A](implicit inner: JsonWriter[A]): JsonWriter[List[A]] = (values: List[A]) => values.map(inner.write).mkString("[", ",", "]") } object Main { // 'A: JsonWriter' is a context bound: sugar for an implicit JsonWriter[A] parameter. def toJson[A](value: A)(implicit writer: JsonWriter[A]): String = writer.write(value) def main(args: Array[String]): Unit = { println(toJson(36)) println(toJson("Ada")) println(toJson(List(1, 2, 3))) // JsonWriter[List[Int]] — SYNTHESIZED println(toJson(List("a", "b"))) // and again, for a different A // println(toJson(3.7)) ← compile error: no JsonWriter[Double] in scope } }
Look at what the compiler did with toJson(List(1, 2, 3)): it needed a JsonWriter[List[Int]], found listWriter, saw that it required a JsonWriter[Int], found intWriter, and assembled the instance — all at compile time, with the call site saying nothing. The Kotlin column has to build that same tree by hand (ListWriter(IntWriter)) at every use, and grows unmanageable for a nested type like Map<String, List<Int>>. That resolution is the reason Scala's library ecosystem looks the way it does: JSON codecs, ordering, equality, and the whole of Cats are type classes, and it is also the reason the compiler is slow and the error messages are what they are. Missing instance means a compile error, which is the good half of the bargain.
Variance & Higher-Kinded Types
Variance: out and in become + and -
open class Animal(val name: String) class Cat(name: String) : Animal(name) // Declaration-site variance, exactly as in Scala — Kotlin borrowed it. class Box<out A>(val value: A) // covariant: Box<Cat> IS a Box<Animal> interface Sink<in A> { fun accept(value: A) } // contravariant fun main() { val catBox: Box<Cat> = Box(Cat("Tom")) val animalBox: Box<Animal> = catBox // allowed, because of 'out' println(animalBox.value.name) val animalSink = object : Sink<Animal> { override fun accept(value: Animal) = println("got ${value.name}") } val catSink: Sink<Cat> = animalSink // allowed, because of 'in' catSink.accept(Cat("Jerry")) }
class Animal(val name: String) class Cat(name: String) extends Animal(name) class Box[+A](val value: A) // + is 'out': covariant trait Sink[-A] { def accept(value: A): Unit } // - is 'in': contravariant object Main { def main(args: Array[String]): Unit = { val catBox: Box[Cat] = new Box(new Cat("Tom")) val animalBox: Box[Animal] = catBox // allowed, because of + println(animalBox.value.name) val animalSink: Sink[Animal] = (value: Animal) => println(s"got ${value.name}") val catSink: Sink[Cat] = animalSink // allowed, because of - catSink.accept(new Cat("Jerry")) // Function types are variance in action: A => B is contravariant in A and // covariant in B, which is why this assignment type-checks. val describeAnimal: Animal => String = animal => animal.name val describeCat: Cat => String = describeAnimal println(describeCat(new Cat("Tom"))) } }
Kotlin took declaration-site variance from Scala and renamed the annotations: +A is out A, -A is in A, and the rules (a covariant parameter may only appear in output position, a contravariant one only in input position) are identical. Kotlin adds use-site variance too (Array<out Any> at a call site), which Scala expresses with existential types or bounds and generally avoids. The reason this matters more in Scala is that its collections are covariant by default — List[Cat] genuinely is a List[Animal], where Kotlin's List is covariant but MutableList cannot be.
Higher-kinded types: abstracting over List itself
The type-system feature Kotlin cannot express at all. A higher-kinded type parameter is a type constructorList, Option, Future — rather than a type, so you can write code that works for any of them.
// This is the signature we want: // // fun <F<_>, A> double(container: F<Int>): F<Int> // // Kotlin cannot express 'F<_>': a type parameter may stand for a TYPE, never for // a type constructor. So there is no way to write one function that works for // both List<Int> and Int?. You write it once per container, forever. fun doubleList(container: List<Int>): List<Int> = container.map { it * 2 } fun doubleNullable(container: Int?): Int? = container?.let { it * 2 } fun main() { println(doubleList(listOf(1, 2, 3))) println(doubleNullable(21)) println(doubleNullable(null)) }
// F[_] is a type parameter that takes a type parameter. The signature is // literally "for any container F that can be mapped over". trait Mappable[F[_]] { def map[A, B](container: F[A])(transform: A => B): F[B] } object Mappable { implicit val listMappable: Mappable[List] = new Mappable[List] { def map[A, B](container: List[A])(transform: A => B): List[B] = container.map(transform) } implicit val optionMappable: Mappable[Option] = new Mappable[Option] { def map[A, B](container: Option[A])(transform: A => B): Option[B] = container.map(transform) } } object Main { // ONE function. It works for List, for Option, and for anything else with an // instance — including types that do not exist yet. def double[F[_]](container: F[Int])(implicit mappable: Mappable[F]): F[Int] = mappable.map(container)(_ * 2) def main(args: Array[String]): Unit = { println(double(List(1, 2, 3))) println(double(Option(21))) println(double(Option.empty[Int])) } }
A Kotlin type parameter always stands for a complete type, so F<_> is simply not writable and there is no way to say "any container that can be mapped over" — you write doubleList, doubleNullable, doubleSequence, and accept the duplication. (The Arrow library simulates higher kinds with a defunctionalization trick, and the boilerplate it costs is the argument for why this belongs in the language.) In Scala, F[_] plus implicits is the foundation everything functional is built on: Functor, Monad, Traverse and the rest of Cats are exactly this trait with more laws, which is what lets one traverse work over every effect type in your program.
For-Comprehensions
The for-comprehension: one syntax, every container
A for … yield is not a loop. It is sugar for flatMap/map/withFilter, so it works on anything with those methods — and it is the construct Kotlin most conspicuously lacks.
class User(val name: String, val managerId: Int?) val users = mapOf( 1 to User("Ada", null), 2 to User("Grace", 1), ) fun managerName(id: Int): String? { // Each step can fail. The chain is safe-calls and lets, and it gets deeper // with every hop. val user = users[id] ?: return null val managerId = user.managerId ?: return null return users[managerId]?.name } fun main() { println(managerName(2)) println(managerName(1)) println(managerName(99)) // Over lists, it is a nested loop with a filter. val pairs = (1..3).flatMap { number -> listOf("a", "b").filter { number % 2 == 1 }.map { letter -> "$number$letter" } } println(pairs) }
case class User(name: String, managerId: Option[Int]) object Main { val users = Map( 1 -> User("Ada", None), 2 -> User("Grace", Some(1)), ) // Each <- is a flatMap. Any None short-circuits the whole thing to None, // and the happy path reads top to bottom like ordinary code. def managerName(id: Int): Option[String] = for { user <- users.get(id) managerId <- user.managerId manager <- users.get(managerId) } yield manager.name def main(args: Array[String]): Unit = { println(managerName(2)) println(managerName(1)) println(managerName(99)) // The SAME syntax over lists is a nested loop. 'if' is a filter. val pairs = for { number <- 1 to 3 if number % 2 == 1 letter <- List("a", "b") } yield s"$number$letter" println(pairs.toList) } }
Read the two yield blocks and notice that they are the same syntax doing two different things: over Option it is a chain of fallible steps that short-circuits on the first None; over List it is a nested loop with a filter. It works because both types have flatMap, and for is nothing but sugar for it — which means it works over Either, Try, Future, a database transaction, or any type you give a flatMap to. Kotlin has no such construct, so each container gets its own idiom: ?:-with-early-return for nullables, flatMap chains for lists, suspend for futures. That fragmentation is the concrete cost of not having the abstraction.
The same for-comprehension over Either
// Kotlin's Result carries a Throwable, not a typed error, and it does not // compose — so a chain of fallible steps is a chain of ?: and lets. fun parse(text: String): Int? = text.toIntOrNull() fun checkPositive(number: Int): Int? = if (number > 0) number else null fun half(number: Int): Int? = if (number % 2 == 0) number / 2 else null fun pipeline(text: String): String { val parsed = parse(text) ?: return "not a number" val positive = checkPositive(parsed) ?: return "not positive" val halved = half(positive) ?: return "not even" return "ok: $halved" } fun main() { println(pipeline("8")) println(pipeline("7")) println(pipeline("-2")) println(pipeline("nope")) }
import scala.util.Try object Main { // Either[Error, Value] — Left is the failure, Right is the success, and the // error type is YOURS, not Throwable. def parse(text: String): Either[String, Int] = Try(text.toInt).toEither.left.map(_ => "not a number") def checkPositive(number: Int): Either[String, Int] = if (number > 0) Right(number) else Left("not positive") def half(number: Int): Either[String, Int] = if (number % 2 == 0) Right(number / 2) else Left("not even") // Identical shape to the Option version — because Either has flatMap too. // The first Left short-circuits, and carries its message out. def pipeline(text: String): String = (for { parsed <- parse(text) positive <- checkPositive(parsed) halved <- half(positive) } yield s"ok: $halved").merge def main(args: Array[String]): Unit = { println(pipeline("8")) println(pipeline("7")) println(pipeline("-2")) println(pipeline("nope")) } }
This is the payoff for the abstraction. The for block is structurally identical to the Option one on the previous row, but every failure now carries a typed message out with it, and the happy path still reads as three sequential lines. Kotlin's Result cannot do this: its error type is fixed to Throwable, it has no flatMap in the standard library, and there is no comprehension to chain it with — so the idiomatic Kotlin is the early-return ladder in the left column, which works but does not compose (you cannot pass that pipeline around as a value, or run a list of them and collect the failures). Arrow's Either and its either { } block exist precisely to import this pattern.
Futures & Concurrency
suspend becomes Future — and Future is eager
import kotlinx.coroutines.* suspend fun fetch(id: Int): String { delay(10) return "user-$id" } fun main() = runBlocking { println(fetch(1)) // sequential: plain code val results = coroutineScope { // concurrent: async + awaitAll (1..3).map { id -> async { fetch(id) } }.awaitAll() } println(results) }
import scala.concurrent.{Await, Future} import scala.concurrent.ExecutionContext.Implicits.global // an IMPLICIT parameter import scala.concurrent.duration._ object Main { // No 'suspend'. A Future starts running the moment it is constructed, and the // ExecutionContext it runs on is passed implicitly. def fetch(id: Int): Future[String] = Future { Thread.sleep(10) s"user-$id" } def main(args: Array[String]): Unit = { println(Await.result(fetch(1), 2.seconds)) // Already running by the time Future.sequence sees them — this is awaitAll. val results = Future.sequence((1 to 3).map(fetch)) println(Await.result(results, 2.seconds).toList) } }
Three assumptions to unlearn. A Future is eager: constructing it submits the work, so there is no async { } to write and also no way to describe a computation without starting it. Nothing owns it — there is no coroutineScope, no structured concurrency, and no cancellation (you can drop the result; the work runs on). And the thread pool arrives as an implicit ExecutionContext, which is why that odd import …Implicits.global line appears in every Scala tutorial: it is putting an implicit value in scope for every Future below it to pick up. What Scala gets in exchange is that Future is an ordinary monad, so it composes with map, flatMap, and the for-comprehension you already know.
The eager-Future trap in a for-comprehension
The same for syntax works over Future — which is elegant, and hides the most common performance bug in Scala.
import kotlinx.coroutines.* suspend fun fetch(id: Int): String { delay(50) return "user-$id" } fun main() = runBlocking { // Sequential — and it LOOKS sequential. No trap. val sequential = fetch(1) + " + " + fetch(2) println(sequential) // Parallel is explicit: you must say 'async'. val parallel = coroutineScope { val first = async { fetch(1) } val second = async { fetch(2) } first.await() + " + " + second.await() } println(parallel) }
import scala.concurrent.{Await, Future} import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.duration._ object Main { def fetch(id: Int): Future[String] = Future { Thread.sleep(50) s"user-$id" } def main(args: Array[String]): Unit = { // SEQUENTIAL, despite looking concurrent: fetch(2) is not constructed until // the flatMap from fetch(1) runs. This is the classic Scala performance bug. val sequential = for { first <- fetch(1) second <- fetch(2) } yield s"$first + $second" println(Await.result(sequential, 5.seconds)) // PARALLEL: construct both futures BEFORE the for-comprehension, so both are // already running when the flatMap chain starts. val firstFuture = fetch(1) val secondFuture = fetch(2) val parallel = for { first <- firstFuture second <- secondFuture } yield s"$first + $second" println(Await.result(parallel, 5.seconds)) } }
Both Scala blocks look the same and one of them is twice as slow. The for desugars to fetch(1).flatMap(first => fetch(2).map(…)), so fetch(2) is not even constructed until the first future completes — and because a Future starts on construction, that is the difference between running in parallel and running in sequence. The fix is to build the futures on their own lines first, which every Scala style guide teaches and every Scala codebase gets wrong somewhere. Kotlin cannot have this bug: a suspend call is sequential and looks it, and concurrency requires you to type async. Laziness is what makes the Scala version ambiguous, and it is the strongest argument in the language for effect systems like Cats Effect and ZIO, whose IO is lazy and therefore safe to sequence.
Errors: Try and Either
runCatching becomes Try
fun main() { val parsed: Result<Int> = runCatching { "nope".toInt() } println(parsed.isFailure) println(parsed.getOrElse { -1 }) println(parsed.exceptionOrNull()?.javaClass?.simpleName) parsed.fold( onSuccess = { println("ok $it") }, onFailure = { println("failed: ${it.message}") }, ) println("42".toIntOrNull() ?: -1) }
import scala.util.{Try, Success, Failure} object Main { def main(args: Array[String]): Unit = { val parsed: Try[Int] = Try("nope".toInt) println(parsed.isFailure) println(parsed.getOrElse(-1)) // Try is Success(value) | Failure(throwable) — an ordinary sealed type, so // it pattern-matches like anything else. parsed match { case Success(value) => println(s"ok $value") case Failure(error) => println(s"failed: ${error.getMessage}") } // And it has map/flatMap, so it chains and composes. println(Try("21").map(_.toInt).map(_ * 2).getOrElse(-1)) println("42".toIntOption.getOrElse(-1)) // 2.13: parse without throwing } }
Try is Kotlin's Result with two additions that matter: it is a sealed type, so Success and Failure pattern-match and destructure like any other case class, and it has flatMap, so it works in a for-comprehension and composes with other fallible steps. Kotlin's Result deliberately does not — it cannot be a function's return type without a compiler flag, and it has no comprehension to chain it in. Both carry a Throwable rather than a typed error, which is why Either (previous section) is what you reach for when the failure is part of your domain rather than an exception.
catch is a pattern match
class NotFoundException(val id: Int) : Exception("no such id: $id") fun find(id: Int): String { if (id != 1) throw NotFoundException(id) return "Ada" } fun main() { // try IS an expression in Kotlin — one of the places it beats Java. val name = try { find(99) } catch (error: NotFoundException) { "(missing #${error.id})" } finally { println("done") } println(name) }
case class NotFoundException(id: Int) extends Exception(s"no such id: $id") object Main { def find(id: Int): String = { if (id != 1) throw NotFoundException(id) "Ada" } def main(args: Array[String]): Unit = { // try is an expression here too — and 'catch' takes a block of CASES, so it // is the same pattern language as everywhere else: guards, destructuring, all of it. val name = try { find(99) } catch { case NotFoundException(id) if id > 50 => s"(missing, high id #$id)" case NotFoundException(id) => s"(missing #$id)" case error: IllegalArgumentException => s"(bad argument: ${error.getMessage})" } finally { println("done") } println(name) } }
Kotlin already made try an expression, so the shape carries over. What changes is catch: it takes a block of case clauses, which means the full pattern language applies — you can destructure the exception, guard on its contents, and match several types in one clause. Neither language has checked exceptions. The idiomatic advice in Scala is nevertheless to use Try or Either at your own boundaries and reserve throw for genuinely exceptional conditions and for talking to Java libraries, since an exception is invisible in a type signature and cannot be composed.