Compiled, Static, No Runtime
Hello, World
Every Rust program starts at
fn main(). The Rust cells on this page show only the lines you would actually write — the runner wraps them in fn main() unless a cell defines one itself, which a few of the longer examples do.IO.puts("Hello, World!") println!("Hello, World!"); The exclamation mark matters:
println! is a macro, not a function, and the trailing ! is how Rust spells that everywhere. There is no script mode and no .exs equivalent — cargo run merely hides a compile step that always happens. What you get for it is a single static binary with no runtime to install, where a release still ships the whole BEAM.ArithmeticError at runtime → will not compile
Elixir discovers a type error when the line finally runs. Rust discovers it before the binary exists. The Rust cell shows the conversion you now have to write; the line that would not compile is in the comment.
value = "7"
try do
IO.puts(value + 1)
rescue
ArithmeticError -> IO.puts("ArithmeticError — discovered at RUNTIME")
end let value = "7";
// value + 1 -> cannot add `{integer}` to `&str`
let number: i32 = value.parse().expect("not a number");
println!("{}", number + 1); This is the trade the whole page turns on. You give up the freedom to write anything and have it mean something at runtime; you get back a compiler that rejects a category of crash before the code ships. Note the second half of the bargain: Rust has no implicit conversion anywhere — not even
i32 to i64 — so parsing returns something that can fail and you are made to deal with it.Rebinding → shadowing (or mut)
In Elixir
counter = counter + 1 is not mutation — it rebinds the name to a new immutable value. Rust splits that single behavior in two, and you choose which one you meant.counter = 1
counter = counter + 1
IO.puts(counter) let counter = 1;
let counter = counter + 1; // shadowing: a brand-new binding wearing the same name
println!("{counter}");
let mut total = 1;
total += 1; // mutation: the SAME binding changes in place
println!("{total}"); Shadowing is the faithful translation of Elixir rebinding, and it can even change the type —
let input = "7"; let input: i32 = input.parse().unwrap(); is idiomatic. mut is the genuinely new thing: the value itself changes, which on the BEAM is not expressible at all.@spec → a signature the compiler enforces
Elixir lets you describe types with
@spec, but nothing checks them unless you run Dialyzer as a separate step. In Rust the signature is the code.defmodule Doubling do
@spec double(integer()) :: integer()
def double(number), do: number * 2
end
IO.puts(Doubling.double(21)) fn double(number: i32) -> i32 {
number * 2
}
let doubled = double(21); // the type of `doubled` is inferred from the signature
println!("{doubled}"); Nothing stops you calling
Doubling.double("x") and finding out in production. In Rust the annotation is mandatory on function boundaries and optional almost everywhere else, because inference fills in the rest — you will write far fewer type annotations than you expect, just never on a function signature.Ownership: The One New Idea
Aliasing is free → assignment moves
On the BEAM two names pointing at the same list is unremarkable, because neither can ever change it. In Rust, assigning a heap-backed value moves ownership: the old name is dead from that line on, and the compiler enforces it.
list = [1, 2, 3]
other = list # both names see the same immutable list
IO.inspect(list)
IO.inspect(other) let numbers = vec![1, 2, 3];
let moved = numbers; // ownership MOVES out of `numbers`
// println!("{numbers:?}"); -> borrow of moved value: `numbers`
println!("{moved:?}"); This is the idea everything else is built on. Exactly one variable owns a value; when that owner goes out of scope the memory is freed, immediately and deterministically. There is no garbage collector to decide later — which is why a Rust NIF has no GC pause to add to your latency budget, and why the compiler has to be this strict to stay safe.
Passing a value → borrowing a reference
If assignment moves, then passing a value to a function would move it too — and you would lose it. Borrowing with
& is how you lend it instead, which is what almost every function actually wants.defmodule Totals do
def total(numbers), do: Enum.sum(numbers)
end
numbers = [1, 2, 3, 4]
IO.puts(Totals.total(numbers))
IO.inspect(numbers) fn total(numbers: &[i64]) -> i64 {
numbers.iter().sum()
}
let numbers = vec![1, 2, 3, 4];
println!("{}", total(&numbers)); // lent, not given away
println!("{numbers:?}"); // so it is still usable here A shared reference
&T is read-only, so several may exist at once. Reading a signature is how you learn a function's intentions: &[i64] promises to look and give it back, &mut Vec<i64> announces it will change the caller's value, and a bare Vec<i64> means it is taking the thing for keeps.Structural sharing → an explicit clone
Elixir's update syntax hands back a new map that shares every untouched part of the old one. That is only safe because nothing can be mutated. Rust makes you say when you want a real copy.
original = %{name: "Ada", year: 1843}
updated = %{original | year: 1852}
IO.inspect(original)
IO.inspect(updated) #[derive(Debug, Clone)]
struct Record {
name: String,
year: u32,
}
let original = Record { name: String::from("Ada"), year: 1843 };
let mut updated = original.clone();
updated.year = 1852;
println!("{original:?}");
println!("{updated:?}"); A
.clone() is a genuine deep copy and it is deliberately noisy in the source, because it is the line where you spent memory and time. Reaching for it to quiet the borrow checker is the classic beginner move; it works, it is not a crime, and you will slowly replace those calls with borrows as the model clicks.Lifetimes — no counterpart at all
When a function returns a reference, the compiler must know which input it borrowed from, so it can prove the result does not outlive its source. That is what the
'a annotations say. This concept has no Elixir equivalent whatsoever.defmodule Longest do
def longest(left, right) do
if byte_size(left) >= byte_size(right), do: left, else: right
end
end
IO.puts(Longest.longest("elixir", "rust")) fn longest<'a>(left: &'a str, right: &'a str) -> &'a str {
if left.len() >= right.len() { left } else { right }
}
let winner = longest("elixir", "rust");
println!("{winner}"); A lifetime is not a runtime value and it does not change what the code does — it is a proof obligation, erased before anything runs. A BEAM term simply lives until the per-process garbage collector decides otherwise, so nothing on that side corresponds. The good news is that elision rules cover the overwhelming majority of functions and you will write these annotations rarely.
Pattern Matching You Know
Destructuring tuples and lists
Rust destructures in
let exactly the way Elixir does, including a rest pattern for slices — spelled rest @ .. rather than | rest.{status, payload} = {:ok, "body"}
IO.puts("#{status}: #{payload}")
[first | rest] = [1, 2, 3]
IO.puts(first)
IO.inspect(rest) let (status, payload) = ("ok", "body");
println!("{status}: {payload}");
let numbers = [1, 2, 3];
let [first, rest @ ..] = numbers;
println!("{first}");
println!("{rest:?}"); The deep difference is that Elixir's
= is a match operator that happens to bind, so a failed match raises MatchError at runtime. Rust's let only accepts an irrefutable pattern — one that cannot fail — and anything that might fail must be written as if let or match, checked at compile time.case → match, when → if
This is the row where Rust feels most like home.
match is an expression, arms are tried top to bottom, and guards work the way you expect — only the keyword changes.value = 42
result =
case value do
number when number < 0 -> "negative"
0 -> "zero"
number when number < 100 -> "small"
_ -> "large"
end
IO.puts(result) let value = 42;
let result = match value {
number if number < 0 => "negative",
0 => "zero",
number if number < 100 => "small",
_ => "large",
};
println!("{result}"); Note the semicolon after the closing brace:
match is an expression being assigned, so the statement has to be terminated. Rust took this construct from the ML family for the same reasons Elixir did, and it is the single largest piece of your existing intuition that transfers unchanged.FunctionClauseError → a build failure
Elixir will happily ship a function that has no clause for one of its inputs; you find out when that input arrives. Rust refuses to compile a
match that does not cover every possibility.defmodule Traffic do
def action(:red), do: "stop"
def action(:yellow), do: "slow"
# :green has no clause — this compiles fine and blows up at runtime
end
IO.puts(Traffic.action(:red))
try do
IO.puts(Traffic.action(:green))
rescue
FunctionClauseError -> IO.puts("FunctionClauseError — discovered at RUNTIME")
end enum Light { Red, Yellow, Green }
fn action(light: &Light) -> &'static str {
match light {
Light::Red => "stop",
Light::Yellow => "slow",
Light::Green => "go", // delete this arm and the build fails
}
}
println!("{}", action(&Light::Red));
println!("{}", action(&Light::Green)); Exhaustiveness is what makes an
enum worth declaring. Add a fourth light six months from now and the compiler walks you to every match that needs updating, which is the refactoring story Elixir cannot tell — there, a new atom flows silently through the system until some clause somewhere fails to match it.The pin operator has no equivalent
This one will bite you. In an Elixir pattern a bare name binds, and
^name means "match against what this variable already holds." In a Rust pattern a bare name always binds — there is no pin, so an existing value must be compared in a guard.expected = 5
value = 5
case value do
^expected -> IO.puts("matched the pinned value")
_ -> IO.puts("no match")
end let expected = 5;
let value = 5;
match value {
// `expected => ...` here would BIND a new variable named `expected`
// and match everything, silently. The guard is the only way.
number if number == expected => println!("matched the bound value"),
_ => println!("no match"),
} Rust does warn when a pattern shadows a constant, but only for constants — shadowing an ordinary
let binding is accepted in silence, and the arm then matches every input. If a match arm is firing when it should not, this is the first thing to check.Structs, Enums & Option
defstruct → struct
Both languages give you a named product type with named fields. Rust's has no default values, so every field must be supplied at construction.
defmodule Person do
defstruct name: "", age: 0
end
person = %Person{name: "Ada", age: 36}
IO.puts(person.name)
IO.inspect(person) #[derive(Debug)]
struct Person {
name: String,
age: u32,
}
let person = Person { name: String::from("Ada"), age: 36 };
println!("{}", person.name);
println!("{person:?}"); An Elixir struct is a map with a
__struct__ key, so it carries its defaults and will accept a missing field. A Rust struct is a compile-time layout with no tag and no defaults, and leaving out age is an error. The #[derive(Debug)] line is what makes {person:?} printable at all — see the macros section.Tagged tuples → a real sum type
You already model alternatives as
{:circle, radius} and {:rectangle, width, height}. Rust's enum is that pattern turned into a declared, closed type — each variant may carry its own differently-shaped payload.shapes = [{:circle, 2}, {:rectangle, 3, 5}]
for shape <- shapes do
area =
case shape do
{:circle, radius} -> 3 * radius * radius
{:rectangle, width, height} -> width * height
end
IO.puts(area)
end enum Shape {
Circle { radius: i64 },
Rectangle { width: i64, height: i64 },
}
let shapes = vec![
Shape::Circle { radius: 2 },
Shape::Rectangle { width: 3, height: 5 },
];
for shape in &shapes {
let area = match shape {
Shape::Circle { radius } => 3 * radius * radius,
Shape::Rectangle { width, height } => width * height,
};
println!("{area}");
} The tagged tuple is a convention held together by discipline: nothing stops
{:circle, "two"}, and a typo like {:circel, 2} travels quietly until something fails to match it. The enum closes the set, so a misspelled variant is a build error and a new variant forces every match to be revisited.nil → Option<T>
nil is a value that any expression can produce and every caller has to remember to guard against. Option<T> is a different type from T, so the compiler will not let you use the value until you have said what happens when it is absent.configured_port = nil
IO.inspect(configured_port)
IO.puts(configured_port || 4000) let configured_port: Option<u16> = None;
println!("{configured_port:?}");
println!("{}", configured_port.unwrap_or(4000));
let explicit_port: Option<u16> = Some(8080);
println!("{}", explicit_port.unwrap_or(4000)); There is no
nil in Rust at all — no null pointer, no universal absent value. || becomes unwrap_or, and where you would write a case on nil you write a match the compiler checks. This closes Hoare's "billion dollar mistake" at the type level, and it is one of the two or three things that will make you miss Rust when you go back.Result and the ? Operator
{:ok, value} / {:error, reason} → Result<T, E>
The convention you already follow is a built-in type here.
Result<T, E> has exactly two variants, Ok and Err, and a function's signature states which error type can escape it.defmodule Parser do
def parse(text) do
case Integer.parse(text) do
{number, rest} when rest == "" -> {:ok, number}
_ -> {:error, :not_a_number}
end
end
end
IO.inspect(Parser.parse("42"))
IO.inspect(Parser.parse("abc")) fn parse(text: &str) -> Result<i64, String> {
match text.parse::<i64>() {
Ok(number) => Ok(number),
Err(_) => Err(format!("not a number: {text}")),
}
}
println!("{:?}", parse("42"));
println!("{:?}", parse("abc")); Because it is a real type rather than a convention, the compiler knows an unhandled
Result when it sees one and warns you about it. The Elixir habit of a bare {:ok, _} match that crashes on {:error, _} has no quiet equivalent — you either handle the error branch or say explicitly that you are not going to.with → the ? operator
Both constructs solve the same problem: a sequence of steps where any one can fail and the first failure should short-circuit the rest. Rust compresses it to a single character appended to the fallible call.
defmodule Pipeline do
def run(text) do
with {number, rest} when rest == "" <- Integer.parse(text),
true <- number > 0 do
{:ok, number * 2}
else
_ -> {:error, :bad_input}
end
end
end
IO.inspect(Pipeline.run("21"))
IO.inspect(Pipeline.run("abc"))
IO.inspect(Pipeline.run("-3")) fn run(text: &str) -> Result<i64, String> {
let number: i64 = text.parse().map_err(|_| String::from("bad input"))?;
if number <= 0 {
return Err(String::from("bad input"));
}
Ok(number * 2)
}
println!("{:?}", run("21"));
println!("{:?}", run("abc"));
println!("{:?}", run("-3")); The
? operator returns early on Err, converting the error through the From trait on its way out. There is no else block, and that is the real difference: with funnels every failed clause into one place where you have lost track of which clause failed, while ? propagates each error as itself and the signature names the type that can come out.raise/rescue → panic! (and why not to)
Rust has an abrupt failure path, but it is reserved for bugs rather than for expected problems.
catch_unwind exists and is deliberately awkward; the panic message below goes to stderr while the program keeps running.result =
try do
raise "boom"
rescue
error in RuntimeError -> "rescued: #{error.message}"
end
IO.puts(result) let outcome = std::panic::catch_unwind(|| {
panic!("boom");
});
match outcome {
Ok(()) => println!("no panic"),
Err(_) => println!("caught a panic"),
} Do not read
panic! as "let it crash." The BEAM phrase assumes a supervisor underneath that will notice and restart; nothing of the kind exists here. A panic unwinds the current thread, and if that is the main thread the process exits with a non-zero status. Rust's division of labor is strict: Result for what you expect to go wrong, panic! for what should have been impossible.defexception → a type implementing Error
An Elixir exception is a struct with a
message/1. A Rust error is any type implementing Display and std::error::Error, which is what lets it travel through ? and be boxed alongside other error types.defmodule InsufficientFunds do
defexception [:balance, :requested]
def message(error) do
"balance #{error.balance} is less than #{error.requested}"
end
end
try do
raise InsufficientFunds, balance: 10, requested: 25
rescue
error in InsufficientFunds -> IO.puts(Exception.message(error))
end use std::fmt;
#[derive(Debug)]
struct InsufficientFunds {
balance: u32,
requested: u32,
}
impl fmt::Display for InsufficientFunds {
fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
write!(formatter, "balance {} is less than {}", self.balance, self.requested)
}
}
impl std::error::Error for InsufficientFunds {}
let failure = InsufficientFunds { balance: 10, requested: 25 };
println!("{failure}"); Note that the Rust error is a value returned from a function, not something thrown past every frame between here and a handler: it appears in the signature, and a caller that ignores it gets a warning.
Enum → Iterators
Enum.map / filter / sum → iterator adapters
The vocabulary transfers almost word for word. The one addition is
.iter(), which turns a collection into an iterator, and .copied(), which hands you values instead of references so the closures read cleanly.numbers = [1, 2, 3, 4, 5, 6]
result =
numbers
|> Enum.filter(fn number -> rem(number, 2) == 0 end)
|> Enum.map(fn number -> number * number end)
|> Enum.sum()
IO.puts(result) let numbers = vec![1, 2, 3, 4, 5, 6];
let result: i32 = numbers
.iter()
.copied()
.filter(|number| number % 2 == 0)
.map(|number| number * number)
.sum();
println!("{result}"); The type annotation on
result is not decoration — sum() is generic over what it produces, so the compiler needs to be told. This is the most common place a newcomer meets "type annotations needed", and the fix is almost always to annotate the binding or write .sum::<i32>().Stream → every iterator is already lazy
In Elixir you choose
Enum or Stream depending on whether you want the intermediate lists built. In Rust that choice does not exist: iterators are lazy always, and nothing happens until something consumes them.result =
1..1_000_000
|> Stream.map(fn number -> number * 2 end)
|> Stream.filter(fn number -> rem(number, 3) == 0 end)
|> Enum.take(5)
IO.inspect(result) let result: Vec<i64> = (1..1_000_000)
.map(|number| number * 2)
.filter(|number| number % 3 == 0)
.take(5)
.collect();
println!("{result:?}"); The chain does no work until
collect() drives it, so only five elements are ever produced. And because the adapters are ordinary generic structs that the optimizer can see through, the whole pipeline usually compiles to the same machine code as the hand-written loop — laziness here costs nothing, where Stream trades allocation for a small per-element overhead.Map → HashMap and BTreeMap
Rust makes you pick the ordering guarantee.
HashMap is the direct analog of an Elixir map with unspecified iteration order; BTreeMap keeps keys sorted, which is what this example wants so the output is stable.inventory = %{"apples" => 3, "pears" => 5}
inventory = Map.put(inventory, "plums", 2)
IO.puts(inventory["apples"])
IO.puts(map_size(inventory))
for {name, count} <- Enum.sort(inventory) do
IO.puts("#{name}: #{count}")
end use std::collections::BTreeMap;
let mut inventory = BTreeMap::new();
inventory.insert("apples", 3);
inventory.insert("pears", 5);
inventory.insert("plums", 2);
println!("{}", inventory["apples"]);
println!("{}", inventory.len());
for (name, count) in &inventory {
println!("{name}: {count}");
} Indexing with
inventory["apples"] panics on a missing key, exactly as Elixir's Access would return nil and then fail a step later. The safe form is inventory.get("apples"), which gives you an Option — and reaching for it by reflex is a good habit to build early.for comprehension → flat_map
Rust has no comprehension syntax at all. A nested generator with a filter becomes an iterator chain, and the inner closure needs
move because it captures the outer variable by value and outlives the call that made it.pairs = for first <- 1..3, second <- 1..3, first < second, do: {first, second}
IO.inspect(pairs) let pairs: Vec<(i32, i32)> = (1..=3)
.flat_map(|first| (1..=3).map(move |second| (first, second)))
.filter(|(first, second)| first < second)
.collect();
println!("{pairs:?}"); Note
1..=3: Rust's .. excludes the upper bound and ..= includes it, where Elixir's .. is always inclusive. This is a quiet off-by-one waiting to happen, and it is worth over-checking for the first few weeks.Binaries → String and &str
One binary type → String and &str
Elixir has a single string type. Rust has two, and the split is ownership showing up in the type you touch most often:
&str is a borrowed view (a literal is baked into the binary), String is heap-allocated and owned.greeting = "Hello"
name = "Ada"
message = greeting <> ", " <> name <> "!"
IO.puts(message)
IO.puts(byte_size(message)) let greeting: &str = "Hello";
let name = String::from("Ada");
let message = format!("{greeting}, {name}!");
println!("{message}");
println!("{}", message.len()); Both print
11, because both count bytes. The rule of thumb that will serve you well: take &str in function parameters, return String when you built something new. Reaching for String everywhere works and allocates more than it needs to.Interpolation
Rust interpolates captured variables directly inside the format string, which reads almost exactly like
#{}. Anything more complicated than a plain variable name still goes in the argument list.name = "Ada"
year = 1843
IO.puts("#{name} wrote the first algorithm in #{year}") let name = "Ada";
let year = 1843;
println!("{name} wrote the first algorithm in {year}");
println!("{} wrote it in {}", name, year); The braces are a mini formatting language rather than an escape into the host language:
{value:?} asks for the Debug representation, {value:.2} rounds a float, {value:>8} right-aligns. Elixir's #{} can hold any expression; Rust's inline capture can hold only a name.Graphemes → chars (and no indexing)
Both languages store UTF-8 and both distinguish bytes from characters. They draw the character boundary in different places, and Rust refuses to let you index a string by integer at all.
text = "héllo"
IO.puts(byte_size(text))
IO.puts(String.length(text))
IO.inspect(String.graphemes(text)) let text = "héllo";
println!("{}", text.len());
println!("{}", text.chars().count());
let characters: Vec<char> = text.chars().collect();
println!("{characters:?}"); Elixir's
String functions work on graphemes — what a reader would call a character. Rust's chars() yields Unicode scalar values, one level below that, so a flag emoji or a combining accent counts as several; true grapheme clusters need the unicode-segmentation crate. And text[0] does not compile, because a byte offset into UTF-8 is not necessarily a character boundary.String.split / Enum.join
The names line up closely. Watch the quoting: Rust distinguishes a
char in single quotes from a &str in double quotes, and splitting on a single character is measurably faster.line = "one,two,three"
parts = String.split(line, ",")
IO.inspect(parts)
IO.puts(Enum.join(parts, " | "))
IO.puts(String.upcase(line)) let line = "one,two,three";
let parts: Vec<&str> = line.split(',').collect();
println!("{parts:?}");
println!("{}", parts.join(" | "));
println!("{}", line.to_uppercase()); Every element of
parts is a borrowed slice pointing into line — no copying happens, and the borrow checker guarantees line outlives them. That is the ownership model paying you back: the obvious code is also the allocation-free code.Functions, Closures & the Pipe
Multiple clauses → one name, one signature
Elixir dispatches on arity and on the shape of the arguments, so one name can carry a dozen clauses. Rust has neither overloading nor default arguments.
defmodule Greeting do
def greet(), do: greet("world")
def greet(name), do: "Hello, #{name}!"
end
IO.puts(Greeting.greet())
IO.puts(Greeting.greet("Ada")) fn greet(name: &str) -> String {
format!("Hello, {name}!")
}
fn greet_world() -> String {
greet("world")
}
println!("{}", greet_world());
println!("{}", greet("Ada")); This is one of the few places where Rust is plainly more verbose than Elixir for no gain you can feel day to day. The idiomatic replacements are a differently named function, an
Option parameter, or — once the argument list grows — a builder struct. Trait methods do give you a form of dispatch, but on the receiver's type, never on a value.fn -> end → |arguments|
Rust closures capture their environment the way Elixir's do, and are called like ordinary functions — no
.() needed, because Rust has one namespace where Elixir has two.factor = 3
triple = fn number -> number * factor end
IO.puts(triple.(7))
IO.inspect(Enum.map([1, 2, 3], triple)) let factor = 3;
let triple = |number: i32| number * factor;
println!("{}", triple(7));
let tripled: Vec<i32> = vec![1, 2, 3].into_iter().map(triple).collect();
println!("{tripled:?}"); What Rust adds is how the capture happens — by shared reference, by mutable reference, or by value with
move — and the compiler picks the least restrictive option that compiles. That choice is invisible until a closure outlives what it captured, at which point the error message tells you to add move.The pipe → method chaining (and a borrow)
Rust has no
|>. Method chaining covers most of the same ground, but this example shows where it stops: the intermediate String has to be bound to a name, because slices of a temporary would outlive it.result =
" hello world "
|> String.trim()
|> String.upcase()
|> String.split(" ")
IO.inspect(result) let text = " hello world ".trim().to_uppercase();
let words: Vec<&str> = text.split(' ').collect();
println!("{words:?}"); Writing that as one chain fails with "temporary value dropped while borrowed" —
to_uppercase() produces a String that would die at the end of the statement while words still points into it. This is the borrow checker turning up in a spot where an Elixir pipeline has nothing to say, and binding the intermediate is the whole fix.Returning a function
A function that builds and returns another function works in both languages. Rust needs you to describe the return type, and
impl Fn(i32) -> i32 is the way to say "some closure with this shape" without boxing it.defmodule Multiplier do
def build(factor) do
fn number -> number * factor end
end
end
double = Multiplier.build(2)
IO.puts(double.(21)) fn build(factor: i32) -> impl Fn(i32) -> i32 {
move |number| number * factor
}
let double = build(2);
println!("{}", double(21)); The
move is mandatory here: factor lives on build's stack frame, which is gone by the time the closure runs, so it must be captured by value. Elixir has no such requirement because a closure copies what it captures into a heap-allocated term the garbage collector keeps alive.Protocols → Traits
defprotocol → trait
This is the closest correspondence on the page. Both let you attach behavior to types you did not write, and both keep the implementation separate from the type definition.
defprotocol Describable do
def describe(value)
end
defimpl Describable, for: Integer do
def describe(number), do: "the integer #{number}"
end
defimpl Describable, for: List do
def describe(list), do: "a list of #{length(list)} items"
end
IO.puts(Describable.describe(42))
IO.puts(Describable.describe([1, 2, 3])) trait Describable {
fn describe(&self) -> String;
}
impl Describable for i64 {
fn describe(&self) -> String {
format!("the integer {self}")
}
}
impl Describable for Vec<i64> {
fn describe(&self) -> String {
format!("a list of {} items", self.len())
}
}
println!("{}", 42_i64.describe());
println!("{}", vec![1_i64, 2, 3].describe()); Two real differences. Rust resolves the call at compile time by default, where a protocol is a runtime lookup on the term's type. And coherence — the orphan rule — requires that either the trait or the type be local to your crate, so you cannot implement someone else's trait for someone else's type; Elixir lets any application
defimpl anything for anything. (The Elixir cell is display-only here only because AtomVM compiles a protocol with two implementations too slowly to run in the browser; the dispatch itself is fine.)Duck typing → declared trait bounds
An Elixir function is generic because nothing is checked — it works on whatever you pass until it does not. A Rust generic states up front which capabilities the type must have, and the compiler verifies the caller supplied them.
defmodule Largest do
def largest([head | tail]), do: Enum.reduce(tail, head, &max/2)
end
IO.puts(Largest.largest([3, 7, 2]))
IO.puts(Largest.largest(["pear", "apple", "plum"])) fn largest<T: PartialOrd + Copy>(items: &[T]) -> T {
let mut winner = items[0];
for &item in items {
if item > winner {
winner = item;
}
}
winner
}
println!("{}", largest(&[3, 7, 2]));
println!("{}", largest(&["pear", "apple", "plum"])); The compiler generates a separate specialized copy of
largest for each concrete type you call it with — monomorphization — so the abstraction costs nothing at runtime. The bill arrives as compile time and binary size instead, which is the trade Rust makes almost everywhere.A heterogeneous list → Box<dyn Trait>
A list holding two different structs is unremarkable in Elixir. Rust needs every element of a
Vec to be the same type, so a mixed collection is opted into explicitly with a trait object.defmodule Rectangle do
defstruct width: 0, height: 0
end
defmodule Square do
defstruct side: 0
end
defmodule Area do
def area(%Rectangle{width: width, height: height}), do: width * height
def area(%Square{side: side}), do: side * side
end
for shape <- [%Rectangle{width: 3, height: 5}, %Square{side: 4}] do
IO.puts(Area.area(shape))
end trait Shape {
fn area(&self) -> i64;
}
struct Rectangle { width: i64, height: i64 }
struct Square { side: i64 }
impl Shape for Rectangle {
fn area(&self) -> i64 { self.width * self.height }
}
impl Shape for Square {
fn area(&self) -> i64 { self.side * self.side }
}
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Rectangle { width: 3, height: 5 }),
Box::new(Square { side: 4 }),
];
for shape in &shapes {
println!("{}", shape.area());
} dyn Shape is the opt-in to runtime dispatch through a vtable — precisely what every Elixir call already does, for free, all the time. Box is required because a trait object has no size known at compile time. Notice the price of getting the heterogeneous list back: one allocation per element and one pointer indirection per call.Mutation Is Explicit
Everything immutable → mut where you need it
Rust bindings are immutable by default, which will feel familiar. The difference is that
mut unlocks genuine in-place mutation rather than rebinding.list = [1, 2, 3]
appended = list ++ [4]
IO.inspect(list)
IO.inspect(appended) let mut numbers = vec![1, 2, 3];
numbers.push(4);
println!("{numbers:?}");
let frozen = vec![1, 2, 3];
// frozen.push(4); -> cannot borrow `frozen` as mutable
println!("{frozen:?}"); Elixir's
++ builds a new list and leaves the old one untouched, which is why appending in a loop is a well-known performance trap. push amortizes to constant time because it really does write into the existing buffer — the same operation the BEAM cannot offer at any price.The aliasing rule
The core rule is short: at any moment a value may have either one mutable reference or any number of shared ones, never both. The braces below end the shared borrow early so the mutation that follows is allowed.
numbers = [1, 2, 3]
first = hd(numbers)
appended = numbers ++ [4]
IO.puts(first)
IO.inspect(numbers)
IO.inspect(appended) let mut numbers = vec![1, 2, 3];
{
let first = &numbers[0];
println!("{first}");
} // the shared borrow ends here
numbers.push(4);
println!("{numbers:?}");
// Holding `first` across the push would be:
// cannot borrow `numbers` as mutable because it is also borrowed as immutable You already have this guarantee, obtained differently: the BEAM makes everything immutable and deep-copies across process boundaries, so no two writers can ever see the same memory. Rust proves the same property at compile time and then lets you mutate — which is the entire bargain, and the only genuinely hard part of learning the language.
Enum.reduce → fold, or just a loop
Without mutation, an accumulator has to be threaded through a fold. Rust offers the fold as well, but a mutable accumulator in a plain loop is equally idiomatic and often clearer.
total = Enum.reduce(1..5, 0, fn number, accumulator -> accumulator + number end)
IO.puts(total) let mut total = 0;
for number in 1..=5 {
total += number;
}
println!("{total}");
let folded: i32 = (1..=5).fold(0, |accumulator, number| accumulator + number);
println!("{folded}"); Both compile to the same thing, so the choice is about reading. The Elixir instinct to reach for
reduce transfers fine, but resist writing a fold where the body is genuinely imperative — Rust programmers read the loop faster, and there is no purity argument to be made when the whole language permits mutation.Threads Share Memory
spawn → thread::spawn
Both start concurrent work in one line. What comes back is different:
spawn gives you a pid you can send to, link to, and monitor; thread::spawn gives you a handle whose only ability is to wait for one return value.parent = self()
spawn(fn ->
send(parent, {:result, 6 * 7})
end)
receive do
{:result, value} -> IO.puts(value)
end use std::thread;
let handle = thread::spawn(|| 6 * 7);
let value = handle.join().unwrap();
println!("{value}"); The cost model is the thing to internalize. A BEAM process starts at a few hundred words and you can hold millions of them;
thread::spawn creates a real operating-system thread with a stack measured in megabytes, so you will run tens or hundreds and reach for a thread pool beyond that. Cheap concurrency in Rust lives in the async section instead.The mailbox → an mpsc channel
A process's mailbox comes with the process and is addressed by pid. A Rust channel is a separate object you create, and the sending half is cloned to every producer that needs it.
parent = self()
for number <- 1..3 do
spawn(fn -> send(parent, {:done, number}) end)
end
for _ <- 1..3 do
receive do
{:done, number} -> IO.puts("finished #{number}")
end
end use std::sync::mpsc;
use std::thread;
let (sender, receiver) = mpsc::channel();
for number in 1..=3 {
let sender = sender.clone();
thread::spawn(move || {
sender.send(number).unwrap();
});
}
drop(sender); // the receiver ends when every sender is gone
let mut finished: Vec<i32> = receiver.iter().collect();
finished.sort();
println!("{finished:?}"); A channel is typed, so a worker cannot send a message the consumer was not built to handle — a real gain. The
drop(sender) is the part that catches people out: the receiver keeps waiting while any sender is alive, and the original one is still in scope.Selective receive has no equivalent
Elixir's
receive scans the mailbox and takes the first message matching any of its clauses, leaving everything else in place for later. This example proves it: the low-priority message arrives first and is stepped over.send(self(), {:low, "low priority"})
send(self(), {:high, "high priority"})
receive do
{:high, message} -> IO.puts(message)
end
receive do
{:low, message} -> IO.puts(message)
end use std::sync::mpsc;
let (sender, receiver) = mpsc::channel();
sender.send("low priority").unwrap();
sender.send("high priority").unwrap();
// A channel is strictly FIFO. There is no way to look past the front,
// so priority has to be modeled as a SECOND channel and chosen between.
println!("{}", receiver.recv().unwrap());
println!("{}", receiver.recv().unwrap()); This is the BEAM feature with the least satisfying replacement. Selective receive lets a process defer work it is not ready for and pick it up later, which is how
GenServer handles out-of-order replies and how a state machine ignores events belonging to another state. In Rust you model priority as separate channels and choose between them with tokio::select!, and once a message is off a channel it is gone.State in a process → Arc<Mutex<T>>
On the BEAM, shared mutable state does not exist — state lives inside a process and you message it. Rust shares the memory itself, so the lock is back.
defmodule Tally do
use GenServer
def init(count), do: {:ok, count}
def handle_call(:increment, _from, count), do: {:reply, count + 1, count + 1}
end
{:ok, pid} = GenServer.start_link(Tally, 0)
IO.puts(GenServer.call(pid, :increment))
IO.puts(GenServer.call(pid, :increment)) use std::sync::{Arc, Mutex};
use std::thread;
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..4 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
let mut count = counter.lock().unwrap();
*count += 1;
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("{}", *counter.lock().unwrap()); Sit with this one — it is the largest conceptual reversal on the page.
Arc is the atomic reference count that lets several threads own the same value; Mutex is the lock. What Rust adds over every other language with locks is that the data lives inside the mutex, so there is no way to read it without locking: forgetting is a compile error rather than a race you find in production.Copy-on-send → Send and Sync
Every BEAM message is deep-copied on its way out, so nothing is ever shared and there is nothing to prove. Rust shares, and proves instead:
Send marks a type safe to move between threads, Sync marks one safe to reference from several.parent = self()
data = %{count: 5}
spawn(fn -> send(parent, {:copy, data}) end)
receive do
{:copy, received} -> IO.inspect(received)
end use std::sync::Arc;
use std::thread;
// Rc<T> is NOT Send — its refcount is not atomic — so this would not compile:
// let shared = Rc::new(5);
// thread::spawn(move || println!("{shared}"));
let shared = Arc::new(5);
let cloned = Arc::clone(&shared);
let handle = thread::spawn(move || println!("{cloned}"));
handle.join().unwrap();
println!("{shared}"); These two traits are applied automatically by the compiler, so you almost never write them — you only meet them in an error message explaining why something cannot cross a thread boundary. That message is the compiler catching, at build time, the exact class of bug the BEAM avoids by copying: it is the same guarantee, bought with analysis instead of memory bandwidth.
A crash is reported, never restarted
Both examples stage a worker that dies and let the parent find out. Watch what the parent is given, and what it is expected to do about it.
pid = spawn(fn -> raise "worker failed" end)
reference = Process.monitor(pid)
receive do
{:DOWN, ^reference, :process, ^pid, _reason} ->
IO.puts("worker died — and I was told about it")
end use std::thread;
let handle = thread::spawn(|| {
panic!("worker failed");
});
match handle.join() {
Ok(()) => println!("worker finished"),
Err(_) => println!("worker panicked — the main thread is still running"),
} A panicking thread does not take the process down, and
join() hands back an Err — so far so similar. There the resemblance stops. There is no supervisor, no restart strategy, no :one_for_all, no link graph propagating the failure to everything that depended on the worker. You get one result about one thread, and rebuilding it is your problem. This is worth remembering before describing a Rust service as fault-tolerant.Tokio Tasks vs Processes
Task.async → tokio::spawn
Cheap concurrency in Rust lives here rather than in threads. A Tokio task is closer to a process in cost — thousands are routine — and is multiplexed over a small thread pool. Neither cell can run on this page: AtomVM aborts on
Task.async, and the test runner has no crates, so tokio is unavailable.first = Task.async(fn ->
Process.sleep(10)
"first done"
end)
second = Task.async(fn -> "second done" end)
IO.puts(Task.await(second))
IO.puts(Task.await(first)) use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let first = tokio::spawn(async {
sleep(Duration::from_millis(10)).await;
"first done"
});
let second = tokio::spawn(async {
"second done"
});
println!("{}", second.await.unwrap());
println!("{}", first.await.unwrap());
} Three differences matter. Scheduling is cooperative, so a task that blocks without awaiting starves its whole worker thread, where a BEAM process is preempted after a fixed reduction budget no matter what it does. Tasks share memory with each other, so everything in the previous section still applies. And there is no runtime in the language: you choose and start one, which is what
#[tokio::main] does.Async is a different kind of function
On the BEAM every function can block, because blocking only parks one process. In Rust
async transforms a function into a state machine that returns a Future, and a future does nothing until something awaits it. Not runnable here — the runtime comes from a crate.defmodule Fetching do
def fetch(name) do
Process.sleep(5)
"fetched #{name}"
end
end
# Any caller can call this. There is no second kind of function.
IO.puts(Fetching.fetch("config")) async fn fetch(name: &str) -> String {
format!("fetched {name}")
}
fn synchronous_caller() {
// fetch("config").await; -> `await` is only allowed inside `async`
// Calling it without awaiting just builds a Future and drops it,
// so the body never runs at all.
let _future = fetch("config");
}
#[tokio::main]
async fn main() {
synchronous_caller();
println!("{}", fetch("config").await);
} This is what people mean by "function coloring":
async code can only be awaited from async code, so the choice propagates up through every caller and a library often has to publish both forms. It is the tax Rust pays for having no runtime of its own, and the single largest structural difference from a language where the scheduler is simply always there.quote/unquote → macro_rules!
defmacro → macro_rules!
Both languages do real compile-time metaprogramming over the syntax tree, which puts them in rare company. Both examples build the same thing: a construct that receives its arguments unevaluated and decides what code to emit.
defmodule Assertions do
defmacro assert_equal(left, right) do
quote do
if unquote(left) == unquote(right) do
IO.puts("ok")
else
IO.puts("FAILED")
end
end
end
end
require Assertions
Assertions.assert_equal(1 + 1, 2) macro_rules! assert_equal {
($left:expr, $right:expr) => {
if $left == $right {
println!("ok");
} else {
println!("FAILED");
}
};
}
assert_equal!(1 + 1, 2); The difference is what you write the macro in. An Elixir macro is an ordinary function that receives AST and returns AST, so the whole language is available while building it.
macro_rules! is a separate pattern-matching mini-language over token trees, deliberately limited. Writing Rust to generate Rust means a procedural macro, which has to live in its own crate — a much heavier step than defmacro.Free protocols → #[derive(...)]
Every BEAM term can be inspected and compared, because every term shares one universal representation. Rust has no such representation, so the printing and comparison code is generated per type — by a macro you opt into.
defmodule Coordinate do
defstruct horizontal: 0, vertical: 0
end
origin = %Coordinate{}
duplicate = %Coordinate{horizontal: 0, vertical: 0}
IO.inspect(origin)
IO.inspect(origin == duplicate) #[derive(Debug, Clone, PartialEq)]
struct Coordinate {
horizontal: i32,
vertical: i32,
}
let origin = Coordinate { horizontal: 0, vertical: 0 };
let duplicate = origin.clone();
println!("{origin:?}");
println!("{}", origin == duplicate); Forgetting
Debug and then trying to print the struct is a rite of passage, and the error message names the fix. The upside of opting in: comparing two values of a type that has no sensible notion of equality is a compile error, where Elixir's == compares any two terms whatsoever and returns a confident, meaningless answer.Rustler: Rust Inside the VM
A NIF, end to end
This is why most Elixir developers meet Rust at all. Rustler generates the glue for a native implemented function: the Elixir module declares stubs that the loader replaces, and the Rust crate exports the real ones. Neither half runs on this page — the Elixir side needs the compiled crate, and the Rust side needs the
rustler crate.defmodule FastMath do
use Rustler, otp_app: :my_app, crate: "fast_math"
# Replaced at load time by the Rust function opposite.
# If the NIF fails to load, calling it raises this instead.
def add(_left, _right), do: :erlang.nif_error(:nif_not_loaded)
end
IO.puts(FastMath.add(20, 22)) // native/fast_math/src/lib.rs
#[rustler::nif]
fn add(left: i64, right: i64) -> i64 {
left + right
}
rustler::init!("Elixir.FastMath"); Rustler is the reason this pairing exists in practice rather than in theory: it handles term conversion, panic catching at the boundary, and the build integration through
mix compile.rustler. Reach for it when profiling has actually shown you a hot loop — the boundary is not free, and a NIF wrapped around trivial work is routinely slower than the Elixir it replaced.A NIF panic takes down the node
Everything you have ever written ran inside a process with a supervisor above it. A NIF does not. It executes on the scheduler's own OS thread, in the VM's address space, with no isolation of any kind. The Elixir cell shows the failure you are used to; the Rust cell shows the one you are not.
pid = spawn(fn -> raise "pure Elixir failure" end)
reference = Process.monitor(pid)
receive do
{:DOWN, ^reference, :process, ^pid, _reason} ->
IO.puts("one process died; the rest of the VM never noticed")
end // Indexing past the end PANICS. Across the NIF boundary that unwind
// has nowhere to go, and the whole BEAM node goes with it — every
// process, every supervisor, every connection.
#[rustler::nif]
fn parse_header(bytes: Vec<u8>) -> u8 {
bytes[64]
}
// The same operation, written so failure is a value the VM can see.
#[rustler::nif]
fn parse_header_safely(bytes: Vec<u8>) -> Result<u8, rustler::Error> {
bytes.get(64).copied().ok_or(rustler::Error::BadArg)
} This is the most important row on the page. The supervision tree that has caught every failure of your career cannot reach inside a NIF, so the isolation you have been relying on stops exactly where this code begins. Rustler does catch unwinding panics and turn them into an Elixir error, which helps enormously — but
panic = "abort", a stack overflow, or unsafe code will still take the node down. Write NIFs that return Result, and index with get.A NIF is not preemptible
The BEAM's scheduler is preemptive because it counts reductions and takes the process off the CPU whatever it is doing. It cannot count reductions inside native code, so a long-running NIF simply holds the scheduler thread. The Elixir cell shows the fairness you normally get for free.
parent = self()
spawn(fn ->
Enum.each(1..200_000, fn _ -> :ok end)
send(parent, :heavy_finished)
end)
spawn(fn -> send(parent, :light_finished) end)
receive do
message -> IO.puts("first to finish: #{message}")
end use rustler::Encoder;
// A NIF must return in about a millisecond. Longer than that and the
// scheduler it is running on is stalled — latency spikes everywhere,
// nothing to do with the caller.
#[rustler::nif]
fn checksum_slowly(bytes: Vec<u8>) -> u64 {
bytes.iter().map(|byte| *byte as u64).sum()
}
// Long work belongs on a dirty scheduler, which the VM keeps
// separate from the normal ones exactly for this.
#[rustler::nif(schedule = "DirtyCpu")]
fn checksum_large_input(bytes: Vec<u8>) -> u64 {
bytes.iter().map(|byte| *byte as u64).sum()
} The one-millisecond guideline comes from the Erlang documentation and is meant seriously. Exceeding it does not fail loudly — it shows up as unexplained latency in processes that have nothing to do with your NIF, which is a genuinely difficult thing to diagnose after the fact. The fixes are the dirty scheduler above, or chunking the work and yielding between pieces.
Terms in, terms out
Rustler derives the encoder and decoder that turn a BEAM term into a Rust value and back. The Elixir cell defines the struct the Rust cell maps onto; only the Elixir half can run here.
defmodule Measurement do
defstruct label: "", value: 0.0
end
measurement = %Measurement{label: "temperature", value: 21.5}
IO.inspect(measurement) #[derive(rustler::NifStruct)]
#[module = "Measurement"]
struct Measurement {
label: String,
value: f64,
}
#[rustler::nif]
fn scale(measurement: Measurement, factor: f64) -> Measurement {
Measurement {
label: measurement.label,
value: measurement.value * factor,
}
} That conversion is a real cost and it is easy to overlook when reasoning about whether a NIF is worth it. Every term is copied across the boundary in both directions, so native code doing modest work on a large binary can lose to the pure Elixir version outright. When the data is big and the work is small, pass a reference-counted binary and measure before believing anything.
Mix → Cargo
mix → cargo
The correspondence is close enough to be uncanny — both languages shipped one blessed build tool early and the whole ecosystem settled on it. These are shell commands, so neither cell runs on this page.
mix new my_app
mix deps.get
mix test
mix format
mix docs cargo new my_app
cargo add serde
cargo test
cargo fmt
cargo doc --open Coming from Mix you will feel at home immediately, down to
cargo fmt settling formatting arguments the way mix format did. The one genuine addition is cargo clippy, a lint suite with no real Elixir counterpart — Credo is the nearest thing, and Clippy is both more aggressive and more often right.mix.exs → Cargo.toml
Both declare dependencies in a manifest at the project root and lock resolved versions in a companion file that belongs in version control. Rust's manifest is TOML rather than Elixir code, so it cannot compute anything.
# mix.exs
defp deps do
[
{:jason, "~> 1.4"},
{:rustler, "~> 0.36"}
]
end # Cargo.toml
[dependencies]
serde = { version = "1.0", features = ["derive"] }
rustler = "0.36" Version syntax differs in a way worth reading carefully: Elixir's
~> 1.4 allows 1.5 but not 2.0, while Cargo's bare "1.0" is already a caret requirement meaning the same thing. Cargo's feature flags have no Hex equivalent — they conditionally compile parts of a dependency, which is how one crate serves both an embedded target and a server.