Basics: No Classes, No Mutation
Hello, World
fun main() {
println("Hello, World!")
} IO.puts("Hello, World!")
# The file body IS the program: no main, no class, no entry point to declare.
IO.puts("Elixir #{System.version()}") No
main, no class, no braces, and no semicolons — the file body is the program. IO.puts prints a string; IO.inspect prints any value in its literal form (and returns it, so it drops into the middle of a pipeline without disturbing it), which is the one you will actually use for debugging. String interpolation is #{…}, which is the Ruby heritage showing — José Valim came from Rails, and the surface of the language is deliberately friendly. What is underneath it is not Ruby at all.Modules and functions, not classes and methods
There are no objects. A module is a namespace for functions, and data is passed through them — it does not live in them.
class Greeter(private val salutation: String) {
fun greet(name: String) = "$salutation, $name!"
}
fun main() {
val greeter = Greeter("Hello")
println(greeter.greet("Ada"))
} defmodule Greeter do
# A function, not a method. The data it works on is an ARGUMENT — there is no
# 'this', no instance, and no state hiding inside the module.
def greet(salutation, name) do
"#{salutation}, #{name}!"
end
# def ... do: is the one-liner form (Kotlin's expression body).
def hello(name), do: greet("Hello", name)
end
IO.puts(Greeter.hello("Ada"))
IO.puts(Greeter.greet("Hi", "Alan")) A module holds functions and nothing else — no fields, no constructor, no instance. That is the single biggest structural change from Kotlin: you do not build an object that owns some state and then send it messages; you write functions that take data in and return new data out. Elixir's equivalent of "an object with state" is a process, which lives at the far end of this page, and it is a much bigger hammer than a class. Everything in between — the vast majority of your code — is plain functions over plain data.
Nothing can be mutated
fun main() {
val numbers = mutableListOf(1, 2, 3)
numbers.add(4) // the same list, changed
println(numbers)
val person = mutableMapOf<String, Any>("name" to "Ada")
person["age"] = 36 // the same map, changed
println(person)
var counter = 0
counter += 1 // the same variable, rebound
println(counter)
} numbers = [1, 2, 3]
longer = [0 | numbers] # a NEW list, sharing the tail with the old one
IO.inspect(numbers) # unchanged, forever
IO.inspect(longer)
person = %{name: "Ada"}
older = Map.put(person, :age, 36) # a new map
IO.inspect(person)
IO.inspect(older)
# Rebinding a name is allowed — but it does not change the old VALUE. Anyone
# still holding the previous list or map sees exactly what they saw before.
counter = 0
counter = counter + 1
IO.puts(counter) Data is immutable, full stop:
Map.put returns a new map, [0 | numbers] returns a new list sharing structure with the old. Rebinding a name is allowed (counter = counter + 1 is fine), which trips people who expect a purely functional language to forbid it — but the value the old name pointed at is untouched, so nobody else observes the change. This is what makes the concurrency model at the bottom of this page work: a value handed to another process cannot be changed by anyone, so there is no shared state to protect and no lock to take.Assignment Is Pattern Matching
= is not assignment
The idea that reshapes the whole language.
= is the match operator: it asserts that the left side has the shape of the right side, and binds any variables it finds along the way.fun main() {
val point = Pair(1, 2)
val (x, y) = point // destructuring, and only for componentN types
println("$x $y")
// There is no way to say "this MUST be a pair of (1, something)" and fail
// loudly otherwise — you write a check.
if (point.first != 1) throw IllegalStateException("expected 1")
println("matched")
} point = {1, 2}
{x, y} = point # a MATCH: the shapes line up, so x and y bind
IO.puts("#{x} #{y}")
# The left side can be a literal, and then it is an assertion.
{1, second} = point # matches, binds second
IO.puts(second)
# A failed match RAISES. This is a feature: it fails loudly, at the point where
# your assumption about the data broke.
try do
{9, _} = point
rescue
MatchError -> IO.puts("no match: the tuple did not start with 9")
end
# ^ pins a variable: match against its VALUE instead of rebinding it.
expected = 1
^expected = x
IO.puts("x really was #{expected}") Read
{1, second} = point as an assertion, not an assignment: it says "this had better be a two-tuple starting with 1", binds second, and raises a MatchError if it is not. That is why so much Elixir code has no defensive checks — the match is the check, and it fails at the exact line where the data stopped being what you thought. The pin operator ^ is the one piece of syntax to memorize: without it, expected = x rebinds expected; with it, ^expected = x compares. Everything else on this page — function heads, case, with, receive — is this one operator wearing a different hat.Destructuring goes as deep as the data
data class User(val name: String, val address: Address)
data class Address(val city: String)
fun main() {
val user = User("Ada", Address("London"))
// Nested destructuring is not supported: componentN is one level deep.
val (name, address) = user
val city = address.city
println("$name $city")
val numbers = listOf(1, 2, 3, 4)
println("${numbers.first()} ${numbers.drop(1)}") // no list pattern
} # Maps, lists, tuples, structs — patterns nest to any depth, and you match only
# the keys you care about.
user = %{name: "Ada", address: %{city: "London", zip: "N1"}}
%{name: name, address: %{city: city}} = user
IO.puts("#{name} #{city}")
# [head | tail] is THE list pattern, and it is how list recursion is written.
[first | rest] = [1, 2, 3, 4]
IO.puts(first)
IO.inspect(rest)
# _ discards. A leading underscore names it for the reader without binding it.
[_first, second | _rest] = [1, 2, 3, 4]
IO.puts(second)
# And it works in a for-comprehension, a function head, anywhere a name binds.
for %{name: person_name} <- [%{name: "Ada"}, %{name: "Alan"}] do
IO.puts(person_name)
end A map pattern matches a subset of the keys —
%{name: name} = user succeeds even though user has other keys — which makes it the natural way to accept a "record with at least these fields", and it is why so little Elixir code needs a declared type. Lists match with [head | tail], and that pattern is the whole of list recursion. Kotlin's destructuring is positional, one level deep, and only works where componentN exists, so the equivalent is a chain of property accesses. The same pattern language works in every binding position here: =, function heads, case, for, receive.Atoms, Tuples & Maps
Atoms — and nil is one of them
enum class Status { ACTIVE, PAUSED }
fun main() {
val status = Status.ACTIVE
println(status)
println(status == Status.valueOf("ACTIVE"))
// Null safety is in the TYPE: String? and String are different types.
val missing: String? = null
println(missing?.length ?: "(none)")
} # An atom is a constant whose name is its value. No declaration, no enum.
status = :active
IO.inspect(status)
IO.inspect(status == :active)
# true, false and nil ARE atoms — nil is not a special null, just :nil.
IO.inspect(is_atom(nil))
IO.inspect(is_atom(true))
# There is no null safety, because there is no type system to put it in. What
# there is instead: nil is a value you MATCH on.
missing = nil
result =
case missing do
nil -> "(none)"
text -> String.length(text)
end
IO.inspect(result)
# Only nil and false are falsy. 0 and "" are truthy.
IO.inspect(if 0, do: "zero is truthy") Atoms do the job Kotlin gives to enums and (badly) to strings: a name that is a value, interned, compared by identity, needing no declaration.
nil, true and false are themselves atoms, which means nil is an ordinary value rather than a hole in the type system — and since there is no type system, you get no null safety, no ?., and no compiler telling you a value might be absent. What you get instead is that nil is matchable: functions dispatch on it, case branches on it, and the {:ok, value} | {:error, reason} convention in the next concept means most functions never return a bare nil in the first place.{:ok, value} and {:error, reason}
The convention that replaces both nullable types and exceptions. It is not a language feature — it is a tuple — and the entire ecosystem agrees on it.
fun findUser(id: Int): String? =
if (id == 1) "Ada" else null
fun main() {
val user = findUser(1)
println(user?.uppercase() ?: "(missing)")
// Kotlin's alternatives: a nullable type (no reason for the failure),
// Result (carries a Throwable), or an exception (invisible in the signature).
val parsed = runCatching { "nope".toInt() }
println(parsed.getOrElse { -1 })
} defmodule Users do
def find(1), do: {:ok, "Ada"}
def find(id), do: {:error, {:not_found, id}}
end
# The caller MATCHES on the shape, so both paths are visible and neither can be
# forgotten — an unmatched shape raises rather than sliding through as nil.
case Users.find(1) do
{:ok, name} -> IO.puts(String.upcase(name))
{:error, reason} -> IO.inspect(reason)
end
case Users.find(99) do
{:ok, name} -> IO.puts(name)
{:error, {:not_found, id}} -> IO.puts("no user ##{id}")
end
# The standard library speaks it too, and offers a ! variant that raises instead.
IO.inspect(Integer.parse("42"))
IO.inspect(Integer.parse("nope"))
IO.inspect(Map.fetch(%{a: 1}, :a))
IO.inspect(Map.fetch(%{a: 1}, :b)) The tagged tuple carries what a nullable type cannot: why it failed, in as much structured detail as you like (
{:error, {:not_found, id}}), with no exception class to declare and no Throwable to unwrap. Because the caller matches on the shape, a forgotten error branch is a CaseClauseError at that line rather than a nil that flows onward and explodes somewhere else — which is the failure mode Kotlin's ? exists to prevent, reached by an entirely different road. The ! convention is the escape hatch: Map.fetch/2 returns a tuple, Map.fetch!/2 raises, and the bang in the name is the whole documentation.Maps, keyword lists, and the update syntax
fun main() {
val person = mapOf("name" to "Ada", "age" to 36)
println(person["name"])
println(person["missing"]) // null
val older = person + ("age" to 37) // a new map
println(older)
// Named arguments are a language feature, unrelated to maps.
fun connect(host: String, port: Int = 80, secure: Boolean = false) =
"$host:$port secure=$secure"
println(connect("example.com", secure = true))
} person = %{name: "Ada", age: 36}
IO.inspect(person.name) # atom keys get dot access
IO.inspect(person[:name]) # and bracket access
IO.inspect(person[:missing]) # nil — no error
IO.inspect(Map.get(person, :missing, "(default)"))
# The update syntax: | means "this map, but with these keys changed". It REQUIRES
# the keys to already exist, which catches typos that Map.put would not.
older = %{person | age: 37}
IO.inspect(older)
IO.inspect(person) # unchanged
# Nested updates in one call.
nested = %{user: %{name: "Ada", age: 36}}
IO.inspect(put_in(nested, [:user, :age], 37))
IO.inspect(get_in(nested, [:user, :name]))
# A keyword list is a list of {atom, value} pairs — this is how options are
# passed, and it is what makes the last argument look like named parameters.
defmodule Connection do
def connect(host, options \\ []) do
port = Keyword.get(options, :port, 80)
secure = Keyword.get(options, :secure, false)
"#{host}:#{port} secure=#{secure}"
end
end
IO.puts(Connection.connect("example.com", secure: true)) Two collection types where Kotlin has one, and the distinction matters. A map is the hash map: unique keys, O(log n) access, and the
%{map | key: value} update syntax, which — unlike Map.put — requires the key to already exist and so catches a misspelled field at run time. A keyword list is an ordered list of {atom, value} pairs that allows duplicates, and its only real job is to be the last argument of a function: connect("example.com", secure: true) is a keyword list with the brackets omitted, which is how Elixir gets something that reads exactly like Kotlin's named arguments with defaults.The Pipe, Enum & Stream
The pipe replaces the dot chain
fun main() {
val names = listOf("Ada", "Grace", "Alan", "Barbara")
val result = names
.filter { it.length > 3 }
.map { it.uppercase() }
.sorted()
.joinToString(", ")
println(result)
} names = ["Ada", "Grace", "Alan", "Barbara"]
# |> passes the value on the left as the FIRST argument of the call on the right.
# Every Elixir function takes its data first, precisely so this works.
result =
names
|> Enum.filter(fn name -> String.length(name) > 3 end)
|> Enum.map(&String.upcase/1) # & captures a function by name/arity
|> Enum.sort()
|> Enum.join(", ")
IO.puts(result)
# IO.inspect returns its argument, so it drops into a pipeline to debug it.
[1, 2, 3]
|> Enum.map(&(&1 * 2)) # &1 is the first argument — Kotlin's 'it'
|> IO.inspect(label: "doubled")
|> Enum.sum()
|> IO.inspect(label: "sum") The pipe is Kotlin's dot chain with the receiver moved outside the function:
x |> f(y) is f(x, y). That is why every function in the standard library takes its subject as the first argument — the convention exists to make the pipe work, and your own functions must follow it or they will not compose. The capture syntax is the other half: &String.upcase/1 passes a named function (the /1 is its arity, which is part of its identity here), and &(&1 * 2) is the short lambda where &1 plays the role of Kotlin's it. Slipping IO.inspect(label: …) into the middle of a pipe is the debugging idiom you will use forever.Enum: one module for every collection
fun main() {
val names = listOf("Ada", "Grace", "Alan")
println(names.map { it.length })
println(names.sumOf { it.length })
println(names.groupBy { it.length })
println(names.partition { it.startsWith("A") })
println(names.zip(1..3))
println(names.associateWith { it.length })
println(listOf(1, 2, 3).fold(0) { total, number -> total + number })
} names = ["Ada", "Grace", "Alan"]
IO.inspect(Enum.map(names, &String.length/1))
IO.inspect(names |> Enum.map(&String.length/1) |> Enum.sum())
IO.inspect(Enum.group_by(names, &String.length/1))
IO.inspect(Enum.split_with(names, &String.starts_with?(&1, "A")))
IO.inspect(Enum.zip(names, 1..3))
IO.inspect(Map.new(names, fn name -> {name, String.length(name)} end))
IO.inspect(Enum.reduce([1, 2, 3], 0, fn number, total -> total + number end))
# A comprehension: for is a generator with filters, not a loop.
IO.inspect(for name <- names, String.length(name) > 3, do: String.upcase(name))
# Enum works on ANY Enumerable — lists, ranges, maps, streams, your own structs.
IO.inspect(Enum.map(%{a: 1, b: 2}, fn {key, value} -> {key, value * 10} end))
IO.inspect(Enum.take(1..1000, 3)) The functions are all in one module rather than on the type, so the call is
Enum.map(collection, function) — and it works on anything implementing the Enumerable protocol: lists, ranges, maps, streams, and your own structs. The vocabulary maps over cleanly (fold is reduce, partition is split_with, associateWith is Map.new), with one caution worth internalizing: the accumulator comes after the element in Enum.reduce's function, which is the reverse of Kotlin's fold and will catch you at least once.Sequences become Streams
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())
} # Enum is EAGER (each step builds a list). Stream is LAZY — the same functions,
# fused into one pass, computed only when something forces them.
first_two =
[1, 2, 3, 4, 5]
|> Stream.each(fn number -> IO.puts("seeing #{number}") end)
|> Stream.map(fn number -> number * number end)
|> Enum.take(2) # an Enum call is what forces it
IO.inspect(first_two)
# Infinite streams are ordinary values.
IO.inspect(Stream.iterate(1, &(&1 * 2)) |> Enum.take(6))
IO.inspect(Stream.cycle([1, 2]) |> Enum.take(5)) Stream is asSequence() and Enum is the eager version — the split is exactly Kotlin's, down to the fact that a lazy pipeline does nothing until a terminal operation (any Enum call) forces it. The rule of thumb is the same too: reach for Stream when the collection is large, infinite, or expensive to produce (a file read line by line is the canonical case), and stay with Enum otherwise, because for a small list the laziness costs more than it saves.Functions & Guards
Multiple clauses replace when
A function is not one body with a branch inside it — it is a set of clauses, each with a pattern, tried in order. This is where the
when in your head goes.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 Circle -> 3.14159 * shape.radius * shape.radius
is Rectangle -> shape.width * shape.height
}
fun classify(number: Int): String = when {
number < 0 -> "negative"
number == 0 -> "zero"
else -> "positive"
}
fun main() {
println(area(Circle(1.0)))
println(area(Rectangle(2.0, 3.0)))
println(classify(-5))
} defmodule Shapes do
# One clause per shape. The pattern in the HEAD does the dispatch and the
# destructuring at once — there is no 'when' block inside the function.
def area(%{type: :circle, radius: radius}), do: 3.14159 * radius * radius
def area(%{type: :rectangle, width: width, height: height}), do: width * height
end
IO.inspect(Shapes.area(%{type: :circle, radius: 1.0}))
IO.inspect(Shapes.area(%{type: :rectangle, width: 2.0, height: 3.0}))
defmodule Numbers do
# 'when' in Elixir is a GUARD on a clause — a false friend worth learning early.
def classify(number) when number < 0, do: "negative"
def classify(0), do: "zero"
def classify(number) when number > 0, do: "positive"
# Recursion via clauses: the base case is its own clause, not an if.
def sum([]), do: 0
def sum([head | tail]), do: head + sum(tail)
end
IO.puts(Numbers.classify(-5))
IO.puts(Numbers.classify(0))
IO.inspect(Numbers.sum([1, 2, 3, 4])) Note the false friend: Elixir's
when is a guard attached to a clause, not Kotlin's multi-branch expression — the multi-branch job is done by writing several clauses. Guards are restricted to a fixed list of pure, fast checks (is_integer, comparisons, arithmetic), because the compiler needs to decide them without side effects; you cannot call your own function in one. The payoff is that dispatch, destructuring, and the base case of a recursion all live in the function head, which is why idiomatic Elixir has so few ifs. What you lose is exhaustiveness: nothing warns you that no clause matches a shape, and the result is a FunctionClauseError at run time.Arity is part of the name
fun greet(name: String, salutation: String = "Hello") =
"$salutation, $name!"
fun main() {
println(greet("Ada"))
println(greet("Ada", "Hi"))
// A function reference: the name alone identifies it.
val greeter: (String) -> String = ::greet
println(greeter("Grace"))
} defmodule Greeter do
# \\ declares a default, and the compiler GENERATES the extra clause for it.
# This module therefore defines two functions: greet/1 and greet/2.
def greet(name, salutation \\ "Hello") do
"#{salutation}, #{name}!"
end
# defp is private — invisible outside the module.
defp shout(text), do: String.upcase(text)
def loud(name), do: shout(greet(name))
end
IO.puts(Greeter.greet("Ada"))
IO.puts(Greeter.greet("Ada", "Hi"))
IO.puts(Greeter.loud("Grace"))
# Capturing a function requires its ARITY: greet/1 and greet/2 are different
# functions that happen to share a name.
greeter = &Greeter.greet/1
IO.puts(greeter.("Alan")) # note the dot: calling an anonymous function A function's identity is its name and its arity, written
greet/1 — so greet/1 and greet/2 are two different functions, and every error message, doc page and stack trace will refer to them that way. Default arguments (\\) are sugar that generates the extra clause. The other thing to get used to: calling an anonymous function needs a dot (greeter.("Alan")), because a variable holding a function is not the same kind of thing as a named function — a distinction Kotlin does not draw and Elixir cannot avoid, since a bare name is a function call with no parentheses.Control Flow
case, cond, and (rarely) if
fun describe(code: Int): String = when (code) {
200 -> "OK"
301, 302 -> "Redirect"
in 500..599 -> "Server Error"
else -> "Unknown"
}
fun main() {
println(describe(200))
println(describe(302))
println(describe(503))
} describe = fn code ->
case code do
200 -> "OK"
code when code in [301, 302] -> "Redirect"
code when code >= 500 and code <= 599 -> "Server Error"
_ -> "Unknown"
end
end
IO.puts(describe.(200))
IO.puts(describe.(302))
IO.puts(describe.(503))
# cond is the subject-less form: a list of conditions, first truthy one wins.
score = 87
grade =
cond do
score >= 90 -> "A"
score >= 80 -> "B"
true -> "C" # 'true' is the else branch
end
IO.puts(grade)
# if exists, is an expression, and is used far less than you would expect —
# a case or a function clause is usually the better tool.
IO.puts(if score > 50, do: "pass", else: "fail") Kotlin's one
when splits three ways: case matches a value against patterns (with guards), cond takes a list of conditions with no subject, and if is a two-branch expression that idiomatic Elixir mostly avoids in favor of function clauses. All three are expressions, so they return values. Two details: cond's else branch is spelled true, and an unmatched case raises a CaseClauseError rather than returning null — which is the recurring theme here, that failure is loud and immediate instead of silently absent.with: the happy path, in a straight line
A chain of steps, each of which can fail. Kotlin gives you early returns or nested lets; Elixir gives you one construct built out of pattern matching.
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 {
// The early-return ladder. It works — and it loses WHY it failed.
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("nope"))
} defmodule Pipeline do
defp parse(text) do
case Integer.parse(text) do
{number, ""} -> {:ok, number}
_ -> {:error, "not a number"}
end
end
defp check_positive(number) when number > 0, do: {:ok, number}
defp check_positive(_), do: {:error, "not positive"}
defp half(number) when rem(number, 2) == 0, do: {:ok, div(number, 2)}
defp half(_), do: {:error, "not even"}
# Each <- is a MATCH. If it matches, continue; if not, the whole 'with'
# returns that value — so the failure falls out of the chain with its reason.
def run(text) do
with {:ok, parsed} <- parse(text),
{:ok, positive} <- check_positive(parsed),
{:ok, halved} <- half(positive) do
"ok: #{halved}"
else
{:error, reason} -> reason
end
end
end
IO.puts(Pipeline.run("8"))
IO.puts(Pipeline.run("7"))
IO.puts(Pipeline.run("-2"))
IO.puts(Pipeline.run("nope")) with reads the happy path top to bottom and lets every failure fall out sideways, carrying its own reason — which is what the Kotlin ladder cannot do, since ?: can only produce a fallback, not propagate the cause. It is pattern matching all the way down: each <- is a match, and a mismatch short-circuits the whole block to the unmatched value (the else is optional — without it, with simply returns whatever failed). Kotlin's closest equivalents are an early-return ladder, a chain of nullable lets, or Arrow's either { } block, which exists to import exactly this shape.Structs Instead of Classes
data class becomes struct
data class Person(val name: String, val age: Int = 0) {
fun greeting() = "Hi, $name"
}
fun main() {
val ada = Person("Ada", 36)
println(ada)
println(ada.name)
println(ada.copy(age = 37))
println(ada == Person("Ada", 36))
println(ada.greeting()) // a method ON the object
} defmodule Person do
# A struct is a map with a fixed set of keys and a __struct__ tag.
defstruct name: nil, age: 0
# The "methods" are functions that take the struct as their first argument —
# so they pipe, and so they are not attached to it.
def greeting(%Person{name: name}), do: "Hi, #{name}"
end
ada = %Person{name: "Ada", age: 36}
IO.inspect(ada)
IO.puts(ada.name)
IO.inspect(%{ada | age: 37}) # the update syntax IS copy()
IO.inspect(ada == %Person{name: "Ada", age: 36}) # value equality, for free
IO.puts(Person.greeting(ada))
IO.puts(ada |> Person.greeting()) # and it pipes, which a method cannot
# A struct is still a map, so it pattern-matches — and the struct name in the
# pattern is a type check.
%Person{name: matched} = ada
IO.puts(matched) A struct gives you the data-class payoff — named fields, defaults, value equality, and
%{struct | field: value} as copy() — with one structural difference: the functions live beside the data, not on it, so you call Person.greeting(ada) rather than ada.greeting(). That looks like a downgrade until you notice it pipes. Two things a struct will not do: it cannot be missing a key (the set is fixed at compile time, which catches typos the plain-map version would not), and it enforces nothing about the types of those fields — %Person{name: 42} is perfectly legal.Interfaces become protocols
Protocols are Elixir's polymorphism: dispatch on the type of the first argument, implementable for types you do not own. (The in-browser runtime cannot consolidate user-defined protocols, so this row is display-only — the concepts are what matter here.)
interface Describable {
fun describe(): String
}
data class Dog(val name: String) : Describable {
override fun describe() = "$name is a dog"
}
// You cannot make Int implement Describable after the fact — the interface must
// be declared on the type. The workaround is an extension function, which is
// statically dispatched and therefore will NOT work through a List<Any>.
fun Int.describe() = "the number $this"
fun main() {
println(Dog("Rex").describe())
println(7.describe())
} defprotocol Describable do
def describe(value)
end
defmodule Dog do
defstruct name: nil
end
defimpl Describable, for: Dog do
def describe(dog), do: "#{dog.name} is a dog"
end
# Implement it for a type you do NOT own — Integer, BitString, List, anything.
defimpl Describable, for: Integer do
def describe(number), do: "the number #{number}"
end
defimpl Describable, for: BitString do
def describe(text), do: "the string #{text}"
end
# Dispatch happens at RUN time on the value's type, so this works through a
# heterogeneous list — which a Kotlin extension function cannot do.
for value <- [%Dog{name: "Rex"}, 7, "hello"] do
IO.puts(Describable.describe(value))
end A protocol is dispatched on the runtime type of its first argument, and
defimpl can implement it for any type — including Integer, List, and structs from libraries you did not write. That is the type-class-shaped feature Kotlin approximates with extension functions, and the gap shows in the last block: extension functions are resolved statically, so a Kotlin extension on Int is invisible when the value arrives as Any from a heterogeneous list, while the protocol simply works. The built-in protocols are the ones you will implement in practice — String.Chars (what to_string uses), Inspect (what IO.inspect uses), and Enumerable (what makes your type work with the whole Enum module).Errors: Tagged Tuples & Crashing
Exceptions exist, and are the wrong tool
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() {
// Exceptions are the main way to signal failure in Kotlin — and they are
// invisible in the signature, since there are no checked exceptions.
val name = try {
find(99)
} catch (error: NotFoundException) {
"(missing #${error.id})"
} finally {
println("done")
}
println(name)
} defmodule NotFoundError do
defexception [:id]
@impl true
def message(error), do: "no such id: #{error.id}"
end
defmodule Users do
# The ! suffix is the convention for "this raises".
def find!(1), do: "Ada"
def find!(id), do: raise(NotFoundError, id: id)
# And the ordinary version returns a tagged tuple instead.
def find(1), do: {:ok, "Ada"}
def find(id), do: {:error, {:not_found, id}}
end
name =
try do
Users.find!(99)
rescue
error in NotFoundError -> "(missing ##{error.id})"
after
IO.puts("done")
end
IO.puts(name)
# But this is the idiomatic version — no exception, no try, just a match.
case Users.find(99) do
{:ok, found} -> IO.puts(found)
{:error, reason} -> IO.inspect(reason)
end Elixir has exceptions (
raise/rescue/after, and defexception to declare one), and it would rather you did not use them for anything you expect to happen. Expected failure is a {:error, reason} tuple, because it is a value — it can be matched, piped, stored, and returned, and the compiler-free equivalent of a signature tells the caller it exists. An exception is reserved for the genuinely exceptional: a bug, a broken invariant, a disk that is not there. And here the philosophy turns strange to Kotlin eyes, because the standard response to an exceptional condition is not to catch it at all — see the next concept, and then the Supervision section, where crashing is the design."Let it crash" — and why that is not carelessness
import kotlinx.coroutines.*
suspend fun risky(number: Int): Int {
if (number == 2) throw IllegalStateException("boom on $number")
return number * number
}
fun main() = runBlocking {
// In Kotlin, an uncaught exception in a coroutine CANCELS ITS SCOPE — the
// sibling coroutines die with it. So you must defend, everywhere.
val results = coroutineScope {
(1..3).map { number ->
async {
try {
risky(number)
} catch (error: IllegalStateException) {
-1 // the defensive catch
}
}
}.awaitAll()
}
println(results)
} # A crash kills ONE process. Its heap is its own, so nothing else is corrupted
# and nothing else is even disturbed — the failure cannot spread on its own.
parent = self()
for number <- 1..3 do
spawn(fn ->
# No try/rescue. If this raises, this process dies. That is the plan.
result = if number == 2, do: raise("boom on #{number}"), else: number * number
send(parent, {:ok, number, result})
end)
end
# Collect what survived. The crashed one simply never reports.
survivors =
for _ <- 1..2 do
receive do
{:ok, number, result} -> {number, result}
after
500 -> :timeout
end
end
IO.inspect(Enum.sort(survivors))
IO.puts("the parent is still alive, and never wrote a rescue") The reason "let it crash" is not recklessness is isolation: a process has its own heap, so a crash cannot corrupt anyone else's data, and by default it takes nothing else down with it. Compare the Kotlin column, where an uncaught exception in an
async child cancels the whole coroutineScope — sibling work included — which is exactly why defensive try/catch ends up sprinkled through coroutine code. The Elixir position is that defensive code for unforeseen failures is a fantasy (you cannot catch a bug you did not anticipate), so the only robust answer is to let the failed thing die and restart it from a state you know is good. Which requires something watching. That is a supervisor, and it is the next section.Processes vs Coroutines
spawn is launch — with a different bargain
Both languages give you concurrency units far cheaper than an OS thread. The difference is what they share, and it is the whole story.
import kotlinx.coroutines.*
fun main() = runBlocking {
// Coroutines are cheap and SHARE MEMORY. That is a feature (passing data is
// free) and the source of every data race you will ever write.
val results = mutableListOf<Int>()
val lock = Any()
coroutineScope {
(1..3).forEach { number ->
launch(Dispatchers.Default) {
delay(10L * number)
synchronized(lock) { results.add(number * number) } // the lock is on you
}
}
}
println(results.sorted())
} # A process is cheap too (~300 bytes, millions per node) — but it shares NOTHING.
# Its heap is its own, it is garbage-collected on its own, and the only way in
# is a message. There is no lock, because there is nothing to lock.
parent = self()
for number <- 1..3 do
spawn(fn ->
Process.sleep(10 * number)
send(parent, {:result, number * number}) # a COPY of the value crosses over
end)
end
results =
for _ <- 1..3 do
receive do
{:result, value} -> value
after
1000 -> :timeout
end
end
IO.inspect(Enum.sort(results))
IO.inspect(self()) # every process has a PID, and that is its whole API A coroutine is a cheap unit of scheduling over shared memory; a process is a cheap unit of isolation. Nothing is shared, so a message send copies the value (cheap, because the value is immutable and small; and the copy is exactly what makes the isolation real), and there is no lock, no
@Volatile, no Mutex, and no shared-state bug to write. Two more differences with teeth. Scheduling is preemptive: the BEAM interrupts a process after a fixed budget of work, so a tight CPU loop cannot starve anything — where a Kotlin coroutine that never suspends will happily hog its thread, which is why Dispatchers.IO and yield() exist. And every process has a PID, which is its entire interface: no methods, no handle, just an address you send to.Mailboxes: receive matches, it does not queue
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*
fun main() = runBlocking {
// A Channel is a typed queue between coroutines. It is FIFO: you take what
// is next, and there is no way to say "give me the next message that looks
// like this" — selecting by shape means draining and re-queueing by hand.
val channel = Channel<Pair<String, Int>>()
launch {
channel.send("high" to 1)
channel.send("low" to 2)
channel.close()
}
for ((priority, value) in channel) {
println("$priority $value")
}
} # Every process HAS a mailbox — you do not create one. receive scans it with
# PATTERNS, and takes the first message that matches, not the first that arrived.
send(self(), {:low, 2})
send(self(), {:high, 1})
# This picks the :high message out of the middle of the queue.
receive do
{:high, value} -> IO.puts("high #{value}")
end
receive do
{:low, value} -> IO.puts("low #{value}")
end
# after is a timeout, and it is how you avoid blocking forever.
receive do
:nothing_will_send_this -> IO.puts("unreachable")
after
50 -> IO.puts("timed out, as designed")
end A mailbox is not a channel. It belongs to the process rather than sitting between two of them, it is untyped, and
receive selects by pattern — so a process can pull an urgent message out of the middle of its queue and leave the rest for later, which a FIFO Channel simply cannot do. That selectivity is the mechanism behind GenServer's call/cast split and the whole of OTP. The cost is the mirror image: because a mailbox is unbounded and untyped, a slow consumer grows it without limit (the classic BEAM production failure) and a message nobody matches sits there forever, where Kotlin's Channel<T> gives you a type, backpressure, and a close().State is a loop, not a field
How does an immutable language hold mutable state? It does not — it keeps the value in a recursive function's argument, and a process to run the recursion.
class Counter {
// A field, protected by nothing. Two coroutines touching this is a race, and
// the compiler will not say a word.
private var count = 0
fun increment(): Int {
count += 1
return count
}
fun value() = count
}
fun main() {
val counter = Counter()
counter.increment()
counter.increment()
println(counter.value())
} defmodule Counter do
def start(count \\ 0), do: spawn(fn -> loop(count) end)
# The state is the ARGUMENT. To "change" it, the loop calls itself with a new
# value — and because only this process can run this loop, the state can only
# be touched by one thing at a time. That is the mutual exclusion, for free.
defp loop(count) do
receive do
{:increment, caller} ->
send(caller, {:count, count + 1})
loop(count + 1)
{:value, caller} ->
send(caller, {:count, count})
loop(count)
end
end
end
counter = Counter.start(0)
send(counter, {:increment, self()})
receive do: ({:count, _} -> :ok)
send(counter, {:increment, self()})
receive do: ({:count, _} -> :ok)
send(counter, {:value, self()})
receive do: ({:count, value} -> IO.puts("count is #{value}")) The state lives in
loop/1's argument and in nothing else. It is reachable only by sending the process a message, and the process handles one message at a time — so mutual exclusion is not something you added, it is the shape of the thing. This is a Kotlin Mutex-protected field with the mutex removed and the possibility of forgetting it removed with it. The tail call in loop costs no stack, so a process can loop forever. And note how much boilerplate this is for a counter: send, receive, match, reply, recurse. That is exactly the boilerplate GenServer exists to delete.OTP: GenServer & Supervision
GenServer: the stateful process, packaged
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.*
class Counter {
private val mutex = Mutex()
private var count = 0
suspend fun increment(): Int = mutex.withLock {
count += 1
count
}
suspend fun value(): Int = mutex.withLock { count }
}
fun main() = runBlocking {
val counter = Counter()
coroutineScope {
repeat(3) { launch { counter.increment() } }
}
println(counter.value())
} defmodule CounterServer do
use GenServer
# ---- the client API: ordinary functions, run in the CALLER's process ----
def start_link(initial), do: GenServer.start_link(__MODULE__, initial)
def increment(server), do: GenServer.call(server, :increment)
def value(server), do: GenServer.call(server, :value)
def reset(server), do: GenServer.cast(server, :reset)
# ---- the server callbacks: run INSIDE the server process, one at a time ----
@impl true
def init(initial), do: {:ok, initial}
@impl true
def handle_call(:increment, _from, count), do: {:reply, count + 1, count + 1}
def handle_call(:value, _from, count), do: {:reply, count, count}
@impl true
def handle_cast(:reset, _count), do: {:noreply, 0}
end
{:ok, counter} = CounterServer.start_link(0)
CounterServer.increment(counter)
CounterServer.increment(counter)
CounterServer.increment(counter)
IO.puts(CounterServer.value(counter))
CounterServer.reset(counter)
IO.puts(CounterServer.value(counter)) A GenServer is the recursive loop from the previous concept with the plumbing generated:
call is a synchronous request that waits for a reply (with a five-second default timeout), cast is fire-and-forget, and the state is threaded through the handle_* callbacks as their last argument and returned as part of the result. The split to keep straight is which code runs where: increment/1 runs in the caller's process and merely sends a message, while handle_call/3 runs inside the server, serialized with every other message. That serialization is the lock in the Kotlin column — except that it cannot be forgotten, cannot deadlock against another lock you also took, and comes with supervision for free.Supervision trees replace structured concurrency
The payoff of the whole page. Kotlin's
coroutineScope makes a failure propagate — the parent waits, and one child's exception cancels its siblings. A supervisor makes failure recoverable: the child dies, and is replaced.import kotlinx.coroutines.*
fun main() = runBlocking {
// Structured concurrency: the scope owns its children, cancellation flows
// down, and an uncaught failure flows UP and kills the scope. There is no
// "restart this one child from a known-good state" — you rebuild it yourself,
// by hand, with your own retry loop and your own supervision policy.
val outcome = try {
coroutineScope {
launch { delay(20); println("healthy worker finished") }
launch { delay(10); throw IllegalStateException("worker crashed") }
}
"everything worked"
} catch (error: IllegalStateException) {
"scope died: ${error.message} — and the healthy sibling was canceled"
}
println(outcome)
} defmodule Worker do
use GenServer
def start_link(_), do: GenServer.start_link(__MODULE__, 0, name: __MODULE__)
def ping, do: GenServer.call(__MODULE__, :ping)
@impl true
def init(state), do: {:ok, state}
@impl true
def handle_call(:ping, _from, state), do: {:reply, :pong, state}
end
# The supervisor starts the worker and WATCHES it. :one_for_one means "if a
# child dies, restart that child" — from init/1, which is a known-good state.
{:ok, _supervisor} = Supervisor.start_link([Worker], strategy: :one_for_one)
IO.inspect(Worker.ping())
original = Process.whereis(Worker)
# Kill it. An uncaught raise inside the worker would do exactly the same thing;
# this is simply the most direct way to stage the failure.
Process.exit(original, :kill)
Process.sleep(100) # the supervisor notices, and restarts it
restarted = Process.whereis(Worker)
IO.inspect(Worker.ping()) # :pong — from a NEW process, at a clean state
IO.puts("new process? #{original != restarted}")
IO.puts("the caller never wrote a rescue, and the system is still up") The supervisor is the answer to "then who catches the exception?" — nobody does. The worker crashes, the supervisor is notified (it is linked to it), and it starts a fresh one from
init/1, which is a state known to be good because it is the state the program started in. Failures do not have to be anticipated for this to work, which is the entire point: it recovers from the bug you did not think of. The knobs are the restart strategy (:one_for_one restarts just the dead child; :one_for_all restarts the whole group when any one dies, for children whose state is coupled) and a restart intensity — too many crashes too fast and the supervisor gives up and dies itself, escalating to its supervisor. That is the tree, and it is why a BEAM system degrades in a small area instead of falling over. Kotlin's structured concurrency is genuinely good at the other half of this problem — deterministic cancellation and cleanup — but it has no notion of restart, so the retry policy, the backoff, and the "known-good state" are all yours to design.async/await becomes Task.async/await
(Display-only: the
Task module is one of the pieces of OTP that the in-browser AtomVM runtime does not implement, so this row has no run button. Everything else in this section runs.)import kotlinx.coroutines.*
suspend fun compute(number: Int): Int {
delay(20)
return number * number
}
fun main() = runBlocking {
val results = coroutineScope {
(1..3).map { number -> async { compute(number) } }.awaitAll()
}
println(results)
// A timeout is a scope wrapper.
val slow = withTimeoutOrNull(5) { compute(1) }
println(slow ?: "timed out")
} compute = fn number ->
Process.sleep(20)
number * number
end
# Task.async spawns a LINKED process and returns a handle; Task.await collects it.
# This is async/awaitAll, and it is running on real cores, in parallel.
results =
1..3
|> Enum.map(fn number -> Task.async(fn -> compute.(number) end) end)
|> Enum.map(&Task.await/1)
IO.inspect(results)
# await takes a timeout, and exceeding it EXITS the caller — because the task is
# linked, and a task that overruns is a bug, not a value to fall back from.
task = Task.async(fn -> compute.(1) end)
result =
try do
Task.await(task, 5)
catch
:exit, _ -> "timed out"
end
IO.inspect(result) Task.async/Task.await is the closest thing on this page to a straight translation of async/await — and it is genuinely parallel, since the BEAM schedules processes across every core with no GIL and no Dispatchers.Default to choose. Two differences to hold onto. A Task is linked to its caller, so if the task crashes, the caller crashes too (that is the default, and usually what you want: a broken sub-computation should not leave a half-finished parent limping on). And Task.await's timeout exits the caller rather than returning null, which is why the fallback above is a catch :exit and not a ?: — the BEAM's answer to a stuck task is to kill something, not to paper over it. Use Task.yield/2 when you genuinely want to wait and keep going.