PONY λ M2 Modula-2

Ruby.CodeCompared.To/Roc

An interactive executable cheatsheet for Rubyists learning Roc

Ruby 4.0 Roc nightly (2026-07-02)
Hello World & The Platform Model
Hello, World
puts "Hello, World!"
main! = |_args| { echo!("Hello, World!") Ok({}) }
Ruby scripts start executing at the first line; a Roc program starts at main!, a function receiving the program arguments. The ! suffix on main! and echo! marks them as effectful — Roc tracks purity in the type system, a theme this page returns to. Ok({}) is the success return value: {} is the empty record, Roc's equivalent of returning nil from a method that exists only for its side effects.
Where I/O comes from
# Ruby can perform any I/O from anywhere: $stdout.write("written via the global $stdout ") puts "Kernel#puts is always available"
main! = |_args| { echo!("every effect comes from the platform") Ok({}) }
This is the most foreign idea in Roc for a Rubyist. Ruby ships File, Net::HTTP, and Thread in the standard library, and any line of any method may use them. A Roc program cannot perform any I/O on its own: it is embedded by a platform — a host written in another language — 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.
How programs succeed or fail
# A Ruby script that runs to the end exits 0. # An uncaught exception exits nonzero. puts "all good"
main! = |_args| { echo!("all good") Ok({}) }
Ruby signals failure by letting an exception escape; Roc has no exceptions, so main! returns a Try value instead: Ok({}) exits 0, and returning Err(SomeTag) makes the program exit with code 1. The same Ok/Err pair replaces begin/rescue everywhere — covered in the "No nil, No Exceptions" section below.
A Surprisingly Familiar Surface
Comments
# Single-line comment count = 42 # inline comment =begin Multi-line comment block (rarely used in practice) =end puts count
main! = |_args| { # Single-line comment count : I64 count = 42 # inline comment # Roc has no block comment syntax. # Multi-line comments use # on each line. echo!(count.to_str()) Ok({}) }
Roc uses Ruby's # comment character — one of many small choices that make Roc feel familiar. There is no equivalent of =begin/=end, which few Rubyists will miss. Doc comments use ## before a declaration.
Everything is an expression
score = 85 grade = if score >= 90 "A" elsif score >= 80 "B" else "C" end puts 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 result can be assigned, and both make the last expression of a function its return value. Roc swaps elsif/end for braces, requires the branches to agree in type, and has no statements at all — there is nothing in the language that does not produce a value.
String interpolation: #{} becomes ${}
name = "Matz" language = "Ruby" puts "#{name} created #{language}"
main! = |_args| { name = "Richard Feldman" language = "Roc" echo!("${name} created ${language}") Ok({}) }
Interpolation works in every string literal in both languages — Roc just spells it ${...} instead of #{...}. The one difference that will bite: Roc interpolation accepts only Str values, with no automatic to_s conversion (see the Strings section).
p and inspect become Str.inspect
person = { name: "Matz", language: "Ruby" } p person numbers = [1, 2, 3] puts numbers.inspect
main! = |_args| { person = { name: "Matz", language: "Ruby" } echo!(Str.inspect(person)) numbers : List(I64) numbers = [1, 2, 3] echo!(Str.inspect(numbers)) Ok({}) }
Roc's Str.inspect is Kernel#p / Object#inspect: it renders any value in a readable debug format with no setup required. Note that record fields print in alphabetical order, not insertion order — records are sets of fields, not sequences like Ruby hashes.
Values & Immutability
Bindings cannot be reassigned
greeting = "hello" greeting = "reassigned freely" puts greeting
main! = |_args| { greeting = "bound once" # greeting = "nope" # ^ COMPILE ERROR: duplicate definition echo!(greeting) Ok({}) }
A Ruby local variable is a name you can point at anything, as often as you like. A Roc binding is a permanent definition: name = value and it never changes. This is not a convention like Ruby's SCREAMING_CASE constants (which only warn on reassignment) — it is a compile error, with a separate opt-in syntax for the rare cases that need mutation (next row).
Frozen by default vs frozen, period
greeting = "hello" puts greeting.frozen? # Ruby 4.0: literals are frozen mutable = +"hello" # unary + gives an unfrozen copy mutable << " world" puts mutable
main! = |_args| { greeting = "hello" # There is no mutable string to unlock — # every operation returns a new value: combined = greeting.concat(" world") echo!(combined) echo!(greeting) Ok({}) }
Ruby 4.0 finally made string literals frozen by default, after a decade of magic comments — but +"..." still unlocks a mutable copy, and arrays and hashes remain mutable everywhere. Roc goes all the way: every value of every type is immutable, so the "did some other code mutate this?" question that motivated frozen literals cannot arise for any value at all.
Opt-in mutation: var and the $ sigil
total = 0 total += 5 total += 10 puts total
main! = |_args| { var $total = 0.I64 $total = $total + 5 $total = $total + 10 echo!($total.to_str()) Ok({}) }
When you genuinely want a counter, Roc's var declares a reassignable local — and the $ sigil must appear on every use, so mutation is impossible to miss when reading code. A Rubyist's reflex may be "that looks like a global": it is the opposite, since a var is confined to the function that declares it and can never be shared. Roc also has no +=; write the addition out.
Destructuring assignment
x, y = 3, 4 puts "#{x}, #{y}" person = { name: "Grace", age: 85 } person => { name:, age: } puts "#{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({}) }
Ruby's parallel assignment and rightward hash-pattern matching both have direct Roc equivalents: tuples and records destructure on the left of =. The Roc restriction is that the pattern must be irrefutable — it can never fail — so you cannot destructure an Ok(value) this way; that requires match, because the Err case would be unhandled.
Types Without Type Ceremony
Duck typing vs full inference
def double(value) = value * 2 puts double(21) # works on Integer puts double("ab") # also works on String! # double(nil) # NoMethodError — at runtime
double : I64 -> I64 double = |number| number * 2 main! = |_args| { echo!(double(21).to_str()) # double("ab") would be a COMPILE error, # caught before the program ever runs. Ok({}) }
Ruby checks nothing until the moment a method call fails; Roc checks everything before the program runs — yet Roc code carries barely more annotation than Ruby, because full Hindley–Milner inference works out every type from usage. Delete the double : I64 -> I64 line and the example still compiles; the annotation is documentation the compiler verifies rather than a requirement.
Annotations live inline (and are optional)
# Ruby type signatures live OUTSIDE the language, # in separate .rbs files or Sorbet sigs: # # def describe: (String) -> String def describe(name) = "hello #{name}" puts describe("RBS")
describe : Str -> Str describe = |name| "hello ${name}" main! = |_args| { echo!(describe("Roc")) Ok({}) }
Ruby's gradual-typing story (RBS files, Sorbet) keeps signatures in a parallel universe that can drift from the code. A Roc annotation is one line of the same file — name : Type above the definition — checked on every compile, and omittable wherever you would rather let inference do the work.
Typed number literals
# Ruby has one Integer type and one Float type; # literals never need a type marker. small = 42 ratio = 2.5 puts "#{small} #{ratio}"
main! = |_args| { small = 42.I64 ratio = 2.5.F64 echo!("${small.to_str()} ${ratio.to_str()}") Ok({}) }
Roc has many number types (next section), so a literal can name its type with a dot suffix: 42.I64, 2.5.F64. The mechanism is extensible — any type with a from_numeral method can be used as a suffix, so a custom Money type could accept 19.99.Money, checked and evaluated at compile time.
What untyped literals become
# Every Ruby integer literal is just an Integer: p [1, 2, 3] # [1, 2, 3]
main! = |_args| { echo!(Str.inspect([1, 2, 3])) typed : List(I64) typed = [1, 2, 3] echo!(Str.inspect(typed)) Ok({}) }
Run the Roc side: the first list prints as [1.0, 2.0, 3.0]! Unconstrained number literals default to Dec — a 128-bit fixed-point decimal — not to an integer type. This surprises everyone once. Annotate the binding (or use a literal suffix) whenever the printed representation matters.
Numbers
One Integer vs a menu of sizes
# Ruby integers grow without bound, automatically: huge = 2 ** 100 puts huge puts huge.class # still Integer
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({}) }
Ruby has one Integer that silently promotes to arbitrary precision. Roc asks you to pick a size — I8/U8 through I128/U128 — the price of compiling to machine integers with no interpreter underneath. Underscore digit separators work in both languages. List lengths and indices are always U64.
Dec: exact decimals by default
puts 0.1 + 0.2 # 0.30000000000000004 require "bigdecimal" sum = BigDecimal("0.1") + BigDecimal("0.2") puts sum.to_s("F") # 0.3
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({}) }
Ruby's float literals are IEEE 754 doubles, and exact decimal math means opting into BigDecimal with string constructors. Roc flips the default: an unconstrained decimal literal is a Dec — a 128-bit fixed-point decimal — so 0.1 + 0.2 is exactly 0.3. Hardware floats (F32/F64) are still there when you want speed, and they produce the same 0.30000000000000004 Ruby prints.
Integer division and remainder
puts 17 / 5 # 3 — Integer / Integer truncates! puts 17.0 / 5 # 3.4 puts 17 % 5 # 2
main! = |_args| { quotient : I64 quotient = 17 // 5 remainder : I64 remainder = 17 % 5 echo!("${quotient.to_str()} ${remainder.to_str()}") exact : Dec exact = 17 / 5 echo!(exact.to_str()) Ok({}) }
Ruby overloads / — integer operands truncate, float operands divide exactly — which is a classic beginner trap. Roc splits the two meanings into two operators: // always performs integer division, and plain / is reserved for exact division on Dec and floats, so 17 / 5 is 3.4 with no .0 coercion trick required.
to_i / to_f / to_s become to_i64 / to_f64 / to_str
count = 200 puts count.to_f / 3 puts count.to_s + " units" puts "42".to_i + 1
main! = |_args| { count : I64 count = 200 echo!((count.to_f64() / 3).to_str()) echo!(count.to_str().concat(" units")) parsed = I64.from_str("42") ?? 0 echo!((parsed + 1).to_str()) Ok({}) }
The conversion-method naming convention carried straight over from Ruby — just with sizes attached: .to_i64(), .to_f64(), .to_str(). The big behavioral difference is parsing: Ruby's "abc".to_i silently returns 0, while Roc's I64.from_str returns a Try that forces you to handle the failure (here with the ?? default operator).
Overflow is a compile error (when computable)
# Ruby integers cannot overflow — they just grow: big = 9_223_372_036_854_775_807 puts big + 1 # 9223372036854775808, no drama
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({}) }
Ruby sidesteps overflow by promoting to bignum arithmetic at runtime. Roc's fixed-size integers can overflow — but the compiler evaluates every expression it can at compile time, so I64-max-plus-one is rejected before the program runs. Overflow that only happens at runtime crashes the program rather than silently wrapping.
Strings
Interpolation will not call to_s for you
name = "Rubyist" age = 30 # Ruby calls to_s on anything interpolated: puts "#{name} is #{age}" message = "#{name} turns #{age + 1}" puts message
main! = |_args| { name = "Roc bird" age : I64 age = 30 echo!("${name} is ${age.to_str()}") message = "${name} turns ${(age + 1).to_str()}" echo!(message) Ok({}) }
Ruby interpolation quietly calls to_s on any object; Roc interpolation accepts only Str, so every number needs an explicit .to_str(). It feels noisy for a day and then becomes automatic — and it means nothing ever prints as an accidental #<Object:0x...>.
Concatenation without +
combined = "Fast " + "and friendly" puts combined puts "also " "works" # adjacent literals juxtapose
main! = |_args| { combined = "Fast ".concat("and friendly") echo!(combined) echo!(Str.concat("also ", "works")) Ok({}) }
Roc has no + for strings — concatenation is the concat method, callable in method style or as Str.concat. There is also no <<, since nothing can be appended to in place; building strings from pieces is usually interpolation's job anyway.
Everyday string methods
padded = " systems " puts padded.strip puts "ab" * 3 puts "systems".start_with?("sys") puts "systems".include?("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 operations all exist under slightly different names: strip is trim, "ab" * 3 is repeat, and the predicate methods drop Ruby's ? suffix — starts_with, ends_with, contains. Str.inspect renders the Bool results, since Bool has no .to_str() in the current build.
Splitting and joining
parts = "red,green,blue".split(",") puts parts.length joined = parts.join(" | ") puts joined
main! = |_args| { parts = "red,green,blue".split_on(",") echo!(parts.len().to_str()) joined = Str.join_with(parts, " | ") echo!(joined) Ok({}) }
Ruby's split/join pair maps to split_on and Str.join_with(list, separator). Note that joining lives on Str rather than on the list — the reverse of Ruby's Array#join — so it is not available in method style on the list itself.
Unicode escapes and byte counts
puts "rocket: 🚀" puts "héllo".length # 5 characters puts "héllo".bytesize # 6 bytes
main! = |_args| { echo!("rocket: \u(1F680)") echo!("héllo".count_utf8_bytes().to_str()) Ok({}) }
Both languages store strings as UTF-8, and the code-point escapes differ only in brackets: \u{1F680} in Ruby, \u(1F680) in Roc. Ruby offers both length (characters) and bytesize (bytes); Roc's pinned build exposes only the byte count, under the deliberately unambiguous name count_utf8_bytes() — grapheme-aware operations live in a separate unicode package.
Lists vs Arrays
List literals
numbers = [3, 1, 4, 1, 5] puts numbers.length p numbers mixed = [1, "two", :three] # Ruby arrays mix freely p mixed
main! = |_args| { numbers : List(I64) numbers = [3, 1, 4, 1, 5] echo!(numbers.len().to_str()) echo!(Str.inspect(numbers)) # A List holds ONE element type — for mixtures, # use a tag union (see the Tags section). Ok({}) }
The literal syntax is identical, but a Roc List(a) is homogeneous: every element has the same type. Where a Rubyist would mix types in one array, a Roc programmer uses a list of tags — [Count(1), Word("two"), Symbol] — which keeps the mixture fully type-checked. List is contiguous in memory like a Ruby array, not a linked list.
each becomes for_each!
words = ["alpha", "beta", "gamma"] words.each { |word| puts word }
main! = |_args| { words = ["alpha", "beta", "gamma"] words.for_each!(|word| echo!(word)) Ok({}) }
Squint and these are the same line: Roc's |word| ... closure syntax is Ruby's block-parameter syntax, and for_each! is each. The ! is not decoration — it marks that the closure performs effects (printing), which Roc tracks in the type system. The langref also specifies for word in words { } loops, but the pinned nightly build this page runs on rejects them; .for_each! is the working form today.
map / select / reject
numbers = [1, 2, 3, 4, 5, 6] doubled_evens = numbers .select { |number| number.even? } .map { |number| number * 2 } p doubled_evens p numbers.reject { |number| number > 3 }
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)) echo!(Str.inspect(numbers.drop_if(|number| number > 3))) Ok({}) }
Enumerable chaining works exactly the way a Rubyist expects — map keeps its name, select is keep_if, and reject is drop_if (there is also count_if for count with a block). Thanks to compile-time reference counting, the chain mutates in place when nothing else holds the list, so the functional style does not cost a copy per step.
reduce and sum
numbers = [1, 2, 3, 4] total = numbers.reduce(0) { |accumulator, number| accumulator + number } puts total puts numbers.sum
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({}) }
reduce is fold, with the same shape: an initial accumulator and a two-argument closure. Roc also ships fold_rev, fold_with_index, and fold_until (which can stop early by returning Break(value)) — covering the territory of Ruby's each_with_object and friends.
Indexing returns Try, not nil
numbers = [10, 20, 30] p numbers[9] # nil — silently! value = numbers[9] || 0 puts value puts numbers.fetch(1) # raises on a bad index
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({}) }
Ruby's numbers[9] returns nil, and that nil can travel far before something blows up with NoMethodError. Roc has no nil: .get(index) returns a Try, handled with match or the ?? default operator (Roc's || 0 idiom). The subscript syntax numbers[9] does not exist in the pinned build, so the unsafe path is not even available.
Sorting and reversing
numbers = [3, 1, 2] p numbers.sort p numbers.sort { |left, right| right <=> left } p numbers.reverse p numbers # unchanged — sort! would mutate
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())) echo!(Str.inspect(numbers)) Ok({}) }
Ruby's bang-less sort and reverse already return new arrays, so the functional style is familiar — Roc just removes the mutating sort!/reverse! alternatives entirely. The pinned build has no comparator-free .sort(), so sort_with takes an explicit comparator returning LT/EQ/GT tags, Roc's spelling of Ruby's -1/0/1 spaceship values. reverse is abbreviated to rev.
any? / all? / find
numbers = [2, 4, 6, 7] puts numbers.any? { |number| number.odd? } puts numbers.all? { |number| number > 0 } found = numbers.find { |number| number > 5 } puts found ? "found #{found}" : "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 Enumerable predicates keep their names minus the question mark: any, all, and find_first (plus find_first_index for Ruby's index with a block). The difference is the miss case: Ruby's find hands back nil, Roc's find_first hands back a Try you must actually look at.
each_with_index and friends
words = ["one", "two"] labeled = words.each_with_index.map do |word, index| "#{index}:#{word}" end p labeled
main! = |_args| { words = ["one", "two"] labeled = words.map_with_index(|word, index| "${index.to_str()}:${word}") echo!(Str.inspect(labeled)) Ok({}) }
Where Ruby composes each_with_index with map through an intermediate Enumerator, Roc offers direct _with_index variants: map_with_index, fold_with_index, and friends. The index arrives as a U64, so interpolating it needs the usual .to_str().
Records, Hashes & Dicts
Symbol-keyed hashes become records
point = { x: 1.5, y: 2.5 } puts "(#{point[:x]}, #{point[:y]})"
main! = |_args| { point = { x: 1.5, y: 2.5 } echo!("(${point.x.to_str()}, ${point.y.to_str()})") Ok({}) }
The literal syntax is character-for-character Ruby's symbol-key hash shorthand, but a Roc record is closer to an anonymous Struct: fields are fixed at compile time, accessed with dot syntax instead of [:key], and a typo like point.z is a compile error rather than a silent nil.
merge becomes spread syntax
defaults = { verbose: false, retries: 3, timeout_seconds: 30 } custom = defaults.merge(retries: 5) p custom
main! = |_args| { defaults = { verbose: Bool.False, retries: 3.I64, timeout_seconds: 30.I64 } custom = { ..defaults, retries: 5 } echo!(Str.inspect(custom)) Ok({}) }
Ruby's merge is Roc's { ..defaults, retries: 5 } spread-update syntax. Both produce a new value without touching the original. One Roc restriction Ruby does not have: the update can only replace existing fields, never add new ones — the record's shape is part of its type.
Data.define becomes a type alias
Employee = Data.define(:name, :department) def describe(employee) "#{employee.name} works in #{employee.department}" end employee = Employee.new(name: "Nia", department: "Compilers") puts 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({}) }
Ruby 3.2's Data.define brought immutable value objects to Ruby; Roc records are that idea as the language default. A type alias (single colon) names the shape for use in annotations. The alias is structural — any record with those fields satisfies it — where Data.define creates a distinct class. For a nominally distinct type, Roc uses := (see the "No Classes, No GC" section).
Tuples
pair = [1, "two"] # Ruby uses arrays as tuples puts "#{pair[0]} #{pair[1]}" number, word = pair puts "#{number} #{word}"
main! = |_args| { pair = (1.I64, "two") echo!("${pair.0.to_str()} ${pair.1}") (number, word) = pair echo!("${number.to_str()} ${word}") Ok({}) }
Ruby fakes tuples with two-element arrays; Roc has the real thing, with parentheses, positional .0/.1 access, and destructuring. Because a tuple's length and element types are part of its type, "forgot the second element" is a compile error instead of a nil.
Hashes with runtime keys become Dict
scores = {} scores["math"] = 90 scores["art"] = 95 puts scores.length puts scores["art"] puts scores.fetch("music", 0)
main! = |_args| { scores = Dict.empty() .insert("math", 90.I64) .insert("art", 95.I64) echo!(scores.len().to_str()) echo!((scores.get("art") ?? 0).to_str()) echo!((scores.get("music") ?? 0).to_str()) Ok({}) }
Records cover Ruby's symbol-key "struct-like" hashes; for genuinely dynamic keys, Roc has Dict. Since values are immutable, insert returns a new dict — chained here — and get returns a Try, making .get(key) ?? default the equivalent of Ruby's fetch(key, default). As with lists, the compiler mutates in place when the dict is unshared.
Tags: Symbols That Grew Up
Symbols become tags
hour = 14 period = hour < 12 ? :morning : :afternoon label = case period when :morning then "AM" when :afternoon then "PM" end puts label
main! = |_args| { hour : I64 hour = 14 period = if hour < 12 { Morning } else { Afternoon } label = match period { Morning => "AM" Afternoon => "PM" } echo!(label) Ok({}) }
A capitalized bare name like Morning is a tag — Roc's symbol. Like :morning, it needs no declaration and simply exists at the point of use. Unlike a symbol, the compiler infers that period has type [Morning, Afternoon] and then verifies the match handles every case — misspell Afternon in the Ruby version and you get a silent nil; in Roc you get a compile error.
Tags carry data
# The Ruby idiom: a symbol plus data, bundled in an array shapes = [[:circle, 2.0], [:rectangle, 3.0, 4.0]] areas = shapes.map do |shape| case shape in [:circle, radius] then 3.14159 * radius * radius in [:rectangle, width, height] then width * height end end p areas
area : [Circle(Dec), Rectangle(Dec, Dec)] -> Dec area = |shape| match shape { Circle(radius) => 3.14159 * radius * radius Rectangle(width, height) => width * height } main! = |_args| { echo!(area(Circle(2)).to_str()) echo!(area(Rectangle(3, 4)).to_str()) Ok({}) }
Where Ruby bundles a symbol with its data in an array — [:circle, 2.0] — a Roc tag holds its payload directly: Circle(2). The function's type spells out exactly which tags it accepts and what each carries, and the match destructures the payloads, all checked at compile time. This is the workhorse pattern of Roc programs.
Declared tag unions (Ruby has no enums)
# Ruby has no enum; the convention is symbols # plus validation by hand: VALID_COLORS = %i[red green blue].freeze def to_hex(color) raise ArgumentError, "bad color" unless VALID_COLORS.include?(color) { red: "#FF0000", green: "#00FF00", blue: "#0000FF" }[color] end puts to_hex(: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({}) }
Ruby's missing-enum problem — validated by frozen arrays, raise, and discipline — is a one-line declaration in Roc: Color := [Red, Green, Blue] creates a nominal (named, closed) tag union. Passing anything that is not one of the three colors, or forgetting a case in match, is a compile error rather than a runtime ArgumentError.
Recursive data structures
Leaf = Data.define(:value) Node = Data.define(:left, :right) def sum_tree(tree) case tree in Leaf(value:) then value in Node(left:, right:) then sum_tree(left) + sum_tree(right) end end tree = Node.new(left: Leaf.new(value: 1), right: Node.new(left: Leaf.new(value: 2), right: Leaf.new(value: 3))) puts 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({}) }
A binary tree that takes two Data.define classes and a case/in in Ruby is a single self-referential type declaration in Roc. The compiler handles the heap allocation and reference counting behind the scenes, and the match is checked for exhaustiveness — add a Branch variant later and every match that misses it stops compiling.
Pattern Matching
case/in becomes match
status_code = 404 message = case status_code in 200 then "ok" in 404 then "not found" else "something else" end puts message
main! = |_args| { status_code : I64 status_code = 404 message = match status_code { 200 => "ok" 404 => "not found" _ => "something else" } echo!(message) Ok({}) }
Ruby 3's case/in pattern matching translates almost directly to Roc's matchin pattern then result becomes pattern => result, and else becomes _. The crucial upgrade: Ruby raises NoMatchingPatternError at runtime when nothing matches; Roc refuses to compile a non-exhaustive match in the first place.
Guards
def describe(number) case number in 0 then "zero" in n if n < 0 then "negative" in n if n.even? then "positive even" else "positive odd" end end puts describe(0) puts describe(-5) puts 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 use the same pattern if condition shape in both languages. As in Ruby, a guarded branch does not count as covering its pattern, so the final catch-all is still required for exhaustiveness.
Or-patterns
def size_class(number) case number in 1 | 2 | 3 then "small" else "big" end end puts size_class(2) puts 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 — Ruby's pattern matching and Roc's match drew from the same well.
Array patterns and rest bindings
def describe(numbers) case numbers in [] then "empty" in [single] then "one: #{single}" in [first, *rest] then "first #{first}, #{rest.length} more" end end puts describe([]) puts describe([7]) puts 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({}) }
List patterns translate symbol-for-symbol: Ruby's splat *rest becomes Roc's .. as rest. Roc additionally proves the match exhaustive at compile time — remove the [] arm and the program stops compiling, where Ruby would raise at runtime on an empty array.
No nil, No Exceptions
There is no nil
def find_user(id) id == 1 ? "Ada" : nil end user = find_user(1) if user puts "found #{user}" else puts "missing" end
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 nil — the billion-dollar mistake is simply absent, and NoMethodError for nil:NilClass cannot happen. Absence is expressed with tags: this function returns [Found(Str), Missing], and the compiler forces every caller to handle Missing. There is not even an Option type to learn; an ad-hoc union with domain-specific names usually reads better than Some/None anyway.
begin/rescue becomes a Try value
def parse_score(text) Integer(text.strip) rescue ArgumentError raise ArgumentError, "bad score: #{text}" end begin puts "score: #{parse_score("95")}" puts "score: #{parse_score("not a number")}" rescue ArgumentError => error puts error.message end
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({}) }
Roc has no exceptions, no raise, and no rescue — a fallible function returns Try(ok, err), and failure is an ordinary value: Ok(score) or Err(BadScore(text)). Nothing unwinds the stack, and nothing can be forgotten: the type signature advertises exactly what can go wrong, and the compiler makes sure callers deal with it. The error tag needs no class declaration the way ArgumentError subclassing does.
|| and &. become ??
numbers = [] first = numbers.first || 0 puts first config = { retries: nil } puts config[:retries] || 3
main! = |_args| { numbers : List(I64) numbers = [] first = numbers.first() ?? 0 echo!(first.to_str()) settings = Dict.empty().insert("verbose", "true") echo!(settings.get("retries") ?? "3") Ok({}) }
Ruby's || default idiom (and its safe-navigation cousin &.) collapses into Roc's ??: use the Ok payload, or fall back to the default on Err. Unlike ||, it cannot misfire on a legitimate false value — the classic Ruby bug where enabled = flag || true destroys an intentional false has no Roc equivalent.
Propagating failure with ?
# In Ruby, exceptions propagate automatically — # any uncaught error unwinds through every caller: def show_first(numbers) first = numbers.fetch(0) # raises IndexError if empty puts "first: #{first}" end show_first([5, 6, 7])
show_first! = |numbers| { first = numbers.first()? echo!("first: ${first.to_str()}") Ok({}) } main! = |_args| { numbers : List(I64) numbers = [5, 6, 7] show_first!(numbers) }
Since Roc errors are return values, they do not propagate on their own — the ? suffix does it explicitly: unwrap the Ok, or return the Err to the caller immediately. It is Ruby's exception bubbling made visible, one character per hop. Here main! simply passes show_first!'s Try along as its own result.
Error types compose without a class hierarchy
class ConfigError < StandardError; end class BadPort < ConfigError; end def read_port(text) Integer(text) rescue ArgumentError raise BadPort, "bad port: #{text}" end begin puts read_port("8080") puts read_port("eighty") rescue ConfigError => error puts error.message end
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({}) }
Ruby organizes errors into class hierarchies so rescue ConfigError can catch a family at once. Roc's error tags are structural and its unions open, so errors from different functions merge automatically: a function calling two fallible helpers infers the union of both error sets, with no base classes and no raise/rescue choreography. A match can still handle specific tags and catch the rest with _.
Blocks Become Functions
Your block syntax is the whole language
[1, 2, 3].each { |number| puts number * 2 } doubler = ->(number) { number * 2 } p [1, 2, 3].map(&doubler)
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3] numbers.for_each!(|number| echo!((number * 2).to_str())) doubler = |number| number * 2 echo!(Str.inspect(numbers.map(doubler))) Ok({}) }
Roc adopted Ruby's |number| block-parameter pipes as its only function syntax — every Roc function, named or anonymous, is written the way a Rubyist writes a block. And there is no & operator, because there is no block/proc divide to bridge: a function is a value, passed by name like any other argument.
def, lambda, proc, block → one form
def add(left, right) = left + right also_add = ->(left, right) { left + right } same_again = proc { |left, right| left + right } puts add(2, 3) puts also_add.call(2, 3) puts same_again.call(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({}) }
Ruby has methods, blocks, procs, lambdas, and Method objects, each with its own calling convention and arity rules. Roc has exactly one callable thing. A top-level function is just a named value that happens to be a lambda — which is why its type annotation sits above it like any other value's, and why there is no .call: parentheses invoke anything.
Compile-checked duck typing
# Classic duck typing: anything with to_s works def announce(value) puts value.to_s end announce(42) announce(2.5)
announce! : a => {} where [a.to_str : a -> Str] announce! = |value| { echo!(value.to_str()) } main! = |_args| { announce!(42.I64) announce!(2.5.Dec) Ok({}) }
Roc's where clause is duck typing with a compiler behind it: where [a.to_str : a -> Str] accepts any type that has a to_str method — the same "if it quacks" spirit as Ruby, except a non-quacking argument fails at compile time instead of raising NoMethodError in production. There is no interface or trait to declare; the constraint names the method directly.
Control Flow
No truthiness
count = 0 puts "zero is truthy in Ruby!" if count name = nil puts "only nil and false are falsy" unless name
main! = |_args| { ready : Bool ready = Bool.True if ready { echo!("conditions must be a real Bool") } # if 0 { ... } or if "text" { ... } # would be COMPILE errors — no truthiness. Ok({}) }
Ruby's "everything except nil and false is truthy" — where even 0 and "" pass an if — has no Roc counterpart: a condition must be an actual Bool, and there is no nil to be falsy in the first place. There is also no unless, no modifier-if, and no ternary; if/else as an expression covers all of them.
while loops and break
count = 0 while count < 5 count += 1 break if count == 3 end puts 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 confined to the enclosing function, so the compiler can allow honest imperative iteration without giving up its guarantees. Rubyists do not have to translate every loop into a fold.
Guard clauses and early return
def clamp_positive(number) return 0 if number < 0 number end puts clamp_positive(-5) puts 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({}) }
The Ruby guard-clause style survives intact: Roc has a genuine return for early exits, and the final expression of a function body is its return value, exactly like Ruby's implicit return. Only the one-line modifier form (return 0 if ...) has no equivalent.
Purity & Effects
Ruby's ! convention, enforced by the compiler
numbers = [3, 1, 2] sorted = numbers.sort # returns a new array numbers.sort! # ! = "the dangerous version" p numbers # But the convention is just a convention: # nothing stops a bang-less method from # launching missiles.
main! = |_args| { # In Roc, ! means "performs effects" — and the # compiler checks it. echo! prints; for_each! # takes an effectful closure; main! runs it all. words = ["sorted", "by", "type system"] words.for_each!(|word| echo!(word)) Ok({}) }
Every Rubyist knows the ! suffix means "watch out" — but it is folklore, unevenly applied and never checked. Roc turns the same visual convention into a type-system rule: a function whose body performs effects must be named with a !, and a function without one provably cannot print, mutate the outside world, or perform any I/O. Since nothing in Roc mutates in place, the "dangerous mutation" meaning disappears and only the effect meaning remains.
Pure functions cannot call effectful ones
# Nothing in Ruby marks this method as doing I/O: def quiet_looking_helper(name) puts "(surprise: I/O happened here)" "hello #{name}" end greeting = quiet_looking_helper("Ruby") puts 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({}) }
A Roc function type uses -> when pure and => when effectful, and a pure function cannot call an effectful one — the "surprise puts buried in a helper" of the Ruby example is a compile error in Roc. You always know from the call site whether a function can touch the outside world.
Assertions with expect
total = 2 + 2 raise "math is broken" unless total == 4 puts "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; if it fails, the program halts with a nonzero exit code — Ruby's raise unless idiom as a keyword. The same keyword doubles as Roc's unit-testing construct: top-level expect blocks are collected and run by roc test, playing the role of a Minitest assertion with zero framework setup.
crash: raise without rescue
configuration_found = false unless configuration_found raise "no configuration — cannot continue" end
main! = |_args| { configuration_found : Bool configuration_found = False if !configuration_found { crash "no configuration — cannot continue" } Ok({}) }
Roc's crash is raise with no rescue anywhere: an unrecoverable abort for states that should be impossible, 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 (and the Ruby side's uncaught raise would likewise end with a nonzero exit). Expected failures should be Try values; crash is for bugs.
No Classes, No GC
The aliasing bug that cannot happen
first_list = [1, 2, 3] second_list = first_list # same object! second_list << 4 p first_list # [1, 2, 3, 4] — changed at a distance
main! = |_args| { original : List(I64) original = [1, 2, 3] extended = original.append(4) echo!(Str.inspect(extended)) echo!(Str.inspect(original)) Ok({}) }
Every Rubyist has been bitten by two variables secretly sharing one mutable object — the bug that motivates all the defensive dup calls. In Roc, values are immutable, so sharing is always safe: append returns a new list and original is untouched, guaranteed. When the original's reference count is 1 the compiler mutates in place anyway, so the safety costs nothing.
GC pauses vs compile-time reference counting
# Ruby's GC finds garbage at runtime: words = Array.new(3) { |index| "word #{index}" } p words GC.start puts "collected #{GC.count} times so far"
main! = |_args| { words = ["word 0", "word 1", "word 2"] echo!(Str.inspect(words)) # No GC exists: the compiler inserted the # exact retain/release calls at compile time. echo!("memory managed before the program ran") Ok({}) }
Ruby discovers garbage while the program runs, pausing to mark and sweep. Roc has no garbage collector and no runtime memory manager at all: the compiler analyzes the program (the technique is called Perceus reference counting) and inserts precise allocate/free operations at compile time. Because Roc's immutable values cannot form reference cycles, the classic refcounting leak is unrepresentable.
Classes become types with method blocks
class Counter attr_reader :value def initialize(value = 0) @value = value end def increment = Counter.new(value + 1) def describe = "count is #{value}" end counter = Counter.new.increment.increment puts 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 functions, and value.method(args) dispatches to them — so method chaining reads exactly like Ruby. What is missing is the object machinery: no @value instance variables (the data is just a record), no self, no inheritance, and no runtime dispatch. A "method" is an ordinary function whose first argument is the type.
No open classes, no method_missing
# Ruby classes are open — anyone can add methods: class String def shout = "#{self}!" end puts "hello".shout
# Str cannot be reopened; write a function instead: shout : Str -> Str shout = |text| "${text}!" main! = |_args| { echo!(shout("hello")) Ok({}) }
Roc deliberately has no open classes, no method_missing, no define_method, and no runtime metaprogramming of any kind — every call is resolved statically at compile time. The Rails-style expressiveness this rules out is real, but so is the payoff: no action at a distance, no load-order-dependent behavior, and "jump to definition" always has exactly one answer.
Gotchas for Rubyists
Untyped integers print as decimals
# Ruby integers always print as integers: p [1, 2, 3] # [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]. The habit to build: annotate any binding whose value you intend to display as an integer, or suffix the literals (1.I64).
Bare True is not a Bool
ready = false # unambiguously a boolean puts !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 gives Roc declaration-free symbols creates this one sharp edge.
No [] subscript on lists
numbers = [10, 20, 30] puts numbers[0] # the reflex of a lifetime puts numbers[-1] # negative indexing, too
main! = |_args| { numbers : List(I64) numbers = [10, 20, 30] echo!((numbers.get(0) ?? 0).to_str()) echo!((numbers.last() ?? 0).to_str()) Ok({}) }
The pinned build does not parse numbers[0] at all — element access is .get(index), which returns a Try. There is no negative indexing either; use .last(). Ten years of muscle memory will fight you on this one for about an afternoon.
The stdlib is still settling
# Ruby's String API has been stable for decades: puts "roc rocks".sub("rocks", "flies") puts "shout".upcase
main! = |_args| { # No Str.replace and no case methods in this # build — compose what exists: echo!("roc rocks".drop_suffix("rocks").concat("flies")) echo!("shout") Ok({}) }
Roc is pre-1.0 and this page pins a specific nightly compiler, so chunks of the string API a Rubyist reaches for daily are missing or renamed: no sub/gsub equivalent, no upcase/downcase/capitalize, string length is count_utf8_bytes(), and there are no regular expressions in the standard library at all. Expect this table to improve as Roc approaches its first stable release.