Hello World & the Build
Hello, World
OCaml has no distinguished entry point: a source file is a sequence of top-level definitions, and evaluating
let () = … at the end runs the program. Rust names its entry point explicitly.let () = print_endline "Hello, World!"fn main() {
println!("Hello, World!");
}The OCaml idiom
let () = … is a pattern binding, not a function definition — it says "the expression on the right has type unit, and here is the proof." Rust instead requires a function literally named main, and everything at the top level of a file is a declaration rather than something that executes. The trailing ! on println! marks a macro; that is what lets Rust check the format string against its arguments at compile time, in the same spirit as OCaml's typed Printf format strings.Formatted Output
Both languages type-check the format string at compile time rather than trusting it at runtime, which is rarer than it sounds — but they reach that guarantee by very different routes.
let name = "OCaml"
let year = 1996
let () = Printf.printf "%s appeared in %d\n" name yearfn main() {
let name = "Rust";
let year = 2015;
println!("{} appeared in {}", name, year);
}OCaml's
"%s appeared in %d" is not a string: it is a value of the built-in format type, and the compiler reads the directives to work out that printf here demands exactly a string then an int. Rust gets there through macro expansion instead — println! parses the literal at compile time and generates code for the specific arguments given. The practical difference is that OCaml's directives name the type (%s, %d, %f) while Rust's {} is type-agnostic and dispatches on the Display trait, so one placeholder serves every printable type.dune and opam vs Cargo
This is configuration rather than code, so neither column runs. The shape of the difference matters more than the syntax: OCaml splits the job across two tools, and Rust does not.
(* dune-project *)
(lang dune 3.16)
(* bin/dune *)
(executable
(name main)
(libraries str))
(* Build and run:
dune build
dune exec bin/main.exe
Dependencies come from opam:
opam install str *)// Cargo.toml
// [package]
// name = "demo"
// version = "0.1.0"
// edition = "2024"
//
// [dependencies]
// regex = "1"
// Build and run:
// cargo build
// cargo run
//
// Dependencies come from crates.io, resolved by Cargo itself.OCaml separates package management (opam, which installs libraries into a switch) from the build system (dune, which compiles what is installed). Cargo is both at once:
Cargo.toml declares dependencies and Cargo fetches, version-resolves, builds and runs them. The practical consequence for a newcomer is that a Rust project is reproducible from its Cargo.toml and Cargo.lock alone, whereas an OCaml project also depends on which switch you have activated.Variables & Types
Immutable Bindings by Default
This is the closest the two languages ever get. Both spell a binding
let, both make it immutable without asking, and both infer the type.let () =
let count = 10 in
let message = "items" in
Printf.printf "%d %s\n" count messagefn main() {
let count = 10;
let message = "items";
println!("{} {}", count, message);
}The similarity is not a coincidence — Rust took
let, immutability-by-default and type inference directly from the ML family. The difference to keep in mind is scoping: OCaml's let … in is an expression that introduces a binding over the body that follows it, so it nests, while Rust's let is a statement that runs to the end of the enclosing block. That is why the OCaml column threads in between the bindings and the Rust column simply ends each line with a semicolon.Shadowing
Shadowing — rebinding a name to a new value, possibly of a different type — is idiomatic in both languages rather than a mistake the linter should catch.
let () =
let value = 5 in
let value = value * 2 in
let value = string_of_int value ^ " points" in
print_endline valuefn main() {
let value = 5;
let value = value * 2;
let value = format!("{} points", value);
println!("{}", value);
}Both columns rebind
value three times and change its type on the last step, from an integer to a string. Neither language is mutating anything: each let creates a fresh binding that hides the previous one, and the old value is still there underneath if an inner scope ends. This is one of the few places where a Rust habit transfers to OCaml unchanged, and it is worth noticing because most languages that borrowed let from ML did not also borrow this.Where Type Inference Stops
This is the first real culture shock, and it is worth meeting early: Rust's inference is deliberately local, and it stops at every function boundary.
(* OCaml infers the whole signature. No annotation anywhere. *)
let add first second = first + second
let describe items = List.length items
let () =
Printf.printf "%d\n" (add 3 4);
Printf.printf "%d\n" (describe [ "a"; "b"; "c" ])// Rust requires every parameter and return type to be written out.
fn add(first: i32, second: i32) -> i32 {
first + second
}
fn describe(items: &[&str]) -> usize {
items.len()
}
fn main() {
println!("{}", add(3, 4));
println!("{}", describe(&["a", "b", "c"]));
}OCaml runs Hindley–Milner inference over the whole program, so a function's type is discovered and writing it down is optional documentation. Rust infers only inside a function body; signatures are always explicit. This is a design decision rather than a limitation — it means an error is reported at the function that is actually wrong instead of somewhere far away that happened to unify badly, and it means a function's public contract cannot change silently when its body is edited. The cost is exactly what the two columns show: Rust makes you type more.
Integer Sizes and Overflow
An OCaml
int is not a machine word, and the missing bit surprises people who go looking for it.(* OCaml's int is 63 bits on a 64-bit platform: one bit is
spent on the GC's value tag. It wraps silently on overflow. *)
let () =
Printf.printf "max_int = %d\n" max_int;
Printf.printf "wraps to = %d\n" (max_int + 1)// Rust's i64 is a full 64 bits, and overflow is checked in debug
// builds. wrapping_add asks for the wrap explicitly.
fn main() {
println!("max = {}", i64::MAX);
println!("wraps to = {}", i64::MAX.wrapping_add(1));
}OCaml tags every value so the garbage collector can tell a pointer from an integer, which costs one bit — hence
max_int being 4611686018427387903 rather than the 9223372036854775807 a full 64-bit integer would give. Rust has no runtime tagging because it has no tracing collector, so i64 really is 64 bits. The other half of the difference is behavioral: OCaml wraps silently, while Rust panics on overflow in a debug build and wraps in a release build, so the arithmetic here has to say wrapping_add to state which it wants. When a Rust integer type is not annotated it defaults to i32, which is narrower than an OCaml int and catches people out.Unit and Tuples
Tuples and destructuring carry over almost exactly, which makes the one difference underneath them easy to miss.
let divide_and_remainder numerator denominator =
(numerator / denominator, numerator mod denominator)
let () =
let (quotient, remainder) = divide_and_remainder 17 5 in
Printf.printf "%d remainder %d\n" quotient remainderfn divide_and_remainder(numerator: i32, denominator: i32) -> (i32, i32) {
(numerator / denominator, numerator % denominator)
}
fn main() {
let (quotient, remainder) = divide_and_remainder(17, 5);
println!("{} remainder {}", quotient, remainder);
}The syntax matches so closely that the columns are nearly identical, and that is the point — this is transferable knowledge. The difference is in representation: an OCaml tuple is a heap-allocated block that the garbage collector manages, so passing one around copies a pointer. A Rust tuple is a value laid out inline, so returning
(i32, i32) here moves eight bytes and touches no heap at all. Both languages also have a unit type written (), meaning "no useful value," which is the same idea in both.Strings
Building a String
OCaml concatenates with
^; Rust has an operator too, but reaching for format! is the idiomatic route and avoids a subtlety about which side gets consumed.let () =
let greeting = "Hello" in
let subject = "world" in
let sentence = greeting ^ ", " ^ subject ^ "!" in
print_endline sentence;
Printf.printf "length = %d\n" (String.length sentence)fn main() {
let greeting = "Hello";
let subject = "world";
let sentence = format!("{}, {}!", greeting, subject);
println!("{}", sentence);
println!("length = {}", sentence.len());
}The lengths agree here because every character is ASCII. They will not always agree:
String.length in OCaml counts bytes — an OCaml string is an immutable byte sequence with no declared encoding — and Rust's len() also counts bytes, but a Rust String is guaranteed to hold valid UTF-8, which OCaml never promises. So the two agree on the number and disagree on what it means: in Rust it is a byte count of known-good UTF-8, and in OCaml it is a byte count of whatever you put there.One String Type vs Two
This is the first place ownership leaks into something as ordinary as a string, and it is the single most common early stumbling block for anyone arriving from a garbage-collected ML.
(* OCaml has one string type. A literal and a computed
string are the same type and interchange freely. *)
let shout text = String.uppercase_ascii text
let () =
print_endline (shout "literal");
print_endline (shout ("com" ^ "puted"))// Rust has &str (a borrowed view) and String (an owned buffer).
// Taking &str accepts both, because String derefs to &str.
fn shout(text: &str) -> String {
text.to_uppercase()
}
fn main() {
println!("{}", shout("literal"));
println!("{}", shout(&format!("com{}", "puted")));
}OCaml has exactly one string type because the garbage collector makes the question of ownership irrelevant. Rust splits it in two:
String owns a heap buffer it will free, and &str is a borrowed window onto somebody else's bytes — a literal baked into the binary, or part of a String. The rule that makes this bearable in practice is the one the example follows: take &str, return String. A parameter typed &str accepts every kind of string without forcing the caller to give anything up, and returning String hands back something the caller owns outright.Walking a String
Both columns hold the same five-letter word, which occupies six bytes in UTF-8 because of the accented final letter. Watch what each language calls a "character."
let () =
let text = "caffè" in
Printf.printf "bytes = %d\n" (String.length text);
String.iter (fun character -> Printf.printf "[%c]" character) text;
print_newline ()fn main() {
let text = "caffè";
println!("bytes = {}", text.len());
for character in text.chars() {
print!("[{}]", character);
}
println!();
}OCaml's
char is a single byte, so String.iter walks the six bytes and the accented letter comes apart into two pieces of mojibake. Rust's char is a Unicode scalar value — four bytes wide — and .chars() decodes the UTF-8 as it goes, so the accented letter arrives whole. This is the practical payoff of Rust having made UTF-8 a type-level guarantee: the standard iterator can decode because the bytes are known to be decodable. In OCaml, correct Unicode handling means reaching for a library such as Uutf.Splitting and Joining
The operations line up one for one; the difference is when the work happens.
let () =
let line = "alpha,beta,gamma" in
let parts = String.split_on_char ',' line in
List.iter (fun part -> Printf.printf "<%s>" part) parts;
print_newline ();
print_endline (String.concat " | " parts)fn main() {
let line = "alpha,beta,gamma";
let parts: Vec<&str> = line.split(',').collect();
for part in &parts {
print!("<{}>", part);
}
println!();
println!("{}", parts.join(" | "));
}String.split_on_char builds the whole list of freshly allocated strings immediately. Rust's split returns a lazy iterator of &str slices that point into the original text and allocate nothing, which is why collect() is needed to force it into a Vec. Leaving the collect off and iterating directly is the cheaper idiom, and it is the habit worth forming: in Rust, splitting a string is normally free.Collections
The Linked List vs the Growable Array
The default sequence type is different in a way that changes which operations are cheap, and it is the most important collection fact on this page.
let () =
let numbers = [ 1; 2; 3 ] in
let extended = 0 :: numbers in
List.iter (fun number -> Printf.printf "%d " number) extended;
print_newline ();
Printf.printf "length = %d\n" (List.length extended)fn main() {
let numbers = vec![1, 2, 3];
let mut extended = vec![0];
extended.extend(numbers);
for number in &extended {
print!("{} ", number);
}
println!();
println!("length = {}", extended.len());
}OCaml's
list is a singly linked list, so consing onto the front with :: is constant time and List.length walks the whole thing. Rust's Vec is a contiguous growable array, so pushing onto the back is amortized constant time, len() is free, and there is no cheap way to prepend. The habit to unlearn is building a result by consing and reversing at the end; in Rust you push onto the back and it is already in order. The nearest equivalent to an OCaml list is VecDeque, but it is rarely what you actually want.Fixed Arrays
OCaml arrays are mutable even though OCaml is "immutable by default"; Rust requires the mutability to be declared at the binding.
let () =
let scores = [| 10; 20; 30 |] in
scores.(1) <- 99;
Array.iter (fun score -> Printf.printf "%d " score) scores;
print_newline ()fn main() {
let mut scores = [10, 20, 30];
scores[1] = 99;
for score in &scores {
print!("{} ", score);
}
println!();
}An OCaml
array is always mutable — there is no immutable array type, and scores.(1) <- 99 needs no permission from the binding. Rust puts that permission in the binding instead: dropping mut from let mut scores makes the assignment a compile error. Both index from zero and both bounds-check at runtime, panicking or raising on an out-of-range index rather than reading past the end.Hash Tables
Both return an option rather than raising on a missing key, so the lookup shape is the same. One difference in the OCaml column is a genuine trap worth knowing about.
let () =
let ages = Hashtbl.create 8 in
Hashtbl.replace ages "ada" 36;
Hashtbl.replace ages "alan" 41;
(match Hashtbl.find_opt ages "ada" with
| Some age -> Printf.printf "ada is %d\n" age
| None -> print_endline "ada is unknown");
Printf.printf "entries = %d\n" (Hashtbl.length ages)use std::collections::HashMap;
fn main() {
let mut ages = HashMap::new();
ages.insert("ada", 36);
ages.insert("alan", 41);
match ages.get("ada") {
Some(age) => println!("ada is {}", age),
None => println!("ada is unknown"),
}
println!("entries = {}", ages.len());
}Note that the OCaml column uses
Hashtbl.replace rather than Hashtbl.add. add pushes a new binding that shadows the old one without removing it, so adding the same key twice leaves two entries and Hashtbl.length reports two — the table is really a multimap with a stack per key. Rust's insert has no such mode: it overwrites and hands back the previous value as an Option. The lookup halves line up neatly, with find_opt and get both returning an option that the match must handle.The Persistent Map
OCaml's
Map is persistent: adding to it returns a new map and leaves the old one intact, sharing most of its structure. Rust's standard library has no persistent map, and the difference shows up as an explicit clone.module StringMap = Map.Make (String)
let () =
let first = StringMap.empty in
let second = StringMap.add "language" "OCaml" first in
let third = StringMap.add "paradigm" "functional" second in
Printf.printf "second has %d\n" (StringMap.cardinal second);
Printf.printf "third has %d\n" (StringMap.cardinal third);
StringMap.iter (fun key value -> Printf.printf "%s=%s " key value) third;
print_newline ()use std::collections::BTreeMap;
fn main() {
let second: BTreeMap<&str, &str> =
[("language", "Rust")].into_iter().collect();
let mut third = second.clone();
third.insert("paradigm", "systems");
println!("second has {}", second.len());
println!("third has {}", third.len());
for (key, value) in &third {
print!("{}={} ", key, value);
}
println!();
}This is the sharpest illustration of what the garbage collector was buying. OCaml's
Map.add is cheap because the old and new maps share their unchanged subtrees, and nothing has to decide when that shared structure dies — the collector does. Rust's BTreeMap is a mutable structure, so keeping the old version means copying it, which is what clone() does and why it is written out rather than implied. Persistent collections do exist in Rust as libraries (im, rpds), and they work by reference-counting the shared nodes, which is the manual version of what OCaml gets for free. The first line of the OCaml column, Map.Make (String), is also a functor application — see the modules section.Control Flow
if Is an Expression
Both languages make
if an expression that produces a value, and both require every branch to agree on the type.let () =
let temperature = 31 in
let advice =
if temperature > 30 then "stay inside"
else if temperature > 20 then "pleasant"
else "bring a coat"
in
print_endline advicefn main() {
let temperature = 31;
let advice = if temperature > 30 {
"stay inside"
} else if temperature > 20 {
"pleasant"
} else {
"bring a coat"
};
println!("{}", advice);
}This transfers directly. Both languages also apply the same rule about a missing
else: an if with no else must have type unit, because there is no value to produce when the condition is false. OCaml states this as a type error mentioning unit; Rust says the branches have incompatible types and names (). The one syntactic thing to remember is that Rust's braces are mandatory and its parentheses around the condition are not, which is the exact opposite of C and catches people who arrive by way of it.Loops
OCaml has loops, but reaching for one is a signal that something imperative is happening — note that the
while loop needs a ref cell just to have something to change.let () =
for index = 1 to 3 do
Printf.printf "for %d\n" index
done;
let countdown = ref 3 in
while !countdown > 0 do
Printf.printf "while %d\n" !countdown;
countdown := !countdown - 1
donefn main() {
for index in 1..=3 {
println!("for {}", index);
}
let mut countdown = 3;
while countdown > 0 {
println!("while {}", countdown);
countdown -= 1;
}
}The
ref in the OCaml column is the tell. OCaml has no mutable local bindings, so a counter has to live in a heap cell that := writes and ! reads. Rust simply marks the binding mut and the counter stays in a register. This is why while loops are ordinary in Rust and slightly exotic in OCaml, and why an OCaml programmer writing Rust often reaches for recursion where a loop reads better. Rust's 1..=3 is an inclusive range, matching for … to …; the more common 1..3 excludes its upper bound.Recursion and Tail Calls
The OCaml version is tail-recursive and runs in constant stack space. The Rust version is written as a loop on purpose, and the reason is the whole point of the row.
let rec sum_to total current =
if current = 0 then total
else sum_to (total + current) (current - 1)
let () = Printf.printf "%d\n" (sum_to 0 100000)fn sum_to(mut total: u64, mut current: u64) -> u64 {
while current != 0 {
total += current;
current -= 1;
}
total
}
fn main() {
println!("{}", sum_to(0, 100000));
}Rust does not guarantee tail-call elimination. The optimizer often performs it in a release build, but the language makes no promise, so the same recursive function that is perfectly safe in OCaml can overflow the stack in Rust — and in a debug build, at this depth, it very likely will. This is the single most important habit to change: where an OCaml programmer reaches for a tail-recursive helper with an accumulator, a Rust programmer writes a loop, and it is not considered a step down. The
mut on the parameters is how Rust says the accumulator may be updated in place.Functions, Currying & Closures
Currying and Partial Application
This is the OCaml habit that transfers least well, and it is worth confronting directly rather than discovering it halfway through a refactor.
(* Every OCaml function of two arguments is really a
function returning a function, so this just works. *)
let add first second = first + second
let () =
let add_ten = add 10 in
Printf.printf "%d\n" (add_ten 5);
Printf.printf "%d\n" (add_ten 32)// Rust functions take all their arguments at once. To get the
// same effect you return a closure that captures the first.
fn add(first: i32) -> impl Fn(i32) -> i32 {
move |second| first + second
}
fn main() {
let add_ten = add(10);
println!("{}", add_ten(5));
println!("{}", add_ten(32));
}In OCaml,
add 10 is not a special case — add genuinely has type int -> int -> int, which is int -> (int -> int), so applying it to one argument is ordinary application. Rust has no currying at all: a function with two parameters must be given two. Getting partial application means explicitly returning a closure, and move is required so the closure takes ownership of first rather than borrowing a local that is about to disappear. The consequence for everyday code is that OCaml's point-free pipelines do not survive translation, and Rust reads more explicitly as a result.Passing a Function
Higher-order functions are natural in both, but Rust needs a type parameter and a trait bound where OCaml needs nothing at all.
let apply_twice transform value = transform (transform value)
let () =
Printf.printf "%d\n" (apply_twice (fun number -> number * 3) 2);
Printf.printf "%d\n" (apply_twice succ 10)fn apply_twice<F: Fn(i32) -> i32>(transform: F, value: i32) -> i32 {
transform(transform(value))
}
fn main() {
println!("{}", apply_twice(|number| number * 3, 2));
println!("{}", apply_twice(|number| number + 1, 10));
}OCaml has one function type, written with arrows, and every function inhabits it. Rust has three closure traits, distinguished by what the closure does to its captures:
Fn may be called repeatedly and only reads them, FnMut may be called repeatedly and mutates them, and FnOnce consumes them and can therefore be called only once. Writing F: Fn(i32) -> i32 asks for the most permissive one the caller can supply. That taxonomy exists purely because Rust tracks ownership; with a garbage collector the distinction has nothing to track and OCaml does not need it.Labeled and Optional Arguments
OCaml has first-class support for named and defaulted arguments. Rust has none, and the workaround is verbose enough that it changes how APIs get designed.
(* OCaml has labeled (~) and optional (?) arguments built in. *)
let greet ?(greeting = "Hello") ~name () =
Printf.printf "%s, %s!\n" greeting name
let () =
greet ~name:"Ada" ();
greet ~greeting:"Welcome" ~name:"Alan" ()// Rust has neither. The idiom is a struct with a Default,
// updated with the struct-update syntax.
#[derive(Default)]
struct Greeting {
greeting: Option<String>,
name: String,
}
fn greet(options: Greeting) {
let greeting = options.greeting.unwrap_or_else(|| "Hello".to_string());
println!("{}, {}!", greeting, options.name);
}
fn main() {
greet(Greeting { name: "Ada".to_string(), ..Default::default() });
greet(Greeting {
greeting: Some("Welcome".to_string()),
name: "Alan".to_string(),
});
}The trailing
() in the OCaml column is not decoration: a function whose last parameter is optional cannot be known to be fully applied, so an ordinary unit argument is added to mark the end. Rust's absence of named arguments is a long-standing and deliberate gap, and the community answer is the builder pattern or an options struct, as here. ..Default::default() fills in every field not written out. The practical effect is that Rust APIs tend to expose several small functions where an OCaml API would expose one function with optional arguments.The Pipeline Operator
OCaml's
|> and Rust's method chaining reach the same place from opposite directions, and the OCaml version can only do it because of currying.let () =
[ 1; 2; 3; 4; 5 ]
|> List.filter (fun number -> number mod 2 = 1)
|> List.map (fun number -> number * number)
|> List.fold_left ( + ) 0
|> Printf.printf "%d\n"fn main() {
let total: i32 = vec![1, 2, 3, 4, 5]
.into_iter()
.filter(|number| number % 2 == 1)
.map(|number| number * number)
.sum();
println!("{}", total);
}|> is just an infix operator meaning "apply the function on the right to the value on the left," and it reads well only because List.filter predicate is already a partially applied function waiting for its list. Rust cannot define |> usefully for exactly the reason the currying row gave, so it chains methods instead — each adaptor returns a new iterator, and nothing is computed until sum() asks for a result. The OCaml column, by contrast, builds a full intermediate list at every step.Records & Structs
Defining a Record
Records and structs correspond closely, including functional update — OCaml spells it
with and Rust spells it ...type point = { x : int; y : int }
let () =
let origin = { x = 0; y = 0 } in
let shifted = { origin with x = 5 } in
Printf.printf "(%d, %d)\n" origin.x origin.y;
Printf.printf "(%d, %d)\n" shifted.x shifted.y#[derive(Clone, Copy)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let origin = Point { x: 0, y: 0 };
let shifted = Point { x: 5, ..origin };
println!("({}, {})", origin.x, origin.y);
println!("({}, {})", shifted.x, shifted.y);
}The correspondence is real and the syntax is nearly interchangeable. The
#[derive(Clone, Copy)] line has no OCaml equivalent because it answers a question OCaml never asks: whether using origin after building shifted from it should be allowed. Copy says this type is cheap enough to duplicate implicitly, so origin survives. Without it, ..origin would move the value and the first println! would fail to compile. Every OCaml record behaves like the Copy case from the programmer's point of view, because the collector makes aliasing free.A Mutable Field
OCaml marks mutability on the field; Rust marks it on the binding. That relocation has consequences.
type counter = { mutable total : int }
let () =
let counter = { total = 0 } in
counter.total <- counter.total + 5;
counter.total <- counter.total + 5;
Printf.printf "%d\n" counter.totalstruct Counter {
total: i32,
}
fn main() {
let mut counter = Counter { total: 0 };
counter.total += 5;
counter.total += 5;
println!("{}", counter.total);
}In OCaml a
mutable field is mutable for everybody who can reach the record, forever — there is no such thing as a read-only view of it. Rust puts the permission on the binding and on the reference, so the same Counter can be handed out as &Counter (nobody may write) or &mut Counter (exactly one holder may write). That is why Rust needs no mutable keyword on the field: the answer is decided at each use site rather than once at the type definition.Attaching Behavior
OCaml keeps data and the functions over it separate; Rust gathers the functions into an
impl block and gives them self.type rectangle = { width : float; height : float }
(* Functions live beside the type, not inside it. *)
let area rectangle = rectangle.width *. rectangle.height
let scale rectangle factor =
{ width = rectangle.width *. factor; height = rectangle.height *. factor }
let () =
let small = { width = 3.0; height = 4.0 } in
let large = scale small 2.0 in
Printf.printf "%.1f\n" (area small);
Printf.printf "%.1f\n" (area large)struct Rectangle {
width: f64,
height: f64,
}
// Methods live in an impl block and take self.
impl Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
fn scale(&self, factor: f64) -> Rectangle {
Rectangle { width: self.width * factor, height: self.height * factor }
}
}
fn main() {
let small = Rectangle { width: 3.0, height: 4.0 };
let large = small.scale(2.0);
println!("{:.1}", small.area());
println!("{:.1}", large.area());
}The OCaml arrangement is a module convention rather than a language feature —
area is just a function whose first parameter happens to be a rectangle, and calling it is area small. Rust's impl block gives the same functions a receiver, so the call becomes small.area() and method resolution can find it from the type. Note also that OCaml uses distinct operators for floating-point arithmetic — *. rather than * — because it has no operator overloading, while Rust overloads * through the Mul trait.Variants & Pattern Matching
Variants and Enums
This is the strongest correspondence on the page. Rust's enums are sum types with payloads, and they came from exactly this.
type shape =
| Circle of float
| Rectangle of float * float
| Point
let area = function
| Circle radius -> 3.14159 *. radius *. radius
| Rectangle (width, height) -> width *. height
| Point -> 0.0
let () =
List.iter
(fun shape -> Printf.printf "%.2f\n" (area shape))
[ Circle 1.0; Rectangle (2.0, 3.0); Point ]enum Shape {
Circle(f64),
Rectangle(f64, f64),
Point,
}
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle(radius) => 3.14159 * radius * radius,
Shape::Rectangle(width, height) => width * height,
Shape::Point => 0.0,
}
}
fn main() {
for shape in [Shape::Circle(1.0), Shape::Rectangle(2.0, 3.0), Shape::Point] {
println!("{:.2}", area(&shape));
}
}Both are true algebraic data types, both check exhaustiveness at compile time, and deleting a branch from either column produces a compile error naming the case that is now unhandled. The differences are cosmetic: Rust qualifies each constructor with its type (
Shape::Circle), and OCaml's function keyword is shorthand for a one-argument fun that immediately matches. What an OCaml programmer should take away is that match here is not a switch statement that happens to look similar — it is the same construct, with the same guarantee.Option
Same name, same two constructors, same reason for existing. This is knowledge that transfers with no adjustment at all.
let find_even numbers = List.find_opt (fun number -> number mod 2 = 0) numbers
let () =
(match find_even [ 1; 3; 4; 5 ] with
| Some number -> Printf.printf "found %d\n" number
| None -> print_endline "none found");
(match find_even [ 1; 3; 5 ] with
| Some number -> Printf.printf "found %d\n" number
| None -> print_endline "none found")fn find_even(numbers: &[i32]) -> Option<i32> {
numbers.iter().copied().find(|number| number % 2 == 0)
}
fn main() {
match find_even(&[1, 3, 4, 5]) {
Some(number) => println!("found {}", number),
None => println!("none found"),
}
match find_even(&[1, 3, 5]) {
Some(number) => println!("found {}", number),
None => println!("none found"),
}
}Option is one of the clearest places Rust took an ML idea unchanged, down to the constructor names Some and None. The combinators line up too: OCaml's Option.map, Option.value ~default and Option.bind are Rust's map, unwrap_or and and_then. One representational bonus Rust has: Option<&T> is the same size as a bare pointer, because the null pointer is used to represent None — the optimization OCaml cannot make because it must distinguish None from a pointer at runtime for the collector.Guards, Or-Patterns and Bindings
Guards, or-patterns and the catch-all wildcard all exist in both, with almost the same spelling.
let classify number =
match number with
| 0 -> "zero"
| n when n < 0 -> "negative"
| 1 | 2 | 3 -> "small"
| n when n > 100 -> "huge"
| _ -> "ordinary"
let () =
List.iter
(fun number -> Printf.printf "%d is %s\n" number (classify number))
[ 0; -5; 2; 500; 42 ]fn classify(number: i32) -> &'static str {
match number {
0 => "zero",
n if n < 0 => "negative",
1 | 2 | 3 => "small",
n if n > 100 => "huge",
_ => "ordinary",
}
}
fn main() {
for number in [0, -5, 2, 500, 42] {
println!("{} is {}", number, classify(number));
}
}The only real difference is the keyword: OCaml writes
when and Rust writes if. Everything else lines up, including the important semantic detail that a guard does not count toward exhaustiveness in either language — the compiler cannot prove n < 0 and n > 100 cover the rest, so the wildcard is required in both columns. Rust's &'static str return type is a lifetime annotation saying the returned string lives for the whole program, which is true of string literals; the lifetimes section returns to this.A Recursive Type
A recursive type needs no ceremony in OCaml. In Rust it needs
Box, and the reason is worth understanding rather than memorizing.type tree =
| Leaf
| Node of tree * int * tree
let rec total tree =
match tree with
| Leaf -> 0
| Node (left, value, right) -> total left + value + total right
let () =
let sample = Node (Node (Leaf, 1, Leaf), 2, Node (Leaf, 3, Leaf)) in
Printf.printf "%d\n" (total sample)enum Tree {
Leaf,
Node(Box<Tree>, i32, Box<Tree>),
}
fn total(tree: &Tree) -> i32 {
match tree {
Tree::Leaf => 0,
Tree::Node(left, value, right) => total(left) + value + total(right),
}
}
fn main() {
let sample = Tree::Node(
Box::new(Tree::Node(Box::new(Tree::Leaf), 1, Box::new(Tree::Leaf))),
2,
Box::new(Tree::Node(Box::new(Tree::Leaf), 3, Box::new(Tree::Leaf))),
);
println!("{}", total(&sample));
}Every OCaml value of a variant type is already a pointer to a heap block, so a type may mention itself freely — the size is always one word. Rust lays values out inline by default, so a
Node containing two Trees directly would have infinite size, and the compiler says exactly that. Box<Tree> is a heap pointer with a known size, which breaks the cycle. This is the same allocation OCaml was doing all along; Rust simply makes you write it down, and in exchange the non-recursive parts of your data stay unboxed and cache-friendly.Modules & Functors vs Traits
Modules
Both languages have modules, and at this level they look the same. The divergence starts as soon as you want to abstract over one.
module Geometry = struct
let pi = 3.14159
let circle_area radius = pi *. radius *. radius
end
let () =
Printf.printf "%.4f\n" Geometry.pi;
Printf.printf "%.2f\n" (Geometry.circle_area 2.0)mod geometry {
pub const PI: f64 = 3.14159;
pub fn circle_area(radius: f64) -> f64 {
PI * radius * radius
}
}
fn main() {
println!("{:.4}", geometry::PI);
println!("{:.2}", geometry::circle_area(2.0));
}The visible differences are conventions rather than semantics: Rust module names are lower case and members are private until marked
pub, while OCaml module names are capitalized and members are public unless a signature hides them. The deep difference is what a module is. An OCaml module is a value-like thing that can be passed to a functor, returned, and stored; a Rust module is purely a namespace, resolved at compile time and unable to be abstracted over. Everything OCaml does with functors, Rust does with traits and generics instead.A Signature vs a Trait
This is the central re-mapping of the whole page: an OCaml signature constrains a module, while a Rust trait constrains a type.
module type Describable = sig
type t
val describe : t -> string
end
module IntDescription : Describable with type t = int = struct
type t = int
let describe value = Printf.sprintf "the number %d" value
end
let () = print_endline (IntDescription.describe 42)trait Describable {
fn describe(&self) -> String;
}
impl Describable for i32 {
fn describe(&self) -> String {
format!("the number {}", self)
}
}
fn main() {
println!("{}", 42.describe());
}The OCaml column names a module,
IntDescription, and the caller must know that name to use it — the association between int and its description is carried by the module, not by the type. Rust attaches the implementation to i32 itself, so 42.describe() works with nothing named at the call site, and any generic function bounded by Describable will find it automatically. That automatic resolution is the thing OCaml genuinely lacks and the reason traits feel lighter in daily use. What OCaml gains in exchange is the subject of the next two rows.A Functor vs a Generic Function
The same abstraction — "work for any element type that can be compared" — written both ways. The size difference is not an accident of style.
module type Comparable = sig
type t
val compare : t -> t -> int
end
module MakeLargest (Element : Comparable) = struct
let largest items =
List.fold_left
(fun best item -> if Element.compare item best > 0 then item else best)
(List.hd items) (List.tl items)
end
module IntCompare = struct
type t = int
let compare = compare
end
module LargestInt = MakeLargest (IntCompare)
let () = Printf.printf "%d\n" (LargestInt.largest [ 3; 9; 4 ])fn largest<T: PartialOrd + Copy>(items: &[T]) -> T {
let mut best = items[0];
for &item in &items[1..] {
if item > best {
best = item;
}
}
best
}
fn main() {
println!("{}", largest(&[3, 9, 4]));
}A functor is a function from modules to modules, so parameterizing means building a new module, naming it, and calling through it. Rust puts the constraint on a type parameter (
T: PartialOrd) and the compiler finds the implementation, so there is nothing to name and nothing to instantiate. Both are resolved at compile time and both monomorphize, so neither pays at runtime. The trade is expressiveness against ceremony: a functor can take several types and several operations at once and can be applied more than once to the same type with different behavior, which trait resolution deliberately forbids — see the next row.Two Orderings for One Type
Here is what OCaml's modules buy that traits do not. Rust enforces coherence: a given type implements a given trait exactly once, globally.
(* A functor can be applied twice to the same type with
different behavior. Both modules coexist happily. *)
module Ascending = struct
type t = int
let compare left right = compare left right
end
module Descending = struct
type t = int
let compare left right = compare right left
end
module Sorter (Order : sig type t val compare : t -> t -> int end) = struct
let sort items = List.sort Order.compare items
end
module SortUp = Sorter (Ascending)
module SortDown = Sorter (Descending)
let () =
List.iter (Printf.printf "%d ") (SortUp.sort [ 3; 1; 2 ]);
print_newline ();
List.iter (Printf.printf "%d ") (SortDown.sort [ 3; 1; 2 ]);
print_newline ()// A type has at most ONE impl of a trait, so a second ordering
// needs either a newtype or a comparator passed as a value.
fn sort_by<T, F: Fn(&T, &T) -> std::cmp::Ordering>(mut items: Vec<T>, order: F) -> Vec<T> {
items.sort_by(order);
items
}
fn main() {
let ascending = sort_by(vec![3, 1, 2], |left, right| left.cmp(right));
let descending = sort_by(vec![3, 1, 2], |left, right| right.cmp(left));
for number in &ascending {
print!("{} ", number);
}
println!();
for number in &descending {
print!("{} ", number);
}
println!();
}Coherence is what makes
42.describe() unambiguous in the earlier row — there is only one candidate, so the compiler never has to ask which. The price is that i32 cannot have two different Ord implementations, and OCaml's "apply the functor twice with different orderings" has no direct translation. The two Rust workarounds are both on display in spirit: pass the comparator as a value, as here, or define a wrapper type (struct Descending(i32)) whose sole purpose is to carry the second implementation. An OCaml programmer should expect to reach for one of these regularly.Dynamic Dispatch
Putting different implementations in one list needs runtime dispatch in both languages, and both spell it out rather than doing it silently.
(* A first-class module packs a module into a value. *)
module type Greeter = sig val greet : unit -> string end
module English = struct let greet () = "Hello" end
module French = struct let greet () = "Bonjour" end
let () =
let greeters : (module Greeter) list =
[ (module English); (module French) ]
in
List.iter
(fun greeter ->
let module G = (val greeter : Greeter) in
print_endline (G.greet ()))
greeterstrait Greeter {
fn greet(&self) -> String;
}
struct English;
struct French;
impl Greeter for English {
fn greet(&self) -> String { "Hello".to_string() }
}
impl Greeter for French {
fn greet(&self) -> String { "Bonjour".to_string() }
}
fn main() {
let greeters: Vec<Box<dyn Greeter>> = vec![Box::new(English), Box::new(French)];
for greeter in &greeters {
println!("{}", greeter.greet());
}
}OCaml's first-class modules let a module be packed into a value with
(module English) and unpacked with (val …); Rust's dyn Greeter is a trait object, a fat pointer carrying the data and a vtable. The parallel is exact, including the cost: both give up the compile-time specialization that functors and generic bounds provide. The keyword dyn is mandatory in modern Rust precisely so that the choice to dispatch dynamically is visible in the source, which is the same motivation behind OCaml making you write the packing and unpacking out.Ownership, Moves & Borrowing
A Value Has One Owner
This is the row where the two languages part company, and everything else in this section follows from it.
(* Two names, one heap block, and the collector sorts it out. *)
let () =
let first = [ 1; 2; 3 ] in
let second = first in
Printf.printf "first has %d\n" (List.length first);
Printf.printf "second has %d\n" (List.length second)// Assigning MOVES the vector. Using "first" afterwards would
// not compile, so the code borrows instead of moving.
fn main() {
let first = vec![1, 2, 3];
let second = &first;
println!("first has {}", first.len());
println!("second has {}", second.len());
}In OCaml,
let second = first copies a pointer and both names refer to the same list forever; nobody frees anything until the collector proves nobody can reach it. Rust has no collector, so it needs a rule that says who is responsible for freeing: assignment moves ownership, and the original binding becomes unusable. Writing let second = first; here and then using first gives "borrow of moved value", which is the error every newcomer meets first. The & in the example asks for a borrow — a temporary reference that does not take ownership — which is what an OCaml programmer means nearly every time they write an assignment like this.Passing a Value to a Function
The habit to build: a Rust function that only needs to read its argument should borrow it, which makes the call site behave the way an OCaml programmer already expects.
let describe items = Printf.sprintf "%d items" (List.length items)
let () =
let numbers = [ 1; 2; 3 ] in
print_endline (describe numbers);
(* numbers is still perfectly usable *)
print_endline (describe numbers)fn describe(items: &[i32]) -> String {
format!("{} items", items.len())
}
fn main() {
let numbers = vec![1, 2, 3];
println!("{}", describe(&numbers));
// numbers is still usable because describe only borrowed it
println!("{}", describe(&numbers));
}If
describe took Vec<i32> by value, the first call would consume numbers and the second would not compile. Taking &[i32] instead borrows, so the caller keeps ownership and can call as many times as it likes — which is exactly OCaml's behavior. The general rule that makes Rust feel less alien is: take a reference unless you need to keep the value. Note also that the parameter is &[i32] rather than &Vec<i32>; a slice accepts vectors, arrays and sub-ranges alike, so it is the more useful signature.When You Really Do Want a Copy
OCaml never needs a deep copy of an immutable structure. Rust's
Vec is mutable, so the question is real and has to be answered.(* OCaml lists are immutable, so sharing IS copying,
semantically. There is nothing to duplicate. *)
let () =
let original = [ 1; 2; 3 ] in
let shared = original in
Printf.printf "%d %d\n" (List.length original) (List.length shared)// A Vec is mutable, so a real duplicate has to be asked for.
fn main() {
let original = vec![1, 2, 3];
let mut duplicate = original.clone();
duplicate.push(4);
println!("{} {}", original.len(), duplicate.len());
}This is the compensation for the previous rows: because OCaml's default data structures are immutable, sharing and copying are indistinguishable and the language can share aggressively without anyone noticing. Rust's collections are mutable, so it must know whether you wanted the same buffer or a new one, and
clone() is how you say "a new one." An OCaml programmer's instinct that copying is expensive and to be avoided is correct here — but so is the instinct that a clone in an inner loop is a design smell rather than a fix.Shared Ownership
When several places genuinely need to own the same value, Rust needs a reference count — which is a garbage collector, hand-rolled and scoped to one value.
(* The collector makes shared ownership invisible: just
put the same value in two places. *)
type node = { label : string }
let () =
let shared = { label = "config" } in
let holders = [ shared; shared; shared ] in
List.iter (fun node -> Printf.printf "%s " node.label) holders;
print_newline ();
Printf.printf "still reachable: %s\n" shared.labeluse std::rc::Rc;
struct Node {
label: String,
}
fn main() {
let shared = Rc::new(Node { label: "config".to_string() });
let holders = vec![Rc::clone(&shared), Rc::clone(&shared), Rc::clone(&shared)];
for node in &holders {
print!("{} ", node.label);
}
println!();
println!("reference count: {}", Rc::strong_count(&shared));
}Rc is "reference counted": each Rc::clone bumps a counter rather than copying the data, and the value is freed when the count reaches zero. That is exactly what OCaml's collector does for every value, which is why the OCaml column needs no special type at all. Two things to carry across: Rc::clone is cheap and is written as a clone only to make the count bump visible; and Rc cannot free a cycle, so a cyclic data structure — trivial in OCaml — leaks unless one direction uses Weak. For sharing across threads the equivalent is Arc.Mutation & Interior Mutability
ref vs mut
OCaml's
ref is not a language feature — it is an ordinary record with one mutable field, and ! and := are ordinary functions.let () =
let total = ref 0 in
List.iter (fun number -> total := !total + number) [ 1; 2; 3; 4 ];
Printf.printf "%d\n" !totalfn main() {
let mut total = 0;
for number in [1, 2, 3, 4] {
total += number;
}
println!("{}", total);
}ref x allocates { contents = x } on the heap, ! reads the field and := writes it. That is genuinely all it is. Rust needs no such indirection because mut is a property of the binding, so total stays a stack slot and the loop compiles to an add. When a Rust programmer does need heap indirection with shared mutability they reach for Cell or RefCell, which are the true analogues of OCaml's ref — see the next row.Mutating Through a Shared Reference
Rust's rule is that a value is either shared-and-readable or uniquely-writable.
RefCell is the escape hatch, and it moves the check rather than removing it.(* An OCaml ref can be handed to anyone and written by
anyone. There is no read-only view of it. *)
let bump counter = counter := !counter + 1
let () =
let counter = ref 0 in
bump counter;
bump counter;
Printf.printf "%d\n" !counteruse std::cell::RefCell;
// RefCell allows mutation through a shared reference, and
// checks the borrowing rule at RUNTIME instead of compile time.
fn bump(counter: &RefCell<i32>) {
*counter.borrow_mut() += 1;
}
fn main() {
let counter = RefCell::new(0);
bump(&counter);
bump(&counter);
println!("{}", counter.borrow());
}This is the closest Rust type to an OCaml
ref, and the comparison is instructive. Both let any holder write. The difference is that RefCell still enforces "one writer at a time" — it just does so at runtime, by panicking if you call borrow_mut() while another borrow is live. So the bug that OCaml would let you write (two parts of a program mutating the same cell in an interleaved way and confusing each other) is not prevented in Rust either, but it does become a loud panic instead of quiet corruption. The thread-safe version is Mutex.One Writer or Many Readers
The rule that gives Rust its reputation: you may have many readers, or one writer, never both at once.
(* The alias stays live across the mutation, and reads
the new value afterwards. Nothing here is checked. *)
let () =
let numbers = [| 1; 2; 3 |] in
let alias = numbers in
Printf.printf "before: %d\n" alias.(0);
numbers.(0) <- 99;
Printf.printf "after: %d\n" alias.(0)// The same program, except the borrow MUST end before the
// mutation — so the last line reads numbers, not alias.
fn main() {
let mut numbers = [1, 2, 3];
{
let alias = &numbers;
println!("before: {}", alias[0]);
}
numbers[0] = 99;
println!("after: {}", numbers[0]);
}Both columns print the same two lines, and the single difference between them is the pair of braces. In OCaml the alias is live across the mutation and reads 99 through it afterwards — perfectly legal, and entirely unchecked. Rust rejects exactly that: keeping
alias alive past the assignment is "cannot borrow numbers as mutable because it is also borrowed as immutable," so the borrow has to be confined to a block that ends first, and the last line must read numbers because alias no longer exists. This rule is what makes data races impossible in safe Rust, and it is also the source of most fights with the compiler in the first month. The mental shift is that Rust is not asking "is this correct?" but "can I prove this is correct?", and it declines what it cannot prove even when a human can see the code is fine.Lifetimes
Returning a Reference
Lifetimes have no OCaml counterpart at all, because the question they answer — "is this pointer still valid?" — is one the garbage collector answers silently.
(* Nothing to annotate: the collector keeps whatever is
still reachable, so returning a piece of an argument is free. *)
let first_word sentence =
match String.index_opt sentence ' ' with
| Some position -> String.sub sentence 0 position
| None -> sentence
let () = print_endline (first_word "hello there world")// The 'a says: the returned slice lives as long as the input.
fn first_word<'a>(sentence: &'a str) -> &'a str {
match sentence.find(' ') {
Some(position) => &sentence[..position],
None => sentence,
}
}
fn main() {
println!("{}", first_word("hello there world"));
}A lifetime is not a runtime thing and it does not change what the code does; it is a compile-time claim relating the validity of the output to that of the input. Here
'a says the returned slice cannot outlive the string it points into, so the compiler will reject any caller that drops the sentence and keeps the word. Note also that the OCaml version allocates — String.sub copies the characters out — while the Rust version returns a view into the original with no allocation at all. That is the payoff the annotations buy.When You Do Not Have to Write Them
Most Rust code has no lifetime annotations, and it is worth knowing why before concluding that they are everywhere.
(* OCaml has no annotations to elide. *)
let longest_prefix text limit =
if String.length text <= limit then text else String.sub text 0 limit
let () =
print_endline (longest_prefix "abcdefgh" 3);
print_endline (longest_prefix "ab" 5)// No 'a needed: with one input reference, Rust assumes the
// output borrows from it. This is lifetime elision.
fn longest_prefix(text: &str, limit: usize) -> &str {
if text.len() <= limit { text } else { &text[..limit] }
}
fn main() {
println!("{}", longest_prefix("abcdefgh", 3));
println!("{}", longest_prefix("ab", 5));
}Rust applies three elision rules that cover the overwhelming majority of signatures. The one at work here is the second: if there is exactly one input reference, every output reference is assumed to borrow from it. The third rule covers methods — an output reference is assumed to borrow from
&self. Explicit lifetimes appear only when there are several input references and the compiler cannot guess which one the output comes from. In practice an OCaml programmer will write far fewer of these than the syntax's reputation suggests.The Error Lifetimes Exist to Prevent
Here is the mistake the whole lifetime system exists to catch, and the fix an OCaml programmer will use constantly.
(* Perfectly fine in OCaml: the string outlives the
function because something still points at it. *)
let make_greeting () =
let built = "hello " ^ "world" in
built
let () = print_endline (make_greeting ())// Returning &str borrowed from a local would NOT compile:
// fn make_greeting() -> &str {
// let built = format!("hello {}", "world");
// &built // error: "built" does not live long enough
// }
// Return the owned String instead.
fn make_greeting() -> String {
format!("hello {}", "world")
}
fn main() {
println!("{}", make_greeting());
}In OCaml the local string is heap-allocated and survives because the caller holds it. In Rust a
String built inside the function is dropped when the function returns, so a reference to it would dangle — and the compiler refuses, naming the local that does not live long enough. The fix is almost always the one shown: return the owned String rather than a borrow of it. This pairs with the earlier string rule to give a reliable default for writing Rust functions: borrow the inputs, own the output.Error Handling
Exceptions vs Result
OCaml has both exceptions and
result, and uses exceptions freely. Rust reserves its exception-like mechanism for bugs and uses Result for everything else.exception Division_by_zero_error
let divide numerator denominator =
if denominator = 0 then raise Division_by_zero_error
else numerator / denominator
let () =
Printf.printf "%d\n" (divide 10 2);
match divide 10 0 with
| value -> Printf.printf "%d\n" value
| exception Division_by_zero_error -> print_endline "cannot divide by zero"fn divide(numerator: i32, denominator: i32) -> Result<i32, String> {
if denominator == 0 {
Err("cannot divide by zero".to_string())
} else {
Ok(numerator / denominator)
}
}
fn main() {
match divide(10, 2) {
Ok(value) => println!("{}", value),
Err(message) => println!("{}", message),
}
match divide(10, 0) {
Ok(value) => println!("{}", value),
Err(message) => println!("{}", message),
}
}The cultural difference is larger than the technical one. OCaml exceptions are cheap, are not tracked by the type system, and are entirely idiomatic for ordinary control flow —
Not_found from a standard-library lookup is normal. Rust's panic! is its nearest equivalent but is reserved for programmer error, and is often configured to abort the process rather than unwind, so it is not something to build on. The rule of thumb when translating: an OCaml exception that a caller is expected to catch becomes a Result, and one that signals a broken invariant becomes a panic!. OCaml's | exception pattern in a match, shown above, is a nice piece of syntax with no Rust analogue.Propagating an Error
Chaining fallible operations is where
result starts to hurt in OCaml and where Rust's ? operator earns its keep.let parse_positive text =
match int_of_string_opt text with
| None -> Error (Printf.sprintf "%S is not a number" text)
| Some number when number <= 0 -> Error "must be positive"
| Some number -> Ok number
let sum_two first second =
match parse_positive first with
| Error message -> Error message
| Ok left ->
(match parse_positive second with
| Error message -> Error message
| Ok right -> Ok (left + right))
let () =
(match sum_two "3" "4" with
| Ok total -> Printf.printf "%d\n" total
| Error message -> print_endline message);
(match sum_two "3" "oops" with
| Ok total -> Printf.printf "%d\n" total
| Error message -> print_endline message)fn parse_positive(text: &str) -> Result<i32, String> {
match text.parse::<i32>() {
Err(_) => Err(format!("{:?} is not a number", text)),
Ok(number) if number <= 0 => Err("must be positive".to_string()),
Ok(number) => Ok(number),
}
}
fn sum_two(first: &str, second: &str) -> Result<i32, String> {
let left = parse_positive(first)?;
let right = parse_positive(second)?;
Ok(left + right)
}
fn main() {
match sum_two("3", "4") {
Ok(total) => println!("{}", total),
Err(message) => println!("{}", message),
}
match sum_two("3", "oops") {
Ok(total) => println!("{}", total),
Err(message) => println!("{}", message),
}
}The two
sum_two functions do the same thing, and the difference in weight is the whole story. ? means "unwrap the Ok, or return the Err from this function immediately," collapsing the nested matching in the OCaml column to a single character. OCaml's answer to the same problem is a let* binding operator (let ( let* ) = Result.bind), which gets close but must be defined or imported first and does not compose across different error types the way ? does through the From trait. This is one of the few places where Rust is meaningfully more ergonomic than OCaml.Working Inside an Option
The combinators correspond almost name for name, so this is mostly a translation table.
let () =
let parsed = int_of_string_opt "21" in
let doubled = Option.map (fun number -> number * 2) parsed in
Printf.printf "%d\n" (Option.value doubled ~default:0);
let missing = int_of_string_opt "nope" in
let doubled_missing = Option.map (fun number -> number * 2) missing in
Printf.printf "%d\n" (Option.value doubled_missing ~default:0)fn main() {
let parsed = "21".parse::<i32>().ok();
let doubled = parsed.map(|number| number * 2);
println!("{}", doubled.unwrap_or(0));
let missing = "nope".parse::<i32>().ok();
let doubled_missing = missing.map(|number| number * 2);
println!("{}", doubled_missing.unwrap_or(0));
}OCaml's
Option.map is Rust's map, Option.value ~default is unwrap_or, Option.bind is and_then, and Option.is_some is is_some. Rust writes them as methods, which chains more comfortably than OCaml's prefix application — parsed.map(…).unwrap_or(0) reads left to right where the OCaml needs either nesting or |>. There is one trap worth naming: Rust's unwrap(), without the _or, panics on None and is the direct equivalent of Option.get. Both are fine in a quick script and a liability in a library.The List Module vs Iterators
map, filter and fold
The operations are the same three everybody knows. The difference is that OCaml builds a list at each step and Rust builds nothing until asked.
let () =
let numbers = [ 1; 2; 3; 4; 5; 6 ] in
let evens = List.filter (fun number -> number mod 2 = 0) numbers in
let doubled = List.map (fun number -> number * 2) evens in
let total = List.fold_left ( + ) 0 doubled in
List.iter (Printf.printf "%d ") doubled;
print_newline ();
Printf.printf "total = %d\n" totalfn main() {
let numbers = vec![1, 2, 3, 4, 5, 6];
let doubled: Vec<i32> = numbers
.iter()
.filter(|number| *number % 2 == 0)
.map(|number| number * 2)
.collect();
let total: i32 = doubled.iter().sum();
for number in &doubled {
print!("{} ", number);
}
println!();
println!("total = {}", total);
}Each
List.filter and List.map in the OCaml column allocates a whole new list, so this pipeline builds two intermediate lists that are immediately discarded. Rust's adaptors are lazy: filter and map return iterator structs that do no work, and only collect() drives the chain, so the whole pipeline runs in one pass with one allocation. OCaml's equivalent is the Seq module, which is lazy in the same way and is worth reaching for on long pipelines — but List is what most OCaml code uses, and the strictness is a real difference in behavior, not just performance.fold_left vs the Named Consumers
OCaml reaches for
fold_left as the general tool. Rust has a fold too, but its named consumers are more idiomatic and say more.let () =
let numbers = [ 4; 8; 15; 16; 23; 42 ] in
Printf.printf "sum = %d\n" (List.fold_left ( + ) 0 numbers);
Printf.printf "max = %d\n" (List.fold_left max min_int numbers);
Printf.printf "count = %d\n" (List.length numbers)fn main() {
let numbers = vec![4, 8, 15, 16, 23, 42];
println!("sum = {}", numbers.iter().sum::<i32>());
println!("max = {}", numbers.iter().max().unwrap());
println!("count = {}", numbers.iter().count());
}Both languages can express all three with a fold, and both provide shortcuts. The interesting difference is
max: Rust returns Option<&i32> because an empty iterator has no maximum, so the None case is forced into the open — hence the unwrap(). The OCaml column has to invent a starting value, min_int, and would silently return it for an empty list. That is a small but genuine example of Rust pushing an edge case into the type where OCaml lets you paper over it.Lazy Sequences
An infinite sequence works in both, and this is where OCaml's
Seq and Rust's Iterator line up exactly.let () =
let naturals = Seq.ints 1 in
let squares = Seq.map (fun number -> number * number) naturals in
let first_five = Seq.take 5 squares in
Seq.iter (Printf.printf "%d ") first_five;
print_newline ()fn main() {
let squares = (1..).map(|number| number * number);
let first_five: Vec<i32> = squares.take(5).collect();
for square in &first_five {
print!("{} ", square);
}
println!();
}OCaml's
Seq.t is a function returning a node when called, so nothing is computed until something demands it — the same design as Rust's Iterator trait with its single next method. The mapping between the two module interfaces is nearly one to one: Seq.map/map, Seq.filter/filter, Seq.take/take, Seq.fold_left/fold. The practical difference is that laziness is the default in Rust and the opt-in in OCaml, so a Rust programmer writes long chains without thinking about intermediate allocations and an OCaml programmer has to choose Seq deliberately.Concurrency
Spawning Work
OCaml 5 finally has parallelism, and the shape of the API is close enough to Rust's that this row is mostly a spelling difference.
(* OCaml 5 has real parallelism through domains. A domain
is an OS thread with its own minor heap. *)
let () =
let worker = Domain.spawn (fun () ->
let total = ref 0 in
for index = 1 to 100 do total := !total + index done;
!total)
in
let result = Domain.join worker in
Printf.printf "%d\n" resultuse std::thread;
fn main() {
let worker = thread::spawn(|| {
let mut total = 0;
for index in 1..=100 {
total += index;
}
total
});
let result = worker.join().unwrap();
println!("{}", result);
}Both spawn, both return a handle, both join to collect the result. The difference is what happens when the closure touches shared data. Rust's
join() returns a Result because a panicking thread must be reported rather than silently lost, hence the unwrap(). More importantly, Rust will refuse at compile time to move a non-Send value into the thread — see the next row — while OCaml will happily let two domains share a mutable structure and race on it. OCaml's runtime guarantees no memory unsafety from such a race, but the logical bug is entirely yours.Shared Mutable State
Both reach 2000. The difference is what happens to the programmer who forgets the lock.
(* A Mutex is available and is entirely optional: nothing
stops a domain touching shared state without one. *)
let () =
let lock = Mutex.create () in
let total = ref 0 in
let bump () =
for _ = 1 to 1000 do
Mutex.lock lock;
total := !total + 1;
Mutex.unlock lock
done
in
let first = Domain.spawn bump in
let second = Domain.spawn bump in
Domain.join first;
Domain.join second;
Printf.printf "%d\n" !totaluse std::sync::{Arc, Mutex};
use std::thread;
// The lock is not optional: the data lives INSIDE the Mutex,
// so there is no way to reach it without locking.
fn main() {
let total = Arc::new(Mutex::new(0));
let mut workers = Vec::new();
for _ in 0..2 {
let total = Arc::clone(&total);
workers.push(thread::spawn(move || {
for _ in 0..1000 {
*total.lock().unwrap() += 1;
}
}));
}
for worker in workers {
worker.join().unwrap();
}
println!("{}", *total.lock().unwrap());
}In OCaml the mutex and the data are separate things, and the connection between them lives only in the programmer's head — delete the
Mutex.lock lines and the program still compiles, still runs, and quietly loses increments. In Rust the integer is inside the Mutex, so lock() is the only way to reach it and forgetting is not expressible. Arc is the thread-safe reference count that lets both threads own the mutex. This is the clearest demonstration of what the type system buys: not a faster program, but one where the unsafe version cannot be written by accident.Message Passing
Rust ships a channel in its standard library; OCaml's parallel toolkit is deliberately thin and expects you to reach for Domainslib.
(* OCaml's channels live in the Domainslib library rather
than the standard library, so this shows the shape using a
mutex-protected queue instead. *)
let () =
let queue = Queue.create () in
let lock = Mutex.create () in
let producer = Domain.spawn (fun () ->
for index = 1 to 3 do
Mutex.lock lock;
Queue.add index queue;
Mutex.unlock lock
done)
in
Domain.join producer;
Queue.iter (Printf.printf "%d ") queue;
print_newline ()use std::sync::mpsc;
use std::thread;
fn main() {
let (sender, receiver) = mpsc::channel();
let producer = thread::spawn(move || {
for index in 1..=3 {
sender.send(index).unwrap();
}
});
producer.join().unwrap();
for value in receiver {
print!("{} ", value);
}
println!();
}The Rust channel closes itself: when the
sender is moved into the thread and that thread ends, the sender is dropped and the for loop over the receiver terminates on its own. That is ownership doing scheduling work — the type system knows nobody can send again, so the loop can end. The OCaml column has to join first and then drain, because a hand-rolled queue has no way to signal that it is finished. OCaml 5's standard library gives you domains and mutexes and stops there; the higher-level pieces (task pools, channels, parallel iteration) come from Domainslib.Effect Handlers
This is the one place where OCaml has a language feature Rust simply does not have, and the Rust column shows the honest workaround rather than pretending otherwise.
(* OCaml 5 effects: a computation can suspend itself and
the handler decides what to do. This has NO Rust equivalent. *)
open Effect
open Effect.Deep
type _ Effect.t += Ask : int Effect.t
let () =
let computation () = 1 + perform Ask in
let result =
match_with computation ()
{ retc = (fun value -> value)
; exnc = raise
; effc = (fun (type a) (performed : a Effect.t) ->
match performed with
| Ask -> Some (fun (continuation : (a, _) continuation) ->
continue continuation 41)
| _ -> None) }
in
Printf.printf "%d\n" result// Rust has no effect handlers and no delimited continuations.
// The nearest thing is passing the capability in explicitly.
fn computation(ask: impl Fn() -> i32) -> i32 {
1 + ask()
}
fn main() {
let result = computation(|| 41);
println!("{}", result);
}An effect handler lets a computation suspend at an arbitrary depth and hand control to a handler that decides whether and how to resume it — which is how OCaml 5 implements lightweight concurrency without colored functions, and why OCaml has no
async keyword. Rust cannot express this: it has no delimited continuations, and its async is a compile-time state-machine transformation that does color every function it touches. The workaround shown, passing the capability as a parameter, covers the simple case and none of the interesting ones. If a reader takes one "OCaml has something Rust lacks" away from this page, it should be this.ppx vs Macros
Deriving Boilerplate
Rust's
derive is built into the language; OCaml's equivalent needs a ppx preprocessor added to the build.(* Without ppx_deriving, comparison and printing are
written by hand. *)
type color = Red | Green | Blue
let to_string = function
| Red -> "Red"
| Green -> "Green"
| Blue -> "Blue"
let () =
Printf.printf "%s\n" (to_string Green);
Printf.printf "%b\n" (Red = Red);
Printf.printf "%b\n" (Red = Blue)#[derive(Debug, PartialEq)]
enum Color {
Red,
Green,
Blue,
}
fn main() {
println!("{:?}", Color::Green);
println!("{}", Color::Red == Color::Red);
println!("{}", Color::Red == Color::Blue);
}The OCaml column writes
to_string out by hand because the plain compiler has no deriving mechanism — with ppx_deriving in the dune file it would become type color = Red | Green | Blue [@@deriving show, eq], which is exactly Rust's #[derive(…)] in a different spelling. Note that structural equality is already available in OCaml through the polymorphic =, which works on any type by inspecting representations at runtime; Rust has no such thing, so PartialEq must be derived or implemented before == compiles.Writing a Macro
Rust's macros operate on syntax before type checking; OCaml has nothing equivalent in the language itself, and mostly does not miss it.
(* OCaml has no macro system in the language. Repetition
is factored out with a higher-order function instead. *)
let describe_all label items =
Printf.printf "%s: " label;
List.iter (Printf.printf "%d ") items;
print_newline ()
let () =
describe_all "primes" [ 2; 3; 5 ];
describe_all "squares" [ 1; 4; 9 ]// macro_rules! writes code at compile time, matching on syntax.
macro_rules! describe_all {
($label:expr, $($item:expr),*) => {
print!("{}: ", $label);
$( print!("{} ", $item); )*
println!();
};
}
fn main() {
describe_all!("primes", 2, 3, 5);
describe_all!("squares", 1, 4, 9);
}The OCaml column solves the problem with an ordinary function, and that is the honest answer most of the time — a language with first-class functions, currying and functors rarely needs syntactic abstraction. Where OCaml does need it, ppx rewriters operate on the parse tree as a separate build step, which is more powerful than
macro_rules! and considerably harder to write. Rust's macros also serve a purpose OCaml has no need for: taking a variable number of arguments, which is why println! and vec! are macros rather than functions.Polymorphic Variants
The second thing OCaml has that Rust does not, and the one an OCaml programmer is most likely to miss without being able to name why.
(* A polymorphic variant needs no type declaration and
can belong to many types at once. *)
let describe value =
match value with
| `Circle radius -> Printf.sprintf "circle of %d" radius
| `Square side -> Printf.sprintf "square of %d" side
let () =
print_endline (describe (`Circle 3));
print_endline (describe (`Square 4))// Rust has no equivalent. Every variant belongs to exactly
// one declared enum, and the declaration is required.
enum Shape {
Circle(i32),
Square(i32),
}
fn describe(value: &Shape) -> String {
match value {
Shape::Circle(radius) => format!("circle of {}", radius),
Shape::Square(side) => format!("square of {}", side),
}
}
fn main() {
println!("{}", describe(&Shape::Circle(3)));
println!("{}", describe(&Shape::Square(4)));
}A polymorphic variant tag such as
`Circle is not tied to any one type: two unrelated functions can accept overlapping sets of tags, and the inferred type records exactly which tags a value might carry. That makes open, extensible sums possible without declaring a closed enum in advance. Rust has nothing like it — every variant belongs to one enum, declared up front, which is what makes exhaustiveness checking so straightforward. The usual Rust approximation is a trait with several implementing types, which is open in a different way but gives up exhaustiveness in exchange.