PONY λ M2 Modula-2

Rust.CodeCompared.To/Roc

An interactive executable cheatsheet comparing Rust and Roc

Rust 1.95 Roc nightly (2026-07-02)
Hello World & The Platform Model
Hello, World
fn main() { println!("Hello, World!"); }
main! = |_args| { echo!("Hello, World!") Ok({}) }
The ! suffix on main! and echo! marks them as effectful functions — Roc tracks purity in the type system. main! receives the program arguments as a list and returns a Try: Ok({}) means success. There is no println! in the language itself; echo! is an effect provided by the platform hosting this program.
Where I/O comes from
use std::io::Write; fn main() { let mut stdout = std::io::stdout(); stdout.write_all(b"written via std\n").unwrap(); println!("std is always there"); }
main! = |_args| { echo!("every effect comes from the platform") Ok({}) }
This is the deepest difference between the two languages. A Rust program links std and can open files, spawn threads, or hit the network from anywhere. A Roc program cannot perform any I/O on its own: it is embedded by a platform — a host written in another language (often Rust or Zig) — and the platform decides exactly which effects exist. This page runs your code on the minimal "echo" platform, whose only effect is echo!. The upside is that a platform can make hard guarantees: a Roc plugin literally cannot touch the filesystem unless its host offers that effect.
Success and failure exit codes
fn main() -> Result<(), String> { println!("all good"); Ok(()) }
main! = |_args| { echo!("all good") Ok({}) }
Both languages let the entry point return a result type instead of calling an exit function. Rust's fn main() -> Result<(), E> exits nonzero on Err; Roc's main! returns a Try, and returning Err(SomeTag) makes the program exit with code 1. {} is Roc's empty record — the equivalent of Rust's unit type ().
Values & Immutability
Bindings are immutable
fn main() { let greeting = "comptime-checked"; // greeting = "nope"; // error[E0384]: cannot assign twice println!("{greeting}"); }
main! = |_args| { greeting = "always immutable" echo!(greeting) Ok({}) }
Roc has no let keyword and no mut — a plain name = value binding is immutable, period. Where Rust makes immutability the default with an opt-out, Roc makes it the rule with a separate, visually distinct opt-in for local mutation (next row).
Opt-in mutation: mut vs var $
fn main() { let mut total: i64 = 0; total += 5; total += 10; println!("{total}"); }
main! = |_args| { var $total = 0.I64 $total = $total + 5 $total = $total + 10 echo!($total.to_str()) Ok({}) }
Roc's var declares a reassignable local, and the $ sigil must appear on every use, so mutation is impossible to miss when reading code — like Rust's mut, but marked at each mention rather than only at the declaration. A var is local-only: there is no shared mutable state to send between threads, so Roc needs no borrow checker to police it.
Destructuring assignment
struct Person { name: String, age: u32, } fn main() { let (x, y) = (3, 4); println!("{x}, {y}"); let person = Person { name: String::from("Grace"), age: 85 }; let Person { name, age } = person; println!("{name}: {age}"); }
main! = |_args| { (x, y) = (3.I64, 4.I64) echo!("${x.to_str()}, ${y.to_str()}") person = { name: "Grace", age: 85.I64 } { name, age } = person echo!("${name}: ${age.to_str()}") Ok({}) }
Destructuring works the same way in both languages, with one Roc twist: the pattern must be exhaustive. You can destructure a tuple or a record directly, but you cannot write Ok(item) = list.first() as an assignment, because the Err case would be unhandled — use match for that.
Types & Inference
Type annotations
fn main() { let count: i64 = 42; let label: String = String::from("answer"); println!("{label}: {count}"); }
count : I64 count = 42 label : Str label = "answer" main! = |_args| { echo!("${label}: ${count.to_str()}") Ok({}) }
Roc annotations live on their own line above the definition — name : Type — rather than inline after a colon. Roc uses full Hindley–Milner inference, so annotations are optional nearly everywhere, but any annotation you do write is checked, exactly like Rust. Note the capitalized builtin names: I64, Str, Bool, List(a) — generics use parentheses, not angle brackets.
Typed number literals
fn main() { let small = 42i64; let ratio = 2.5f64; println!("{small} {ratio}"); }
main! = |_args| { small = 42.I64 ratio = 2.5.F64 echo!("${small.to_str()} ${ratio.to_str()}") Ok({}) }
Rust writes the type directly after the digits (42i64); Roc separates it with a dot (42.I64). The suffix mechanism is extensible in Roc: any type with a from_numeral method can be used as a literal suffix, so a custom Ratio type could accept 2.5.Ratio — checked and evaluated at compile time.
What untyped literals become
fn main() { let unconstrained = 3; // defaults to i32 println!("{}", unconstrained); println!("{}", 1.5); // defaults to f64 }
main! = |_args| { echo!(Str.inspect([1, 2, 3])) typed : List(I64) typed = [1, 2, 3] echo!(Str.inspect(typed)) Ok({}) }
Rust defaults an unconstrained integer literal to i32. Roc defaults unconstrained number literals to Dec — a 128-bit fixed-point decimal — which is why the first list prints as [1.0, 2.0, 3.0]. This surprises everyone once. Annotate the binding (or use a literal suffix) whenever the printed representation matters.
Numbers
Integer types
fn main() { let byte: u8 = 255; let big: i128 = 170_141_183_460_469_231_731_687_303_715_884_105_727; println!("{byte} {big}"); }
main! = |_args| { byte : U8 byte = 255 big : I128 big = 170_141_183_460_469_231_731_687_303_715_884_105_727 echo!("${byte.to_str()} ${big.to_str()}") Ok({}) }
The integer menus are identical: I8/U8 through I128/U128, matching Rust's i8i128 sizes exactly (Roc just capitalizes them). Underscore digit separators work in both. Roc has no usize; list lengths and indices are U64 on every target.
Dec: the default is not a float
fn main() { let sum: f64 = 0.1 + 0.2; println!("{}", sum); // 0.30000000000000004 }
main! = |_args| { precise : Dec precise = 0.1 + 0.2 echo!(precise.to_str()) lossy : F64 lossy = 0.1 + 0.2 echo!(lossy.to_str()) Ok({}) }
Roc's flagship number type Dec is a 128-bit fixed-point decimal: 0.1 + 0.2 is exactly 0.3, no epsilon comparisons required. IEEE 754 floats (F32/F64) are still there when you want hardware speed — and give the same 0.30000000000000004 Rust prints. Rust has no decimal type in std; you would reach for the rust_decimal crate.
Integer division and remainder
fn main() { let quotient = 17 / 5; let remainder = 17 % 5; println!("{quotient} {remainder}"); }
main! = |_args| { quotient : I64 quotient = 17 // 5 remainder : I64 remainder = 17 % 5 echo!("${quotient.to_str()} ${remainder.to_str()}") Ok({}) }
Roc spells integer division // (like Python) because plain / is reserved for exact division on Dec and floats. The remainder operator % matches Rust. Dividing by zero with // is an error in both worlds — Roc catches it at compile time when the operands are compile-time known.
Numeric conversions
fn main() { let small: u8 = 200; let widened: i64 = i64::from(small); let narrowed = widened as u8; // silently truncates! println!("{widened} {narrowed}"); }
main! = |_args| { small : U8 small = 200 widened = small.to_i64() echo!(widened.to_str()) total : I64 total = 3 average = total.to_f64() / 2.0 echo!(average.to_str()) Ok({}) }
Neither language converts numbers implicitly. Rust uses From/TryFrom for safe conversions and as for casts that can silently truncate; Roc's conversions are explicit methods like .to_i64() and .to_f64(), and there is no equivalent of the truncating as cast to reach for by accident.
Overflow is a compile error (when computable)
fn main() { let big: i64 = i64::MAX; // let boom = big + 1; // debug build: panics at runtime let safe = big.checked_add(1); println!("{:?}", safe); // None }
main! = |_args| { big : I64 big = 9_223_372_036_854_775_807 # echo!((big + 1).to_str()) # ^ COMPILE ERROR: "Integer addition overflowed!" echo!(big.to_str()) Ok({}) }
Rust panics on overflow in debug builds and wraps in release builds unless you use the checked_*/wrapping_* families. Roc evaluates every top-level and compile-time-known expression during compilation, so I64.max + 1 is rejected before the program ever runs — the commented line produces "Integer addition overflowed!" as a compile error, not a runtime panic.
Strings
String interpolation
fn main() { let name = "Rustacean"; let age: i64 = 10; println!("{name} is {age}"); let message = format!("{name} turns {}", age + 1); println!("{message}"); }
main! = |_args| { name = "Roc bird" age : I64 age = 10 echo!("${name} is ${age.to_str()}") message = "${name} turns ${(age + 1).to_str()}" echo!(message) Ok({}) }
Roc interpolates with \u0024{...} inside any string literal — no separate format! macro needed. The catch for Rust programmers: interpolation only accepts Str values. There is no Display-style auto-conversion, so numbers need an explicit .to_str() call.
Concatenation
fn main() { let start = String::from("Fast "); let combined = start + "and friendly"; println!("{combined}"); }
main! = |_args| { combined = "Fast ".concat("and friendly") echo!(combined) echo!(Str.concat("also ", "works")) Ok({}) }
Roc has no + for strings (and no ++ in the current build) — concatenation is the concat method, callable in method style or as Str.concat. There is also no String-vs-&str split to think about: Str is the only string type, and reference counting makes sharing it free.
Everyday string methods
fn main() { let padded = " systems "; println!("{}", padded.trim()); println!("{}", "ab".repeat(3)); println!("{}", "systems".starts_with("sys")); println!("{}", "systems".contains("stem")); }
main! = |_args| { padded = " systems " echo!(padded.trim()) echo!("ab".repeat(3)) echo!(Str.inspect("systems".starts_with("sys"))) echo!(Str.inspect("systems".contains("stem"))) Ok({}) }
The everyday method names are almost identical: trim, repeat, starts_with, ends_with, contains, plus drop_prefix/drop_suffix where Rust has strip_prefix. Str.inspect is the debug-formatter — the closest thing to Rust's {:?} — used here because Bool has no .to_str() in the current build.
Splitting and joining
fn main() { let parts: Vec<&str> = "red,green,blue".split(',').collect(); println!("{}", parts.len()); let joined = parts.join(" | "); println!("{joined}"); }
main! = |_args| { parts = "red,green,blue".split_on(",") echo!(parts.len().to_str()) joined = Str.join_with(parts, " | ") echo!(joined) Ok({}) }
Rust's split returns a lazy iterator you must collect; Roc's split_on just returns the List(Str) directly. Joining goes through Str.join_with(list, separator) — note it lives on Str, so it is not available in method style on the list.
Unicode escapes and UTF-8
fn main() { println!("rocket: \u{1F680}"); println!("{}", "héllo".len()); // bytes, not chars: 6 }
main! = |_args| { echo!("rocket: \u(1F680)") echo!("héllo".count_utf8_bytes().to_str()) Ok({}) }
Both languages store strings as UTF-8 and write code-point escapes almost identically — Rust's \u{1F680} versus Roc's \u(1F680). Where Rust's len() quietly returns bytes, Roc names the method count_utf8_bytes() so nobody mistakes it for a character count; grapheme-aware operations live in a separate unicode package in both ecosystems.
Lists
List literals
fn main() { let numbers: Vec<i64> = vec![3, 1, 4, 1, 5]; println!("{}", numbers.len()); println!("{:?}", numbers); }
main! = |_args| { numbers : List(I64) numbers = [3, 1, 4, 1, 5] echo!(numbers.len().to_str()) echo!(Str.inspect(numbers)) Ok({}) }
Roc's List is the one sequential collection — contiguous in memory like Vec, not a linked list, despite the functional-language name. The literal syntax needs no macro. Str.inspect plays the role of {:?} for quick debugging output.
map and filter — without the iterator dance
fn main() { let numbers: Vec<i64> = vec![1, 2, 3, 4, 5, 6]; let doubled_evens: Vec<i64> = numbers .iter() .filter(|number| *number % 2 == 0) .map(|number| number * 2) .collect(); println!("{:?}", doubled_evens); }
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3, 4, 5, 6] doubled_evens = numbers .keep_if(|number| number % 2 == 0) .map(|number| number * 2) echo!(Str.inspect(doubled_evens)) Ok({}) }
Roc's list transforms work directly on the list — no .iter() to enter iterator-land and no .collect() to leave it, and no *number dereferencing because there are no references. filter is named keep_if (with siblings drop_if and count_if). Thanks to opportunistic in-place mutation, this chain does not naively copy the list at each step.
fold and sum
fn main() { let numbers: Vec<i64> = vec![1, 2, 3, 4]; let total: i64 = numbers.iter().fold(0, |accumulator, number| accumulator + number); println!("{total}"); let quick: i64 = numbers.iter().sum(); println!("{quick}"); }
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3, 4] total = numbers.fold(0, |accumulator, number| accumulator + number) echo!(total.to_str()) echo!(numbers.sum().to_str()) Ok({}) }
The shape of fold is identical — initial accumulator, then a two-argument closure. Roc also ships fold_rev, fold_with_index, and fold_until (which can stop early by returning Break(value)), covering most of what Rust's richer iterator adapter zoo does.
Indexing cannot panic
fn main() { let numbers: Vec<i64> = vec![10, 20, 30]; // numbers[9] would panic at runtime! match numbers.get(9) { Some(value) => println!("{value}"), None => println!("out of bounds"), } let fallback = numbers.get(1).copied().unwrap_or(0); println!("{fallback}"); }
main! = |_args| { numbers : List(I64) numbers = [10, 20, 30] match numbers.get(9) { Ok(value) => echo!(value.to_str()) Err(_) => echo!("out of bounds") } fallback = numbers.get(1) ?? 0 echo!(fallback.to_str()) Ok({}) }
Rust gives you both the panicking numbers[9] and the safe .get(); Roc only has the safe path — .get() returns a Try, and the panicking subscript simply does not exist. The ?? operator is Roc's unwrap_or: it substitutes a default when the expression is Err.
Sorting and reversing return new lists
use std::cmp::Ordering; fn main() { let mut numbers: Vec<i64> = vec![3, 1, 2]; numbers.sort_by(|left, right| { if left < right { Ordering::Less } else if left > right { Ordering::Greater } else { Ordering::Equal } }); println!("{:?}", numbers); numbers.reverse(); println!("{:?}", numbers); }
main! = |_args| { numbers : List(I64) numbers = [3, 1, 2] ascending = numbers.sort_with(|left, right| { if left < right { LT } else if left > right { GT } else { EQ } }) echo!(Str.inspect(ascending)) echo!(Str.inspect(ascending.rev())) Ok({}) }
Rust's sort_by and reverse mutate the vector in place, which is why the binding must be mut. Roc's sort_with and rev return new lists — but when the original's reference count is 1, the "copy" is performed in place anyway, so the functional style costs nothing. The comparator returns Roc's LT/EQ/GT tags, a structural stand-in for std::cmp::Ordering.
Slices vs take/drop
fn main() { let numbers: Vec<i64> = vec![1, 2, 3, 4, 5]; let head = &numbers[..2]; // borrowed slice let tail = &numbers[2..]; println!("{:?} {:?}", head, tail); }
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3, 4, 5] head = numbers.take_first(2) tail = numbers.drop_first(2) echo!("${Str.inspect(head)} ${Str.inspect(tail)}") Ok({}) }
Rust slices are borrows, and the borrow checker makes sure they never outlive the vector. Roc has no borrows, so take_first/drop_first/sublist conceptually produce new lists that share structure via reference counting — you can return them, store them, and never think about lifetimes.
Searching with predicates
fn main() { let numbers: Vec<i64> = vec![2, 4, 6, 7]; println!("{}", numbers.iter().any(|number| number % 2 == 1)); println!("{}", numbers.iter().all(|number| *number > 0)); match numbers.iter().find(|number| **number > 5) { Some(found) => println!("found {found}"), None => println!("none"), } }
main! = |_args| { numbers : List(I64) numbers = [2, 4, 6, 7] echo!(Str.inspect(numbers.any(|number| number % 2 == 1))) echo!(Str.inspect(numbers.all(|number| number > 0))) match numbers.find_first(|number| number > 5) { Ok(found) => echo!("found ${found.to_str()}") Err(_) => echo!("none") } Ok({}) }
The predicate helpers map one-to-one: any, all, and find_first (Rust's find), plus find_first_index for Rust's position. Notice what is absent on the Roc side: no iter(), and none of the *number/**number dereference noise that Rust's closure-by-reference iterators require.
Records & Tuples
Records need no declaration
struct Point { x: f64, y: f64, } fn main() { let point = Point { x: 1.5, y: 2.5 }; println!("({}, {})", point.x, point.y); }
main! = |_args| { point = { x: 1.5, y: 2.5 } echo!("(${point.x.to_str()}, ${point.y.to_str()})") Ok({}) }
Roc records are structural: { x: 1.5, y: 2.5 } just exists, no struct declaration required, and two records with the same fields are the same type. Field access is the familiar dot syntax. When you do want a named, distinct type, Roc offers nominal records — covered in the Methods section.
Record update syntax
#[derive(Debug)] struct Config { verbose: bool, retries: u32, timeout_seconds: u32, } fn main() { let defaults = Config { verbose: false, retries: 3, timeout_seconds: 30 }; let custom = Config { retries: 5, ..defaults }; println!("{:?}", custom); }
main! = |_args| { defaults = { verbose: Bool.False, retries: 3.I64, timeout_seconds: 30.I64 } custom = { ..defaults, retries: 5 } echo!(Str.inspect(custom)) Ok({}) }
The spread-update syntax is nearly identical — Rust puts ..defaults last, Roc puts it first. Both produce a new value rather than mutating. Roc's version needs no #[derive(Debug)] to print: Str.inspect works on every value out of the box.
Tuples
fn main() { let pair = (1, "two"); println!("{} {}", pair.0, pair.1); let (number, word) = pair; println!("{number} {word}"); }
main! = |_args| { pair = (1.I64, "two") echo!("${pair.0.to_str()} ${pair.1}") (number, word) = pair echo!("${number.to_str()} ${word}") Ok({}) }
Tuples are the same idea with the same .0/.1 access syntax and the same destructuring. Like Rust, the index must be a literal so the compiler can verify it. One Roc note: a one-element "tuple" is just parentheses around a value — tuples start at two elements.
Naming a record type
struct Employee { name: String, department: String, } fn describe(employee: &Employee) -> String { format!("{} works in {}", employee.name, employee.department) } fn main() { let employee = Employee { name: String::from("Nia"), department: String::from("Compilers"), }; println!("{}", describe(&employee)); }
Employee : { name : Str, department : Str } describe : Employee -> Str describe = |employee| "${employee.name} works in ${employee.department}" main! = |_args| { employee = { name: "Nia", department: "Compilers" } echo!(describe(employee)) Ok({}) }
A type alias (single colon) gives a structural record a name for use in annotations, the way Rust programmers use struct for documentation as much as for typing. The alias is transparent — any record with those fields satisfies it. Note there is no &Employee: Roc functions receive values, and reference counting makes that as cheap as borrowing.
Tag Unions vs Enums
Enums become tag unions
enum Color { Red, Green, Blue, } fn to_hex(color: Color) -> &'static str { match color { Color::Red => "#FF0000", Color::Green => "#00FF00", Color::Blue => "#0000FF", } } fn main() { println!("{}", to_hex(Color::Green)); }
Color := [Red, Green, Blue] to_hex : Color -> Str to_hex = |color| match color { Red => "#FF0000" Green => "#00FF00" Blue => "#0000FF" } main! = |_args| { echo!(to_hex(Color.Green)) Ok({}) }
A nominal tag union (declared with :=) is Roc's enum: named, closed, and exhaustively matched. Construction is Color.Green, mirroring Color::Green. Inside a match the tags do not need qualification, and the compiler still verifies that every alternative is handled.
Payloads on variants
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() { println!("{}", area(&Shape::Circle(2.0))); println!("{}", area(&Shape::Rectangle(3.0, 4.0))); }
Shape := [Circle(Dec), Rectangle(Dec, Dec)] area : Shape -> Dec area = |shape| match shape { Circle(radius) => 3.14159 * radius * radius Rectangle(width, height) => width * height } main! = |_args| { echo!(area(Shape.Circle(2)).to_str()) echo!(area(Shape.Rectangle(3, 4)).to_str()) Ok({}) }
Payload-carrying variants translate directly, destructuring and all. At runtime a multi-payload tag compiles to the same layout as a tag holding a tuple — the same "discriminant plus fields" representation a Rust enum uses.
Tags without any declaration
// Rust requires the enum to exist before use: enum Period { Morning, Afternoon, } fn main() { let hour = 14; let period = if hour < 12 { Period::Morning } else { Period::Afternoon }; let label = match period { Period::Morning => "AM", Period::Afternoon => "PM", }; println!("{label}"); }
main! = |_args| { hour : I64 hour = 14 period = if hour < 12 { Morning } else { Afternoon } label = match period { Morning => "AM" Afternoon => "PM" } echo!(label) Ok({}) }
This is the feature with no Rust equivalent: structural tags spring into existence at the point of use. The if gives period the inferred type [Morning, Afternoon] — no declaration anywhere — and the match is still checked for exhaustiveness. It is Rust-enum safety with Python-level ceremony.
Open unions: extensibility in the type
// The closest Rust concept is #[non_exhaustive], // which forces downstream matches to add a catch-all: enum Signal { Go, Stop, Custom(u32), } fn describe(signal: Signal) -> &'static str { match signal { Signal::Go => "go", Signal::Stop => "stop", _ => "something else", } } fn main() { println!("{}", describe(Signal::Go)); println!("{}", describe(Signal::Custom(7))); }
describe : [Go, Stop, ..] -> Str describe = |signal| match signal { Go => "go" Stop => "stop" _ => "something else" } main! = |_args| { echo!(describe(Go)) echo!(describe(Custom(7.I64))) Ok({}) }
The .. in [Go, Stop, ..] makes the union open: the function accepts those tags plus any others, as long as a catch-all branch handles the rest. Rust approximates this socially with #[non_exhaustive]; Roc expresses it structurally in the type itself, and callers can pass tags the author never named.
Recursive types without Box
enum Tree { Leaf(i64), Node(Box<Tree>, Box<Tree>), } fn sum_tree(tree: &Tree) -> i64 { match tree { Tree::Leaf(value) => *value, Tree::Node(left, right) => sum_tree(left) + sum_tree(right), } } fn main() { let tree = Tree::Node( Box::new(Tree::Leaf(1)), Box::new(Tree::Node(Box::new(Tree::Leaf(2)), Box::new(Tree::Leaf(3)))), ); println!("{}", sum_tree(&tree)); }
Tree := [Leaf(I64), Node(Tree, Tree)] sum_tree : Tree -> I64 sum_tree = |tree| match tree { Leaf(value) => value Node(left, right) => sum_tree(left) + sum_tree(right) } main! = |_args| { tree = Tree.Node(Tree.Leaf(1), Tree.Node(Tree.Leaf(2), Tree.Leaf(3))) echo!(sum_tree(tree).to_str()) Ok({}) }
In Rust, a recursive enum needs Box so the compiler can compute its size, and every construction site pays the Box::new tax. Roc's recursive tag unions are automatically heap-allocated and reference-counted behind the scenes — the type declaration and the construction both read like the textbook version.
Pattern Matching
match is exhaustive in both
fn main() { let status_code = 404; let message = match status_code { 200 => "ok", 404 => "not found", _ => "something else", }; println!("{message}"); }
main! = |_args| { status_code : I64 status_code = 404 message = match status_code { 200 => "ok" 404 => "not found" _ => "something else" } echo!(message) Ok({}) }
Roc's match is Rust's match minus the commas between arms — same => arrows, same catch-all _, same compile error if a case is missing. Roc can also match directly on strings, which Rust allows too; both check exhaustiveness structurally.
Guards
fn describe(number: i64) -> &'static str { match number { 0 => "zero", n if n < 0 => "negative", n if n % 2 == 0 => "positive even", _ => "positive odd", } } fn main() { println!("{}", describe(0)); println!("{}", describe(-5)); println!("{}", describe(8)); }
describe : I64 -> Str describe = |number| match number { 0 => "zero" n if n < 0 => "negative" n if n % 2 == 0 => "positive even" _ => "positive odd" } main! = |_args| { echo!(describe(0)) echo!(describe(-5)) echo!(describe(8)) Ok({}) }
Guards are character-for-character identical: pattern if condition =>. As in Rust, a guarded branch does not count toward exhaustiveness, so the final catch-all is still required.
Or-patterns
fn size_class(number: i64) -> &'static str { match number { 1 | 2 | 3 => "small", _ => "big", } } fn main() { println!("{}", size_class(2)); println!("{}", size_class(9)); }
size_class : I64 -> Str size_class = |number| match number { 1 | 2 | 3 => "small" _ => "big" } main! = |_args| { echo!(size_class(2)) echo!(size_class(9)) Ok({}) }
Alternative patterns use the same | separator in both languages. This is one of several places where Roc's designers deliberately kept Rust's syntax, on the theory that Rust got it right.
List patterns and rest bindings
fn describe(numbers: &[i64]) -> String { match numbers { [] => String::from("empty"), [single] => format!("one: {single}"), [first, rest @ ..] => format!("first {first}, {} more", rest.len()), } } fn main() { println!("{}", describe(&[])); println!("{}", describe(&[7])); println!("{}", describe(&[1, 2, 3])); }
describe : List(I64) -> Str describe = |numbers| match numbers { [] => "empty" [single] => "one: ${single.to_str()}" [first, .. as rest] => "first ${first.to_str()}, ${rest.len().to_str()} more" } main! = |_args| { echo!(describe([])) echo!(describe([7])) echo!(describe([1, 2, 3])) Ok({}) }
Slice patterns translate almost symbol-for-symbol: Rust's rest @ .. becomes Roc's .. as rest. Roc matches directly on the List — no need to take a slice view of it first.
Try vs Result
Result becomes Try
fn parse_score(text: &str) -> Result<i64, String> { text.trim().parse::<i64>().map_err(|_| format!("bad score: {text}")) } fn main() { match parse_score("95") { Ok(score) => println!("score: {score}"), Err(problem) => println!("{problem}"), } match parse_score("not a number") { Ok(score) => println!("score: {score}"), Err(problem) => println!("{problem}"), } }
parse_score : Str -> Try(I64, [BadScore(Str)]) parse_score = |text| match I64.from_str(text.trim()) { Ok(score) => Ok(score) Err(_) => Err(BadScore(text)) } main! = |_args| { match parse_score("95") { Ok(score) => echo!("score: ${score.to_str()}") Err(BadScore(bad)) => echo!("bad score: ${bad}") } match parse_score("not a number") { Ok(score) => echo!("score: ${score.to_str()}") Err(BadScore(bad)) => echo!("bad score: ${bad}") } Ok({}) }
Try(ok, err) is Result<T, E> under a new name (it was called Result in older Roc). Fallible parsing lives on the number types — I64.from_str — rather than on the string. Note the error type: [BadScore(Str)] is an anonymous structural tag union, so you get a typed, pattern-matchable error without declaring an error enum.
The ? operator
fn first_word(text: &str) -> Option<&str> { let word = text.split_whitespace().next()?; Some(word.trim_end_matches('!')) } fn main() { println!("{:?}", first_word("hello there")); println!("{:?}", first_word("")); }
show_first! = |numbers| { first = numbers.first()? echo!("first: ${first.to_str()}") Ok({}) } main! = |_args| { numbers : List(I64) numbers = [5, 6, 7] show_first!(numbers) }
Rust's beloved ? exists in Roc with the same meaning: unwrap the Ok or early-return the Err to the caller. Here main! simply passes show_first!'s Try along as its own result. Roc's designers cite ? as a direct Rust borrowing — one of Rust's ideas that survived the trip intact.
Defaults with ??
fn main() { let numbers: Vec<i64> = vec![]; let first = numbers.first().copied().unwrap_or(0); println!("{first}"); }
main! = |_args| { numbers : List(I64) numbers = [] first = numbers.first() ?? 0 echo!(first.to_str()) Ok({}) }
The ?? operator is unwrap_or as syntax: use the Ok payload, or fall back to the default on Err. For transforming instead of defaulting, Try carries the combinators you expect from Result: map_ok, map_err, ok_or, is_ok, and friends.
No null, no Option — tags fill the gap
fn find_user(id: u32) -> Option<String> { if id == 1 { Some(String::from("Ada")) } else { None } } fn main() { match find_user(1) { Some(name) => println!("found {name}"), None => println!("missing"), } }
find_user : U32 -> [Found(Str), Missing] find_user = |id| { if id == 1 { Found("Ada") } else { Missing } } main! = |_args| { match find_user(1) { Found(name) => echo!("found ${name}") Missing => echo!("missing") } Ok({}) }
Roc has no null and, surprisingly, no built-in Option either. Where Rust reaches for Option, Roc either returns a Try (when the absence is an error) or an ad-hoc structural union like [Found(Str), Missing] — which needs no declaration and often carries better names than Some/None would.
Structural errors compose without an enum
// In Rust, combining error types requires an enum // (or a crate like thiserror/anyhow): #[derive(Debug)] enum ConfigError { MissingName, BadPort(String), } fn read_port(text: &str) -> Result<u16, ConfigError> { text.parse::<u16>().map_err(|_| ConfigError::BadPort(text.to_string())) } fn main() { println!("{:?}", read_port("8080")); println!("{:?}", read_port("eighty")); }
read_port : Str -> Try(U16, [BadPort(Str)]) read_port = |text| match U16.from_str(text) { Ok(port) => Ok(port) Err(_) => Err(BadPort(text)) } main! = |_args| { echo!(Str.inspect(read_port("8080"))) echo!(Str.inspect(read_port("eighty"))) Ok({}) }
Because Roc error tags are structural and unions are open, errors from different functions merge automatically: a function calling two fallible helpers infers the union of both error sets, with no From impls, no boxed dyn Error, and no thiserror derive. This is the part of Roc error handling Rust programmers tend to envy most.
Functions & Closures
One syntax for all functions
fn add(left: i64, right: i64) -> i64 { left + right } fn main() { let also_add = |left: i64, right: i64| left + right; println!("{}", add(2, 3)); println!("{}", also_add(2, 3)); }
add : I64, I64 -> I64 add = |left, right| left + right main! = |_args| { also_add = |left, right| left + right echo!(add(2, 3).to_str()) echo!(also_add(2.I64, 3.I64).to_str()) Ok({}) }
Roc took Rust's closure syntax — |args| body — and made it the only function syntax in the language. There is no separate fn form: a top-level function is just a named value that happens to be a lambda, which is why the annotation sits above it like any other value's.
Closures capture without ceremony
fn main() { let amount = 10; let add_amount = move |number: i64| number + amount; println!("{}", add_amount(5)); let greeting = String::from("hi "); let greet = move |name: &str| format!("{greeting}{name}"); println!("{}", greet("Roc")); }
main! = |_args| { amount : I64 amount = 10 add_amount = |number| number + amount echo!(add_amount(5).to_str()) greeting = "hi " greet = |name| "${greeting}${name}" echo!(greet("Roc")) Ok({}) }
Rust closures force a decision — borrow or move? Fn, FnMut, or FnOnce? Roc closures just capture: values are immutable and reference-counted, so there is nothing to move, borrow, or classify. Returning a closure from a function needs no Box<dyn Fn> either.
Generic functions
fn identity<T>(value: T) -> T { value } fn main() { println!("{}", identity("same")); println!("{}", identity(7)); }
identity : a -> a identity = |value| value main! = |_args| { echo!(identity("same")) echo!(identity(7.I64).to_str()) Ok({}) }
Lowercase names in a Roc type annotation are type variables — a -> a is Rust's <T>(T) -> T without the declaration site. Like Rust, Roc monomorphizes: each concrete use compiles to specialized code with no runtime dispatch cost.
where clauses instead of trait bounds
use std::fmt::Display; fn first_to_string<T>(items: &[T]) -> String where T: Display, { match items.first() { Some(item) => item.to_string(), None => String::from("(empty)"), } } fn main() { println!("{}", first_to_string(&[42, 7])); println!("{}", first_to_string::<i64>(&[])); }
first_to_str : List(a) -> Str where [a.to_str : a -> Str] first_to_str = |items| match items.first() { Ok(item) => item.to_str() Err(_) => "(empty)" } main! = |_args| { numbers : List(I64) numbers = [42, 7] echo!(first_to_str(numbers)) empty : List(I64) empty = [] echo!(first_to_str(empty)) Ok({}) }
Roc's where clause constrains a type variable by the methods it must provide — where [a.to_str : a -> Str] reads like a structural, single-method trait bound. There is no trait to declare or implement: any type with a matching to_str method satisfies the constraint, and dispatch is resolved statically, exactly like Rust generics.
Recursion with guaranteed TCO
fn count_down(from: i64) -> i64 { if from <= 0 { 0 } else { count_down(from - 1) // NOT a guaranteed tail call in Rust } } fn main() { println!("{}", count_down(100_000)); }
sum_to : I64, I64 -> I64 sum_to = |limit, accumulator| { if limit <= 0 { accumulator } else { sum_to(limit - 1, accumulator + limit) } } main! = |_args| { echo!(sum_to(10_000, 0).to_str()) Ok({}) }
Rust does not guarantee tail-call optimization — deep recursion can overflow the stack, and the become keyword is still experimental. Roc guarantees that self-tail-calls compile to a loop, so the accumulator-passing style shown here is safe by design. Bonus: because sum_to is pure and its arguments are known, this example is actually computed at compile time — the running program just prints the answer.
Control Flow
if is an expression
fn main() { let score = 85; let grade = if score >= 90 { "A" } else if score >= 80 { "B" } else { "C" }; println!("{grade}"); }
main! = |_args| { score : I64 score = 85 grade = if score >= 90 { "A" } else if score >= 80 { "B" } else { "C" } echo!(grade) Ok({}) }
Both languages make if an expression whose branches must agree in type, and both chain else if the same way. Roc's version is sugar for a match on Bool, and there is no truthiness — the condition must be an actual Bool, just as in Rust. Roc also drops the trailing semicolon question entirely: there are no semicolons.
while loops and break
fn main() { let mut count: i64 = 0; while count < 5 { count += 1; if count == 3 { break; } } println!("{count}"); }
main! = |_args| { var $count = 0.I64 while $count < 5 { $count = $count + 1 if $count == 3 { break } } echo!($count.to_str()) Ok({}) }
Yes, the pure functional language has real while loops with break — mutation through var is local to the function, so it cannot leak or race, and the compiler is free to allow honest imperative iteration. Rust programmers can keep writing loops instead of translating everything into folds.
Iterating a collection
fn main() { let words = vec!["alpha", "beta", "gamma"]; for word in &words { println!("{word}"); } }
main! = |_args| { words = ["alpha", "beta", "gamma"] words.for_each!(|word| echo!(word)) Ok({}) }
The langref specifies for word in words { } loops, but the pinned nightly build this page runs on rejects them (the iterator protocol is still being wired up) — so the idiomatic working form today is .for_each!(...), whose ! marks that its closure performs effects. Expect for to light up in a future nightly.
Early return
fn clamp_positive(number: i64) -> i64 { if number < 0 { return 0; } number } fn main() { println!("{}", clamp_positive(-5)); println!("{}", clamp_positive(9)); }
clamp_positive : I64 -> I64 clamp_positive = |number| { if number < 0 { return 0 } number } main! = |_args| { echo!(clamp_positive(-5).to_str()) echo!(clamp_positive(9).to_str()) Ok({}) }
Roc has a genuine return statement for early exits, and the same convention as Rust for the happy path: the final expression of the function body is the return value, no keyword needed. Even the "guard clause returns early, main logic flows to the bottom" style translates unchanged.
Purity & Effects
Effects are visible in every signature
// Rust does not track purity: any function may do I/O, // and nothing in the signature reveals it. fn quiet_looking_helper(name: &str) -> String { println!("(surprise: I/O happened here)"); format!("hello {name}") } fn main() { let greeting = quiet_looking_helper("Rust"); println!("{greeting}"); }
# Pure: Str -> Str (arrow ->) describe : Str -> Str describe = |name| "hello ${name}" # Effectful: Str => {} (fat arrow =>, name ends in !) announce! : Str => {} announce! = |name| { echo!(describe(name)) } main! = |_args| { announce!("Roc") Ok({}) }
This is Roc's counterpart to Rust's unsafe discipline, applied to I/O: an effectful function must carry a ! suffix and a => arrow in its type, and a pure function (->) cannot call an effectful one. You always know from the call site whether a function can touch the outside world. Rust's signatures are silent on the question.
Top-level values run at compile time
// Rust const evaluation is opt-in and restricted: const LIMIT: i64 = 10; const SQUARED: i64 = LIMIT * LIMIT; // must be a const fn context fn main() { println!("{SQUARED}"); }
limit : I64 limit = 10 squared : I64 squared = limit * limit main! = |_args| { echo!(squared.to_str()) Ok({}) }
Every top-level Roc value is evaluated at compile time — purity makes that always legal, so there is no const fn subset to learn. A top-level definition that would crash (say, an integer overflow) becomes a compile error instead of a runtime panic. It is const evaluation as the default instead of the exception.
Assertions: assert! vs expect
fn main() { let total = 2 + 2; assert_eq!(total, 4); println!("after the assertion"); }
main! = |_args| { total : I64 total = 2 + 2 expect total == 4 echo!("after the expect") Ok({}) }
An inline expect checks a condition mid-program, like assert!; if it fails, the program halts with a nonzero exit code. The same keyword doubles as Roc's unit-testing construct: top-level expect blocks are collected and run by roc test, filling the role of Rust's #[test] functions with less machinery.
crash vs panic!
fn main() { let configuration_found = false; if !configuration_found { panic!("no configuration — cannot continue"); } }
main! = |_args| { configuration_found : Bool configuration_found = False if !configuration_found { crash "no configuration — cannot continue" } Ok({}) }
Roc's crash is panic!: an unrecoverable abort for unreachable states, with the platform deciding what happens next — this row is display-only because the WASM host powering this page stops its instance on a crash (were it run, the Rust side would exit with the panic status 101). Roc culture matches Rust culture here: crash is for impossible states; expected failures should be Try values.
Memory: No Ownership
Sharing without moves or clones
fn main() { let original = String::from("shared text"); let copy = original.clone(); // without .clone(): move error println!("{original}"); println!("{copy}"); }
main! = |_args| { original = "shared text" copy = original echo!(original) echo!(copy) Ok({}) }
In Rust, let copy = original; would move the String and make original unusable — hence the .clone(). Roc has no move semantics at all: both names simply share the same reference-counted value, the compiler inserts the retain/release bookkeeping, and nothing is deep-copied. The entire ownership chapter of the Rust book has no Roc equivalent to learn.
No lifetimes to annotate
fn longest<'a>(left: &'a str, right: &'a str) -> &'a str { if left.len() >= right.len() { left } else { right } } fn main() { let winner = longest("borrow", "checker"); println!("{winner}"); }
longest : Str, Str -> Str longest = |left, right| { if left.count_utf8_bytes() >= right.count_utf8_bytes() { left } else { right } } main! = |_args| { echo!(longest("reference", "counting")) Ok({}) }
The classic lifetime-annotation teaching example simply is not a problem in Roc: functions take and return values, reference counting keeps everything alive exactly as long as needed, and 'a has nothing to attach to. The cost is the refcount bookkeeping; the benefit is that this function signature never fights you.
Rc everywhere — inserted by the compiler
use std::rc::Rc; fn main() { let shared = Rc::new(vec![1, 2, 3]); let another = Rc::clone(&shared); println!("count: {}", Rc::strong_count(&shared)); println!("{:?} {:?}", shared, another); }
main! = |_args| { shared : List(I64) shared = [1, 2, 3] another = shared echo!(Str.inspect(shared)) echo!(Str.inspect(another)) Ok({}) }
Roc's memory model is roughly "what if Rc<T> were automatic, invisible, and optimized by the compiler." Heap values (strings, lists, recursive tags) are reference-counted; stack values (numbers, small records, tuples) are not. Because Roc statically rules out reference cycles, the classic Rc leak scenario cannot be expressed — no Weak pointers needed.
Functional updates mutate when safe
fn main() { let mut numbers: Vec<i64> = vec![1, 2, 3]; numbers[1] = 99; // requires exclusive &mut access println!("{:?}", numbers); }
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3] updated = numbers.set(1, 99) ?? numbers echo!(Str.inspect(updated)) Ok({}) }
Roc's set looks like it copies the list, but the compiler uses Perceus reference counting to mutate in place whenever the value is unshared (reference count 1) — same machine code as the Rust version, without &mut. Rust guarantees exclusivity through the borrow checker; Roc discovers it through the reference count. (set returns a Try because the index might be out of bounds, so ?? supplies the fallback.)
Methods & Static Dispatch
impl blocks become .{ } blocks
struct Counter { value: i64, } impl Counter { fn new() -> Counter { Counter { value: 0 } } fn increment(self) -> Counter { Counter { value: self.value + 1 } } fn describe(&self) -> String { format!("count is {}", self.value) } } fn main() { let counter = Counter::new().increment().increment(); println!("{}", counter.describe()); }
Counter := { value : I64 }.{ new : () -> Counter new = || { value: 0 } increment : Counter -> Counter increment = |{ value }| { value: value + 1 } describe : Counter -> Str describe = |counter| "count is ${counter.value.to_str()}" } main! = |_args| { counter = Counter.new().increment().increment() echo!(counter.describe()) Ok({}) }
A nominal type's trailing .{ } block holds its associated methods — Roc's impl. There is no self keyword: a method is any associated function whose first argument is the type, and value.method(args) dispatches to it statically. Method chaining works exactly as in Rust.
Newtypes
struct UserId(u64); fn greet(user_id: &UserId) -> String { format!("user #{}", user_id.0) } fn main() { let user_id = UserId(42); // greet(&42) would be a type error — u64 is not UserId println!("{}", greet(&user_id)); }
UserId := { value : U64 } greet : UserId -> Str greet = |user_id| "user #${user_id.value.to_str()}" main! = |_args| { user_id = UserId.{ value: 42 } echo!(greet(user_id)) Ok({}) }
A nominal type (:=) is a distinct type the compiler will not silently mix with its backing representation — passing a bare record where a UserId is expected is a type error, exactly like Rust's tuple-struct newtype. Roc also allows directly value-backed newtypes (UserId := U64, constructed as UserId.(42)), but extracting their payload is not yet wired up in the pinned nightly build, so the record-backed form shown here is today's practical pattern.
Static dispatch only — no dyn
use std::fmt::Display; fn print_static<T: Display>(value: T) { println!("{value}"); // monomorphized per T } fn print_dynamic(value: &dyn Display) { println!("{value}"); // vtable lookup at runtime } fn main() { print_static(42); print_dynamic(&"boxed dispatch"); }
print_value! : a => {} where [a.to_str : a -> Str] print_value! = |value| { echo!(value.to_str()) } main! = |_args| { print_value!(42.I64) print_value!(2.5.Dec) Ok({}) }
Rust lets you choose between monomorphized generics and dyn trait objects. Roc removes the choice: all dispatch is static, resolved at compile time, with zero runtime overhead — there is no vtable mechanism in the language at all. If you need heterogeneous collections, you reach for tag unions instead of trait objects.
Gotchas for Rust Programmers
Untyped integers print as decimals
fn main() { let numbers = vec![1, 2, 3]; // Vec<i32> by default println!("{:?}", numbers); // [1, 2, 3] }
main! = |_args| { echo!(Str.inspect([1, 2, 3])) numbers : List(I64) numbers = [1, 2, 3] echo!(Str.inspect(numbers)) Ok({}) }
The number-one surprise: Roc's unconstrained numeric literals default to Dec, so the first line prints [1.0, 2.0, 3.0]. Rust's i32 default never changes how output looks; Roc's Dec default does. The habit to build: annotate any binding whose value you intend to display as an integer.
Interpolation will not auto-convert
fn main() { let count = 5; // Display is invoked automatically: println!("count is {count}"); }
main! = |_args| { count : I64 count = 5 # echo!("count is ${count}") # ^ TYPE MISMATCH: interpolation takes Str, found I64 echo!("count is ${count.to_str()}") Ok({}) }
Rust's format! quietly calls Display; Roc's interpolation accepts only Str, so every number needs its .to_str(). It feels noisy for a day and then becomes automatic — and it means no accidental reliance on a type's formatting impl.
Bare True is not Bool
fn main() { let ready = false; // unambiguously bool println!("{}", !ready); }
main! = |_args| { ready : Bool ready = False echo!(Str.inspect(!ready)) Ok({}) }
Because tags are structural, a bare False is just the tag False in an anonymous union — not necessarily a Bool — so operators like ! may fail to resolve without context. Annotate ready : Bool (or write Bool.False) and everything works. The same structural-tags superpower that removes enum boilerplate creates this one sharp edge.
The stdlib is still settling
fn main() { // Rust std has been stable since 2015: let replaced = "roc rocks".replace("rocks", "flies"); println!("{replaced}"); }
main! = |_args| { # No Str.replace in this build — compose what exists: echo!("roc rocks".drop_suffix("rocks").concat("flies")) Ok({}) }
Roc is pre-1.0 and this page pins a specific nightly compiler, so some familiar operations are missing or renamed: there is no Str.replace yet, string length is count_utf8_bytes(), list reversal is rev(), the pipe operator |> was removed in favor of method chains, and triple-quoted multiline strings are not implemented. Expect this table to improve as Roc approaches its first stable release.