PONY λ M2 Modula-2

Elixir.CodeCompared.To/Kotlin

An interactive executable cheatsheet comparing Elixir and Kotlin

Elixir 1.17 Kotlin 2.4
Hello World & Basics
Hello, World
IO.puts("Hello, World!")
fun main() { println("Hello, World!") }
Execution starts at a required fun main() — there is no script-style top-level code the way elixir file.exs runs it. println plays IO.puts, and curly braces replace do…end.
Rebinding → real mutation (and val)
count = 1 count = count + 1 # rebinds the name; the DATA is immutable IO.puts(count)
fun main() { val fixed = 10 // read-only binding — the good habit var count = 1 // genuinely mutable storage count += 1 println("$fixed $count") }
Elixir rebinding never mutates anything; Kotlin’s var is real mutable storage, visible to every reference. The discipline inverts: immutability stops being the physics of the language and becomes a habit — default to val, reach for var only when mutation is the point.
#{} → $ templates
name = "Ada" count = 3 IO.puts("#{name} has #{count} items (#{count * 2} shoes)")
fun main() { val name = "Ada" val count = 3 println("$name has $count items (${count * 2} shoes)") }
Interpolation transfers almost unchanged: a bare identifier is $name, any real expression takes braces as ${count * 2}. Triple-quoted raw strings exist too ("""…""", with trimIndent() for the heredoc feel).
Static Types & Null Safety
Dynamic → static (with inference)
# Types are checked when the code RUNS: add = fn first, second -> first + second end IO.puts(add.(40, 2)) # add.(40, "two") would crash at runtime with badarith
fun add(first: Int, second: Int): Int = first + second fun main() { println(add(40, 2)) // add(40, "two") // does not COMPILE — caught before running }
Parameter and return types are declared (annotation position is name: Type), and the mistakes Dialyzer might flag later refuse to compile now. Inference keeps bodies terse — val declarations rarely need a type — and the single-expression = expr body echoes do:.
nil → null, with teeth
inventory = %{apples: 5} quantity = inventory[:pears] # nil, silently IO.inspect(quantity) IO.puts(quantity || 0) # truthiness covers for it
fun main() { val inventory = mapOf("apples" to 5) val quantity: Int? = inventory["pears"] // Int? is a distinct TYPE println(quantity) println(quantity ?: 0) // Elvis — but no truthiness anywhere // quantity + 1 // does not compile without a null check }
Kotlin’s null is Elixir’s nil put under compiler supervision: Int? and Int are different types, dereferencing a nullable without a check refuses to compile, and ?./?: do the safe plumbing. One habit to drop: there is no truthiness — conditions must be Boolean, so quantity || 0 becomes the explicit ?: 0.
Atoms → enums & objects
status = :shipped IO.puts(status == :shipped) IO.inspect({:error, :timeout})
enum class Status { PENDING, SHIPPED } fun main() { val status = Status.SHIPPED println(status == Status.SHIPPED) println(Status.entries) // the closed set, known to the compiler }
Ad-hoc atoms become declared enum class constants — a closed, typo-proof set (compare :shippde silently minting a new atom). For atom-tagged payloads like {:error, :timeout}, the fuller answer is a sealed hierarchy (see Sealed Types).
OOP Returns
Modules of functions → methods on objects
defmodule Rectangle do defstruct [:width, :height] def area(%Rectangle{width: width, height: height}) do width * height end end rectangle = %Rectangle{width: 3, height: 4} IO.puts(Rectangle.area(rectangle))
class Rectangle(val width: Int, val height: Int) { fun area(): Int = width * height } fun main() { val rectangle = Rectangle(3, 4) println(rectangle.area()) }
The struct and its module of functions fuse into one class: state and behavior live together, and Rectangle.area(rectangle) becomes rectangle.area() — the data is the receiver, not the first argument. val parameters in the constructor header declare real properties in one stroke.
defstruct → data class
defmodule Person do defstruct [:name, :age] end original = %Person{name: "Ada", age: 36} older = %{original | age: 37} IO.inspect(original) IO.inspect(older) IO.puts(original == %Person{name: "Ada", age: 36})
data class Person(val name: String, val age: Int) fun main() { val original = Person("Ada", 36) val older = original.copy(age = 37) println(original) println(older) println(original == Person("Ada", 36)) // structural equality }
data class is the struct with the conveniences generated: copy(age = 37) is the update syntax %{original | age: 37}, toString prints like IO.inspect, and == compares structurally (as Elixir’s always does — a rhyme, since Kotlin’s == calls equals on every type).
Function heads & guards → one body
defmodule Classify do def call(0), do: "zero" def call(number) when number < 0, do: "negative" def call(_number), do: "positive" end IO.puts(Classify.call(0)) IO.puts(Classify.call(-5)) IO.puts(Classify.call(9))
fun classify(number: Int): String = when { number == 0 -> "zero" number < 0 -> "negative" else -> "positive" } fun main() { println(classify(0)) println(classify(-5)) println(classify(9)) }
Multi-clause definitions and guards collapse into a single body — the subject-less when is the closest shape, a cond-like expression. Dispatch-by-argument-shape does not exist; overloading dispatches on types only, at compile time.
Extension functions
# Elixir modules are closed; "adding" behavior means a new module: defmodule StringExtras do def shout(text), do: String.upcase(text) <> "!" end IO.puts(StringExtras.shout("hello"))
fun String.shout(): String = this.uppercase() + "!" fun main() { println("hello".shout()) // reads like a built-in method }
fun String.shout() attaches a function to an existing type — call sites read like the method always existed, without reopening or owning the class. It is Kotlin’s answer to the ergonomic itch that Elixir’s protocols and Ruby’s open classes each scratch differently — statically resolved, no monkey-patching.
Collections
Enum.map(list, fn) → list.map { }
numbers = [1, 2, 3, 4, 5, 6] result = numbers |> Enum.filter(fn number -> rem(number, 2) == 0 end) |> Enum.map(fn number -> number * 10 end) |> Enum.sum() IO.puts(result)
fun main() { val numbers = listOf(1, 2, 3, 4, 5, 6) val result = numbers .filter { number -> number % 2 == 0 } .map { number -> number * 10 } .sum() println(result) }
The Enum vocabulary lives directly on the collections as extension functions, so the pipeline needs no pipe — each call’s result is the next call’s receiver. The trailing lambda sits outside the parentheses, and a single parameter can be the implicit it (.map { it * 10 }).
Read-only by default — a familiar stance
numbers = [1, 2, 3] # There is no way to mutate this list — only build new ones: more = numbers ++ [4] IO.inspect(numbers) IO.inspect(more)
fun main() { val numbers = listOf(1, 2, 3) // read-only interface // numbers.add(4) // does not compile val more = numbers + 4 // build a new list instead println(numbers) println(more) val editable = mutableListOf(1, 2, 3) // mutability is the opt-in editable.add(4) println(editable) }
A pleasant surprise from the BEAM’s side: Kotlin’s default listOf has no mutating members, and list + element builds a new list — the Elixir instinct works. The honest caveat: read-only is an interface, not deep immutability — mutableListOf is one call away, and shared mutable collections are what the concurrency section has to lock.
Stream → asSequence
1..1_000_000 |> Stream.filter(fn number -> rem(number, 7) == 0 end) |> Stream.map(fn number -> number * 2 end) |> Enum.take(3) |> IO.inspect()
fun main() { val firstThree = (1..1_000_000).asSequence() .filter { number -> number % 7 == 0 } .map { number -> number * 2 } .take(3) .toList() println(firstThree) }
The eager/lazy split maps exactly: list operators are Enum (each step materializes), asSequence() is Stream (deferred, element-at-a-time), and a terminal operation like toList() plays the Enum.take/2 that forces the pipeline.
Maps
stock = %{"apples" => 5, "pears" => 2} updated = Map.put(stock, "plums", 7) for {fruit, quantity} <- Enum.sort(updated) do IO.puts("#{fruit}: #{quantity}") end
fun main() { val stock = mutableMapOf("apples" to 5, "pears" to 2) stock["plums"] = 7 for ((fruit, quantity) in stock) { println("$fruit: $quantity") } }
The infix to builds pairs, destructuring works in the loop head, and — unlike Elixir’s unordered maps past 32 keys — mutableMapOf is insertion-ordered, so no sort is needed for deterministic iteration. Lookups return nullable types, wiring straight into the null-safety operators.
Pattern Matching, Downgraded
when is not a pattern match
response = {:ok, 42} case response do {:ok, value} -> IO.puts("got #{value}") # destructures in the pattern {:error, reason} -> IO.puts("failed: #{reason}") end
fun main() { val response: Pair<String, Int> = "ok" to 42 // when cannot destructure — test first, take apart separately: when (response.first) { "ok" -> { val (_, value) = response println("got $value") } else -> println("failed") } }
The single biggest downgrade from Elixir: when matches values, types, and ranges — it cannot destructure a shape and bind its parts in one motion. Deconstruction is a separate statement. The idiomatic recovery is to model the shapes as a sealed hierarchy, where is-checks plus smart casts get most of the way back (next section).
Destructuring: shallow, positional
{name, age} = {"Ada", 36} IO.puts("#{name}, #{age}") [first | rest] = [1, 2, 3, 4] IO.inspect({first, rest})
data class Person(val name: String, val age: Int) fun main() { val (name, age) = Person("Ada", 36) // componentN(), positional println("$name, $age") val numbers = listOf(1, 2, 3, 4) val first = numbers.first() // no [head | tail] pattern val rest = numbers.drop(1) println("$first $rest") }
Destructuring exists but is shallow and positional — generated componentN() functions on data classes and pairs, no nesting, and nothing like [head | tail] (that becomes first()/drop(1)). It also never fails: there is no match-or-crash assertion anywhere.
Smart casts — the static consolation
# In dynamic Elixir, "what shape is this?" is answered by # matching on it: describe = fn value when is_integer(value) -> "integer: #{value}" value when is_binary(value) -> "string of #{String.length(value)}" end IO.puts(describe.(42)) IO.puts(describe.("hello"))
fun describe(value: Any): String = when (value) { is Int -> "integer: $value" // value IS an Int here is String -> "string of ${value.length}" // and a String here else -> "something else" } fun main() { println(describe(42)) println(describe("hello")) }
is checks in a when smart-cast the subject inside each branch — value.length compiles because the compiler has proven value is a String there. It is the static twin of guard-based clause dispatch: the test and the narrowed use fuse, with the compiler doing the bookkeeping.
Tagged Tuples → Sealed Types
{:ok, _} | {:error, _} → sealed
parse = fn text -> case Integer.parse(text) do {value, ""} -> {:ok, value} _other -> {:error, "not a number: #{text}"} end end case parse.("42") do {:ok, value} -> IO.puts("parsed #{value}") {:error, reason} -> IO.puts(reason) end
sealed interface ParseOutcome data class Parsed(val value: Int) : ParseOutcome data class Failed(val reason: String) : ParseOutcome fun parse(text: String): ParseOutcome = text.toIntOrNull()?.let { value -> Parsed(value) } ?: Failed("not a number: $text") fun main() { when (val outcome = parse("42")) { // exhaustive — no else needed is Parsed -> println("parsed ${outcome.value}") is Failed -> println(outcome.reason) } }
The sealed hierarchy is the tagged-tuple convention with the compiler enrolled: every variant is known, so when over a sealed type is checked for exhaustiveness — adding a third outcome breaks this code at compile time, where a new tuple tag in Elixir surfaces as a runtime CaseClauseError. Smart casts make outcome.value legal per branch.
Result & runCatching
result = try do {:ok, String.to_integer("42")} rescue ArgumentError -> {:error, :not_a_number} end IO.inspect(result)
fun main() { val outcome = runCatching { "42".toInt() } println(outcome.getOrDefault(0)) println(runCatching { "many".toInt() }.isFailure) }
runCatching wraps an exception-throwing computation into a Result — the exceptions-to-values adapter Elixir writes by hand as try-then-tag. getOrDefault, getOrNull, map, and recover then chain over it, tagged-tuple style.
No Pipe — Chains & Scopes
The pipeline without |>
"hello elixir world" |> String.split(" ") |> Enum.map(&String.capitalize/1) |> Enum.join(" ") |> IO.puts()
fun main() { val result = "hello kotlin world" .split(" ") .joinToString(" ") { word -> word.replaceFirstChar { letter -> letter.uppercase() } } println(result) }
Methods-on-receivers make the pipe unnecessary for the standard library — every call returns the next receiver. Note joinToString folding the map step into its trailing lambda, a very Kotlin economy. (There is no String.capitalize anymore; replaceFirstChar is the blessed spelling.)
let — the one-step pipe
# Piping a value into arbitrary code is just |> : 21 |> then(fn number -> number * 2 end) |> IO.puts()
fun main() { val answer = 21.let { number -> number * 2 } println(answer) val message = StringBuilder().apply { append("configured ") append("in place") }.toString() println(message) }
.let { } pipes a value into a block — exactly Elixir’s then/2 — and its siblings cover adjacent shapes: apply (configure and return the receiver), also (side effect), run/with. Combined with ?.let, they also carry the nil-guarding idiom.
Exceptions Are the Norm
Tagged tuples → try/catch
defmodule Withdrawal do def call(balance, amount) when amount > balance do {:error, :insufficient_funds} end def call(balance, amount), do: {:ok, balance - amount} end case Withdrawal.call(100, 250) do {:ok, remaining} -> IO.puts("remaining #{remaining}") {:error, reason} -> IO.puts("declined: #{reason}") end
class InsufficientFunds : IllegalStateException("insufficient funds") fun withdraw(balance: Int, amount: Int): Int { if (amount > balance) throw InsufficientFunds() return balance - amount } fun main() { try { println("remaining ${withdraw(100, 250)}") } catch (declined: InsufficientFunds) { println("declined: ${declined.message}") } }
The convention inverts: mainstream Kotlin signals failure by throwing, not by returning a tagged value — try/catch is the everyday shape (and an expression, so it can produce a value). All exceptions are unchecked; sealed results and runCatching exist for the value-oriented style when a team opts into it.
"Let it crash" has no supervisor
The Elixir cell spells supervision out by hand: Process.monitor turns another process’s death into an ordinary message naming the reason, and the handler starts a replacement — which is precisely the loop Supervisor automates from a child spec. It is written that way rather than with Supervisor.start_link so the mechanism is visible; in real code you would write the supervisor and never see this loop.
start_steady = fn -> spawn(fn -> receive do after 5000 -> :ok end end) end first = start_steady.() reference = Process.monitor(first) Process.exit(first, :kill) receive do {:DOWN, ^reference, :process, ^first, reason} -> second = start_steady.() IO.puts("child died (#{inspect(reason)}) — restarted: #{second != first}") end
import kotlinx.coroutines.* fun main() = runBlocking { val handler = CoroutineExceptionHandler { _, failure -> println("child failed: ${failure.message} — logged, NOT restarted") } supervisorScope { val fragile = launch(handler) { throw IllegalStateException("boom") } fragile.join() println("siblings continue; the failed work is simply gone") } }
Kotlin’s supervisorScope borrows OTP’s word but only half its meaning: it stops a child’s failure from canceling siblings — nothing restarts. There is no restart strategy, no known-good init state to return to; recovery is hand-written catch-and-retry. The BEAM’s crash-and-restart availability model is the genuine loss on this page.
The Concurrency Inversion
Isolated heaps → shared memory (locks return)
# A process's state can ONLY be reached by message: counter = spawn(fn -> receive do {:add, amount, caller} -> send(caller, {:total, amount}) end end) send(counter, {:add, 5, self()}) receive do {:total, total} -> IO.puts("total #{total}") end
import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock fun main() = runBlocking { var total = 0 val lock = Mutex() val workers = (1..4).map { launch(Dispatchers.Default) { lock.withLock { total += 5 } // shared memory needs a LOCK } } workers.joinAll() println("total $total") }
The inversion at the heart of this pairing: coroutines are cheap like processes, but they share one heap — four coroutines incrementing total is a data race unless a Mutex serializes them, and nothing in the language makes you use it. The bug class the BEAM structurally deleted is back, managed by discipline.
Preemptive → cooperative scheduling
# BEAM scheduling is preemptive: a busy process cannot starve # the others — the scheduler swaps it out mid-loop. busy = spawn(fn -> Enum.each(1..50_000, fn _step -> :ok end) end) IO.puts("other processes proceed regardless") IO.puts(is_pid(busy))
import kotlinx.coroutines.* fun main() = runBlocking { // Cooperative scheduling: a coroutine yields only at suspension // points. In a tight loop, you insert them by hand: val busy = launch { repeat(3) { step -> println("busy $step") yield() // without this, the sibling waits for the loop } } val polite = launch { repeat(3) { step -> println("polite $step") yield() } } joinAll(busy, polite) }
BEAM processes are preempted on a reduction budget — a hot loop cannot hog a scheduler. Coroutines are cooperative: they switch only at suspension points (delay, await, an explicit yield()), so CPU-bound work parks on Dispatchers.Default and long loops learn to yield. The interleaved output above exists only because of those yield() calls.
Mailboxes → channels (FIFO only)
worker = spawn(fn -> receive do {:urgent, message} -> IO.puts("urgent first: #{message}") end receive do {:normal, message} -> IO.puts("then: #{message}") end end) send(worker, {:normal, "routine report"}) send(worker, {:urgent, "fire!"}) # Selective receive: the worker plucks :urgent from the mailbox # even though :normal arrived first. receive do after 100 -> :ok end
import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel fun main() = runBlocking { val jobs = Channel<String>() val worker = launch { for (message in jobs) { // strictly FIFO — arrival order println("received: $message") } } jobs.send("routine report") jobs.send("urgent!") jobs.close() worker.join() }
A Channel looks like a mailbox but differs twice over: it is strictly FIFO — there is no selective receive that pattern-matches into the queue and defers the rest — and it is a shared object any coroutine may read, not a private inbox owned by one process. Prioritization means multiple channels plus select.
Supervision trees → structured scopes
# Links tie fates together; trap_exit turns a crash into a message: parent = self() spawn(fn -> Process.flag(:trap_exit, true) child = spawn_link(fn -> exit(:boom) end) receive do {:EXIT, ^child, reason} -> send(parent, {:noticed, reason}) end end) receive do {:noticed, reason} -> IO.puts("child exited: #{inspect(reason)}") end
import kotlinx.coroutines.* fun main() = runBlocking { // A scope OWNS its children: cancel the scope, cancel them all; // runBlocking cannot exit while children run. val parent = launch { val child = launch { delay(10_000) // never finishes on its own } delay(50) child.cancel() // cancellation flows DOWN the tree child.join() println("child canceled: ${child.isCancelled}") } parent.join() }
Structured concurrency is supervision’s cousin with half the toolkit: scopes own children, cancellation flows down, and orphans are impossible — genuinely OTP-ish structure. What is missing is the recovery half: links/monitors become parent-child Jobs, but trap_exit-then-restart has no counterpart — a canceled or failed child stays gone.
Tooling
mix → Gradle
# mix.exs — project + deps + tasks in one Elixir file defp deps do [ {:jason, "~> 1.4"}, {:req, "~> 0.5"} ] end # $ mix deps.get && mix test
// build.gradle.kts — Kotlin DSL for the build dependencies { implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.1") } // $ ./gradlew build && ./gradlew test
Gradle (with the Kotlin DSL) is the mix of the JVM world — dependency coordinates come from Maven Central rather than Hex, and the group:artifact:version triple replaces {:name, "~> version"}. The deeper inheritance matches too: Kotlin leans on Java’s ecosystem exactly the way Elixir leans on Erlang’s. Both cells are configuration fragments, shown display-only.