PONY λ M2 Modula-2

Erlang.CodeCompared.To/Rust

An interactive executable cheatsheet comparing Erlang and Rust

Erlang/OTP 26 Rust 1.95
Output & Running
Hello, World
io:format("Hello, World!~n").
println!("Hello, World!");
Erlang prints with io:format/1 and the ~n directive for the newline. Rust's println! macro appends the newline for you. Rust's "no main needed for a snippet" convention on this site (the runner auto-wraps this in fn main() { ... }) mirrors how Erlang's shell expressions need no wrapping function either.
Printing a value for inspection
Value = {ok, [1, 2, 3]}, io:format("~p~n", [Value]).
let value = Some(vec![1, 2, 3]); println!("{:?}", value);
Erlang's ~p ("pretty print") directive renders any term for humans. Rust's {:?} ("debug") format specifier does the equivalent for any type that derives or implements Debug — which includes nearly everything in the standard library, including Option and Vec.
Dynamic vs. Static Types
Runtime checking vs. compile-time checking
% No type declaration anywhere — this only fails when it actually RUNS: Add = fun(X, Y) -> X + Y end, io:format("~p~n", [Add(2, 3)]).
fn add(x: i32, y: i32) -> i32 { x + y } fn main() { println!("{}", add(2, 3)); }
Erlang has no compile-time type checking at all — Add("two", 3) would compile and only fail when that exact line executes. Rust's fn add(x: i32, y: i32) -> i32 rejects add("two", 3) before the program ever runs.
Local type inference feels familiar
Double = fun(X) -> X * 2 end, io:format("~p~n", [Double(21)]).
let result = 21 * 2; println!("{}", result);
Rust infers the type of result from context, so everyday local code reads almost as unannotated as Erlang. The difference only surfaces at function boundaries: Rust function parameters and return types are always explicit, one place static typing cannot infer its way past a genuine ambiguity.
Pattern Matching
Function clauses become a `match` expression
Describe = fun Describe(0) -> "zero"; Describe(N) when N > 0 -> "positive"; Describe(_) -> "negative" end, io:format("~s~n", [Describe(-5)]).
fn describe(n: i32) -> &'static str { match n { 0 => "zero", n if n > 0 => "positive", _ => "negative", } } fn main() { println!("{}", describe(-5)); }
Erlang picks a function clause by matching the call against each one, top to bottom; Rust's match does the identical thing inside a single function, one arm per Erlang clause. Rust adds one guarantee Erlang lacks: the compiler verifies every match is exhaustive, refusing to compile if a case is missed.
Destructuring a tuple
Result = {ok, 42}, {ok, Value} = Result, io:format("~p~n", [Value]).
let result = (true, 42); let (_, value) = result; println!("{}", value);
Both languages destructure a tuple directly in a binding. Erlang tuples are untyped and any arity; Rust tuples like (bool, i32) are typed by position, so a mismatched destructure is a compile error rather than a runtime crash.
{ok/error} vs. Option/Result
`{ok, X}` becomes `Some(x)`
Lookup = fun(Key, List) -> case lists:keyfind(Key, 1, List) of {Key, Value} -> {ok, Value}; false -> error end end, io:format("~p~n", [Lookup(b, [{a, 1}, {b, 2}])]).
use std::collections::HashMap; fn main() { let entries = HashMap::from([("a", 1), ("b", 2)]); println!("{:?}", entries.get("b")); }
Erlang's convention of returning {ok, Value} on success and a bare error on failure is exactly what Rust's built-in Option<T> formalizes: Some(value) is {ok, Value}, and None is the failure case. HashMap::get already returns an Option, baking the convention into the standard library.
`{error, Reason}` becomes `Err(reason)`
Divide = fun(_, 0) -> {error, divide_by_zero}; (X, Y) -> {ok, X div Y} end, io:format("~p~n", [Divide(10, 0)]).
fn divide(x: i32, y: i32) -> Result<i32, String> { if y == 0 { Err(String::from("divide by zero")) } else { Ok(x / y) } } fn main() { println!("{:?}", divide(10, 0)); }
Where Option only says "did it work," Rust's Result<T, E> carries a reason on failure — Err(reason) corresponds directly to Erlang's {error, Reason}, and Ok(value) corresponds to {ok, Value}, reusing the exact word ok.
The `?` operator propagates failure automatically
Divide = fun(_, 0) -> {error, divide_by_zero}; (X, Y) -> {ok, X div Y} end, % Chaining fallible calls means checking the tag every time, or % crashing on a bad match if you skip the check: Combined = fun(A, B, C) -> {ok, First} = Divide(A, B), {ok, Second} = Divide(First, C), {ok, Second} end, io:format("~p~n", [Combined(100, 2, 5)]).
fn divide(x: i32, y: i32) -> Result<i32, String> { if y == 0 { Err(String::from("divide by zero")) } else { Ok(x / y) } } fn combined(a: i32, b: i32, c: i32) -> Result<i32, String> { let first = divide(a, b)?; let second = divide(first, c)?; Ok(second) } fn main() { println!("{:?}", combined(100, 2, 5)); }
Erlang has no shorthand for "unwrap this {ok, Value}, or bail out early with the error" — every step in a chain re-pattern-matches by hand, as shown. Rust's ? operator does exactly that in one character: it unwraps an Ok and continues, or returns the Err immediately from the enclosing function — the biggest ergonomic win Result has over Erlang's tagged-tuple convention.
Tagged Tuples vs. Enums
Modeling "one of several shapes"
Shapes = [{circle, 5}, {rectangle, 3, 4}], Areas = lists:map(fun ({circle, Radius}) -> 3.14159 * Radius * Radius; ({rectangle, Width, Height}) -> Width * Height end, Shapes), io:format("~p~n", [Areas]).
enum Shape { Circle(f64), Rectangle(f64, f64), } fn area(shape: &Shape) -> f64 { match shape { Shape::Circle(radius) => 3.14159 * radius * radius, Shape::Rectangle(width, height) => width * height, } } fn main() { let shapes = vec![Shape::Circle(5.0), Shape::Rectangle(3.0, 4.0)]; let areas: Vec<f64> = shapes.iter().map(area).collect(); println!("{:?}", areas); }
Erlang models "one of several shapes" as differently-tagged tuples, distinguished by their first element and arity at match time. Rust's enum Shape { Circle(f64), Rectangle(f64, f64) } formalizes exactly this pattern as a genuine, closed sum type — the compiler knows every variant, and a match missing one is a compile error, not a runtime surprise.
Atoms vs. unit enum variants
Status = ok, io:format("~p~n", [Status]).
#[derive(Debug)] enum Status { Ok, Failed, } fn main() { let status = Status::Ok; println!("{:?}", status); }
An Erlang atom like ok is an unstructured constant from a global, unbounded namespace of possible atoms. Rust's enum Status { Ok, Failed } gives the same "one fixed named value" idea but scoped and typed — a Status can only ever be Status::Ok or Status::Failed, never an unrelated value that happens to share the name.
Records vs. Structs
Records vs. structs
Point = {point, 3, 4}, {point, X, Y} = Point, io:format("~p and ~p~n", [X, Y]).
struct Point { x: i32, y: i32, } fn main() { let point = Point { x: 3, y: 4 }; println!("{} and {}", point.x, point.y); }
Erlang's -record(point, {x, y}). desugars to a tagged tuple at compile time — a naming convenience only, indistinguishable at runtime from any other tuple of the same shape. Rust's struct Point { x: i32, y: i32 } introduces a genuinely distinct type the compiler will never confuse with an unrelated two-field struct.
Methods live on the type, not in a free function
% Erlang has no "methods" — every function lives at module level % and takes the record explicitly as its first argument: Area = fun({rectangle, Width, Height}) -> Width * Height end, io:format("~p~n", [Area({rectangle, 3, 4})]).
struct Rectangle { width: f64, height: f64, } impl Rectangle { fn area(&self) -> f64 { self.width * self.height } } fn main() { let rectangle = Rectangle { width: 3.0, height: 4.0 }; println!("{}", rectangle.area()); }
Erlang has no notion of a method — every function is a free function at module scope, and "the record it operates on" is just its first ordinary argument by convention. Rust's impl Rectangle { fn area(&self) -> f64 { ... } } attaches the function to the type, callable as rectangle.area() — genuinely new syntax with no Erlang equivalent.
No Garbage Collector: Ownership
The headline new concept: no garbage collector
% Erlang: every process has its own private heap, garbage-collected % completely automatically — memory management is invisible: List = [1, 2, 3], io:format("~p~n", [List]).
fn main() { let list = vec![1, 2, 3]; println!("{:?}", list); // "list" is automatically freed here, when it goes out of scope — // determined entirely at COMPILE TIME, with no garbage collector // running at all, ever. }
Erlang's per-process garbage collector is invisible and automatic — you never think about when memory is freed. Rust has no garbage collector whatsoever: every value has exactly one owner, and the compiler inserts the deallocation at the precise point that owner goes out of scope. This is the single biggest new concept for an Erlang developer, because Erlang gives you no equivalent mental model to build on — there is simply nothing like it to compare against.
Moving a value transfers ownership — and the old name becomes unusable
% Erlang has no concept of "moving" a value — a value can be bound % to as many variables as you like, all remaining perfectly valid: Original = [1, 2, 3], Copy = Original, io:format("~p and ~p~n", [Original, Copy]).
fn main() { let original = vec![1, 2, 3]; let moved = original; // ownership MOVES to "moved" here // println!("{:?}", original); // this line would be a COMPILE ERROR: // // "value borrowed after move" println!("{:?}", moved); }
This has no Erlang analogue at all: assigning original to moved does not copy the vector and does not create a second independent binding — it moves ownership, and the compiler forbids using original afterward. Erlang variables, once bound, can be referenced from anywhere, as many times as you like, forever — there is no notion of a binding becoming invalid.
Borrowing: reading without taking ownership
PrintLength = fun(List) -> io:format("~p~n", [length(List)]) end, Numbers = [1, 2, 3], PrintLength(Numbers), io:format("still usable: ~p~n", [Numbers]).
fn print_length(numbers: &Vec<i32>) { println!("{}", numbers.len()); } fn main() { let numbers = vec![1, 2, 3]; print_length(&numbers); println!("still usable: {:?}", numbers); }
Erlang passes values to functions freely — nothing is ever "used up" by a function call, because nothing is ever mutated or moved. Rust's &numbers (a borrow, or reference) achieves the same "still usable afterward" result, but by explicit design: print_length only gets temporary, read-only access, never ownership, so numbers remains valid in main after the call returns.
Lists vs. Vectors
Linked lists vs. vectors
[Head | Tail] = [1, 2, 3], io:format("~p and ~p~n", [Head, Tail]).
fn main() { let numbers = vec![1, 2, 3]; let (head, tail) = numbers.split_first().unwrap(); println!("{} and {:?}", head, tail); }
Erlang lists are singly-linked cons cells — cheap to prepend, linear to index. Rust's Vec<T> is a contiguous, growable array — cheap to index, and there is no [Head | Tail] pattern; split_first() is the closest equivalent, returning an Option since an empty vector has no first element to split off.
Map and filter
Doubled = lists:map(fun(N) -> N * 2 end, [1, 2, 3, 4, 5]), Evens = lists:filter(fun(N) -> N rem 2 =:= 0 end, Doubled), io:format("~p~n", [Evens]).
fn main() { let doubled: Vec<i32> = vec![1, 2, 3, 4, 5].iter().map(|n| n * 2).collect(); let evens: Vec<&i32> = doubled.iter().filter(|n| **n % 2 == 0).collect(); println!("{:?}", evens); }
Both languages call these map and filter. Rust's iterators are lazy.iter().map(...).filter(...) builds up a pipeline that does no work until .collect() actually drives it, unlike Erlang's lists:map/2, which eagerly builds a brand new list at every single step.
Recursion
Factorial
Factorial = fun Factorial(0) -> 1; Factorial(N) when N > 0 -> N * Factorial(N - 1) end, io:format("~p~n", [Factorial(5)]).
fn factorial(n: u64) -> u64 { match n { 0 => 1, n => n * factorial(n - 1), } } fn main() { println!("{}", factorial(5)); }
The base case and recursive case survive the translation intact, expressed as match arms rather than separate clauses. Unlike Erlang's BEAM, Rust's compiler does not guarantee tail-call optimization — deep recursion in Rust can genuinely overflow the stack, where idiomatic Erlang leans on the BEAM's guaranteed tail-call elimination without a second thought.
Closures & Higher-Order Functions
Closures as values
Apply = fun(Function, Value) -> Function(Value) end, Increment = fun(X) -> X + 1 end, io:format("~p~n", [Apply(Increment, 41)]).
fn apply<F: Fn(i32) -> i32>(function: F, value: i32) -> i32 { function(value) } fn main() { let increment = |x| x + 1; println!("{}", apply(increment, 41)); }
Both languages treat functions as ordinary values — Erlang funs and Rust closures (|x| x + 1) are equally first-class. Rust's F: Fn(i32) -> i32 generic bound is the type system spelling out, in detail, something Erlang leaves entirely implicit: exactly what kind of callable function must be.
Closures choose how they capture
MakeAdder = fun(X) -> fun(Y) -> X + Y end end, AddFive = MakeAdder(5), io:format("~p~n", [AddFive(10)]).
fn make_adder(x: i32) -> impl Fn(i32) -> i32 { move |y| x + y } fn main() { let add_five = make_adder(5); println!("{}", add_five(10)); }
Erlang closures always capture the enclosing variables' values directly — there is only one way to do it, and since values never change, it is never ambiguous. Rust closures must choose (or let the compiler infer) whether to capture by reference or by move (taking ownership) — move |y| x + y explicitly takes ownership of x so the closure can safely outlive make_adder's own stack frame.
Threads & Channels vs. Processes
Threads are not processes — they share memory
% Erlang processes share NOTHING — spawn/1 starts a fully isolated % unit of concurrency with its own private heap: Pid = spawn(fun() -> io:format("running in a process~n") end), io:format("~p~n", [is_pid(Pid)]).
use std::thread; fn main() { let handle = thread::spawn(|| { println!("running on a thread"); }); handle.join().unwrap(); }
Erlang's spawn/1 and Rust's thread::spawn share a name and a rough shape, but not the isolation guarantee. Erlang processes share nothing — a crash cannot corrupt another process's state, by construction. Rust threads run in the same address space and CAN share memory, though Rust's ownership rules (specifically the Send/Sync traits) enforce at compile time that any sharing is done safely — a fundamentally different mechanism reaching for a related guarantee.
Channels vs. mailboxes
self() ! {greeting, "hello"}, receive {greeting, Message} -> io:format("~s~n", [Message]) end.
use std::sync::mpsc; use std::thread; fn main() { let (sender, receiver) = mpsc::channel(); thread::spawn(move || { sender.send("hello").unwrap(); }); let message = receiver.recv().unwrap(); println!("{}", message); }
Erlang gives every process an implicit mailbox that any other process can ! into. Rust's std::sync::mpsc ("multiple producer, single consumer") channel is explicit — you create the sender/receiver pair yourself and pass the sender to whichever thread needs it, rather than every thread automatically having an inbox the way every Erlang process does.
panic! vs. Let It Crash
`panic!` echoes "let it crash" — with a narrower blast radius
try 10 div 0 of Result -> io:format("~p~n", [Result]) catch error:badarith -> io:format("crashed: badarith~n") end.
use std::panic; fn main() { let result = panic::catch_unwind(|| { let numbers = vec![1, 2, 3]; numbers[10] // index out of bounds — this panics }); match result { Ok(value) => println!("{}", value), Err(_) => println!("crashed: index out of bounds"), } }
Both crash loudly on a genuine bug rather than silently limping onward — a Rust panic! unwinds the current thread, much like a crashing Erlang process. The scope differs: an Erlang process crash is a first-class, expected event that a supervisor is specifically designed to notice and recover from; a Rust panic! is treated as exceptional, catch_unwind is rarely used in ordinary code, and idiomatic Rust reaches for Result to handle anything genuinely expected to fail.
Behaviours vs. Traits
A loose parallel: behaviours and traits both define a contract
% Erlang: a behaviour (like gen_server) declares a set of callbacks % a module must implement — checked only when the behaviour is USED, % not by the compiler itself: io:format("~p~n", [gen_server]). % just illustrating the module name exists
trait Greet { fn greet(&self) -> String; } struct Person { name: String, } impl Greet for Person { fn greet(&self) -> String { format!("Hello, {}!", self.name) } } fn main() { let person = Person { name: String::from("Ada") }; println!("{}", person.greet()); }
Both describe "a contract this type/module must satisfy," but the enforcement differs sharply. Erlang's behaviours (like gen_server) are a convention checked only loosely (a compiler warning if a callback is missing, not a hard error) and only matter once the behaviour is actually invoked. Rust traits are enforced fully at compile time — impl Greet for Person without a greet method simply does not compile, and nothing can call .greet() on a type that never implemented the trait.
Gotchas for Erlang Developers
`=` means something different than it looks like
X = 40, % X = 41 would be a {badmatch,41} error — Erlang variables bind once: io:format("~p~n", [X]).
fn main() { let x = 40; // x = 41; below would be a COMPILE ERROR unless x were declared // "let mut x" — Rust bindings are immutable by default, like Erlang's: println!("{}", x); }
Erlang variables genuinely cannot be rebound within a scope — it is a match failure, enforced everywhere, with no opt-out. Rust's let is immutable by default too, but — unlike Erlang — offers an explicit escape hatch: let mut x permits real, in-place reassignment, something no Erlang variable can ever do.
Build tools: rebar3/Mix vs. Cargo
% rebar3 (Erlang's build tool): % rebar3 new app myapp — create new project % rebar3 compile — compile % rebar3 shell — start a shell with the project loaded % rebar3 eunit — run tests % rebar3 release — build a deployable release
// Cargo (built into Rust): // cargo new myproject — create new project // cargo build — compile // cargo run — build + run // cargo test — run tests // cargo build --release — build an optimized, deployable binary
Both ecosystems bundle project creation, compiling, running/testing, and producing a deployable artifact behind one command-line tool. Cargo is a first-party tool shipped with the language itself (no separate install), the same relationship Go's go command has to Go — where Erlang's rebar3 is a separate, community-maintained tool installed on top of the language.