PONYλM2Modula-2

Lua.CodeCompared.To/Roc

An interactive executable cheatsheet comparing Lua and Roc

Lua 5.3 Roc nightly
Hello World & The Platform Model
Hello, World
A Roc program is a definition of main!, a function the platform calls with the command-line arguments. The ! at the end of a name means "this performs effects", and the compiler enforces it.
print("Hello, World!")
main! = |_args| { echo!("Hello, World!") Ok({}) }
The _args parameter is named with a leading underscore because it is required by the signature and unused by the body. Ok({}) is the return value, where {} is the empty record — Roc's way of returning nothing.
The host, made explicit
You already know this model. A Lua state is created by a host program, and what a script may do is whatever that host chose to expose — which is why Lua embedded in a game looks nothing like Lua at a shell prompt.
-- What exists depends entirely on the C program -- that created this Lua state. A game engine may -- give you no io at all; another host may add -- twenty libraries you have never seen. print("host decides: " .. tostring(io ~= nil))
main! = |_args| { # Same idea, with the list checked. This program # was built against a platform providing exactly # echo!, so echo! is all it can call. echo!("every effect comes from the platform") Ok({}) }
Roc makes the same arrangement part of the type system. An application declares which platform it is built against, and reaching for an effect that platform does not provide is a compile error rather than an attempt to index a nil value at run time.
How a program reports failure
Roc's main! returns a Try: Ok for success and Err for failure. The platform turns that into whatever the operating system wants, so nothing in the program calls an exit function.
local function main() print("all good") return 0 end local status = main() print("status " .. status)
main! = |_args| { echo!("all good") Ok({}) }
Because the status is the function's return value, the compiler checks it like any other value. Lua's os.exit is unavailable in the browser runtime here anyway, which is a good illustration of the point above: what a host does not provide, a script does not have.
Comments
Comments start with # rather than --, and there is no long-bracket form, so a multi-line comment is several # lines.
-- A single-line comment local count = 42 -- an inline comment --[[ A long comment, which can span lines. ]] print(count)
main! = |_args| { # A single-line comment count : I64 count = 42 # an inline comment # Roc has no long-comment form — every comment # line starts with its own #. echo!(count.to_str()) Ok({}) }
The count : I64 line is a type annotation. Writing it is optional — Roc would infer a type — but it pins down which number type this is, and that decides what gets printed.
One Table Becomes Four Types
A table used as a list becomes a List
Where Lua has one container that plays every role, Roc has four distinct types — and this is the first of them. A List is a flat array of a single element type, with a real length rather than a border.
local numbers = {3, 1, 4, 1, 5} print(#numbers) print(numbers[1]) -- Lua counts from 1
main! = |_args| { numbers : List(I64) numbers = [3, 1, 4, 1, 5] echo!(numbers.len().to_str()) echo!((numbers.get(0) ?? 0).to_str()) Ok({}) }
Two differences in three lines: Roc indexes from zero, and get returns a Try rather than nil. The single element type is the bigger change — a Roc list cannot hold a number and a string at once.
A table with fixed keys becomes a record
The second role: a table whose keys are known when you write the code is a record. Its field list is part of its type, so the compiler knows exactly which fields exist.
local point = {x = 1.5, y = 2.5} print("(" .. point.x .. ", " .. point.y .. ")") print(point.z) -- nil, and no complaint
main! = |_args| { point = { x: 1.5, y: 2.5 } echo!("(${point.x.to_str()}, ${point.y.to_str()})") # point.z is a COMPILE error: no such field. Ok({}) }
This is the biggest change of habit. A typo in a field name stops being nil — flowing onward until something tries to index or add it — and becomes a compile error naming the field and the line.
A table with runtime keys becomes a Dict
The third role: when the keys really are data — counted words, a lookup built from input — Roc has Dict. Every key shares one type and so does every value.
local scores = {} scores["math"] = 90 scores["art"] = 95 local count = 0 for _ in pairs(scores) do count = count + 1 end print(count) print(scores["art"]) print(scores["music"] or 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({}) }
A Dict knows its own size, so there is no counting loop. insert returns a new dict rather than changing the old one, which is why the calls chain, and ?? does the job Lua's or does here without the falsy-value trap.
A table with a kind field becomes a tag union
The fourth role, and the one with the biggest payoff: a table carrying a kind field and a chain of comparisons is a tag union. The discriminant becomes the value itself, and each variant declares exactly which fields it carries.
local function area(shape) if shape.kind == "circle" then return 3.14159 * shape.radius * shape.radius elseif shape.kind == "rectangle" then return shape.width * shape.height end error("unknown shape " .. tostring(shape.kind)) end print(area({kind = "circle", radius = 2})) print(area({kind = "rectangle", width = 3, height = 4}))
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({}) }
The Lua version needs a final error for the case that should be impossible, and nothing checks that the branches match the shapes. The Roc version needs no such branch — add a third variant and the match becomes incomplete, so the program stops compiling until it is handled.
Dynamic Types vs Full Inference
Types checked before the program runs
Lua asks at the moment of multiplication whether the operands can be multiplied. Roc infers a type for every expression in the whole program and rejects the mismatches before anything runs.
local function double(number) return number * 2 end print(double(21)) print(double("21") == 42) -- true: the string was -- silently coerced
double : I64 -> I64 double = |number| number * 2 main! = |_args| { echo!(double(21).to_str()) # double("21") is a COMPILE error, named # before the program ever runs. Ok({}) }
The second Lua line is the one worth staring at: passing a string does not fail, it coerces, and the arithmetic goes ahead as though nothing happened. The result is also a float rather than an integer, so a calculation that looked like integer arithmetic quietly stopped being any such thing.
No type(), because the type is never in doubt
Runtime type inspection has no place in Roc, because a value's type is fixed and known. Where Lua branches on type(), Roc makes the possibilities explicit as a tag union and branches on the tag.
local function describe(value) if type(value) == "number" then return "a number" end if type(value) == "string" then return "a string" end return "something else" end print(describe(42)) print(describe("hi")) print(describe(nil))
describe : [Number(I64), Text(Str)] -> Str describe = |value| match value { Number(_) => "a number" Text(_) => "a string" } main! = |_args| { echo!(describe(Number(42))) echo!(describe(Text("hi"))) Ok({}) }
The Roc version has two cases and needs no fallback, because its type says there are exactly two. The Lua version needs a third branch it can never fully trust, and that branch is where nil lands.
Structural types — you already think this way
A Roc record type is its set of fields, so a literal with the right fields already is one. That is the same instinct as duck-typed Lua, with the difference that the shape is checked.
local function describe(point) return "(" .. point.x .. ", " .. point.y .. ")" end -- Any table with x and y works. Nothing declares -- that it is a Point, and nothing checks. print(describe({x = 1, y = 2}))
Point : { x : I64, y : I64 } describe : Point -> Str describe = |point| "(${point.x.to_str()}, ${point.y.to_str()})" main! = |_args| { echo!(describe({ x: 1, y: 2 })) # describe({ x: 1 }) does not compile. Ok({}) }
Point is an alias, not a class: nothing is constructed and nothing is registered. A missing field in Lua produces nil and then an error somewhere else; in Roc it is a compile error naming y.
Values, Locals & Immutability
There is no global scope to fall into
Lua's most-reported footgun is that a name without local is global. Roc has no globals at all: a binding is local to its block, and a top-level definition is a constant the compiler evaluates before the program starts.
local function configure() timeout = 30 -- no "local": this is a GLOBAL end configure() print(timeout) -- visible everywhere, forever
configure : {} -> I64 configure = |{}| { timeout = 30.I64 timeout } main! = |_args| { echo!(configure({}).to_str()) # "timeout" does not exist out here. A binding # inside a function is local, always, and there # is no keyword that would make it otherwise. Ok({}) }
Nothing is mutable at the top level either, so there is no equivalent of a module that changes a global and no setfenv-style sandboxing problem. A missing local cannot be a bug because there is nothing to omit.
A name is bound once
Roc's = defines a name once. A second definition in the same scope is an error rather than a reassignment.
local greeting = "hello" greeting = "rebound" print(greeting)
main! = |_args| { greeting = "hello" # greeting = "rebound" # ^ COMPILE ERROR: duplicate definition echo!(greeting) Ok({}) }
Shadowing is not available either, so each step of a calculation needs its own name. That rules out the family of bugs where a variable quietly means something different fifteen lines further down.
Opting in to mutation
When something genuinely has to change, var declares it and a $ sigil marks every use — so mutation is visible where you read it rather than at a declaration far above.
local total = 0 total = total + 5 total = total + 10 print(total)
main! = |_args| { var $total = 0.I64 $total = $total + 5 $total = $total + 10 echo!($total.to_str()) Ok({}) }
A var is local to its function and cannot escape it, so a mutable value can never be shared between two parts of a program that do not know about each other.
Tables are shared; Roc values are not
Assigning a Lua table copies a reference, so two names can be the same table and a write through one is visible through the other. In Roc there is no write, so the question cannot arise.
local original = {1, 2, 3} local other = original -- the same table other[2] = 99 print(original[2]) -- 99: they are one table
main! = |_args| { original : List(I64) original = [1, 2, 3] other = original.set(1, 99) ?? original echo!((original.get(1) ?? 0).to_str()) echo!((other.get(1) ?? 0).to_str()) Ok({}) }
The Lua column prints 99 because original and other were always the same object. The Roc column prints 2 then 99: set produced a separate list, and the compiler mutates in place only when it can prove nobody else is holding the old one.
Destructuring
Roc's tuple destructuring is Lua's multiple assignment on the first line. The second line has no Lua equivalent: naming a record's fields pulls them out by name, with the compiler checking that each one exists.
local x, y = 3, 4 print(x .. ", " .. y) local person = {name = "Grace", age = 85} local name, age = person.name, person.age print(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({}) }
Lua has to name each key twice because a table has no destructuring form. Where Lua's multiple assignment pads with nil when the counts disagree, a Roc tuple's length is part of its type and cannot disagree.
There Is No nil
There is no nil
Roc has no nil and nothing like it. Absence is modeled by a tag that says what is absent, and the function's type says so out loud.
local function find_user(user_id) if user_id == 1 then return "Ada" end return nil end local name = find_user(1) if name ~= nil then print("found " .. name) else print("missing") end
find_user : U32 -> [Found(Str), Missing] find_user = |user_id| { if user_id == 1 { Found("Ada") } else { Missing } } main! = |_args| { match find_user(1) { Found(name) => echo!("found ${name}") Missing => echo!("missing") } Ok({}) }
The Roc signature tells you at a glance that this might not find anything, and the compiler will not let the caller skip the Missing case. In Lua, forgetting the ~= nil check produces attempt to concatenate a nil value at a line that is not the one with the bug.
Storing absence, which Lua cannot do
In Lua, assigning nil to a table key deletes it, so "this key is absent" and "this key holds nothing" cannot be told apart — and a list with a nil in the middle has an undefined length.
local function describe(settings) if settings.retries == nil then return "not configured" end return "retry " .. settings.retries .. " times" end print(describe({})) print(describe({retries = 0})) print(describe({retries = nil})) -- same as {}
Retries : [Unset, Times(I64)] describe : Retries -> Str describe = |retries| match retries { Unset => "not configured" Times(count) => "retry ${count.to_str()} times" } main! = |_args| { echo!(describe(Unset)) echo!(describe(Times(0))) Ok({}) }
Roc has no such collision, because absence is an ordinary tag. Unset is a value you can store, pass, compare and match on, and Times(0) sits beside it in the same union — so "not configured" and "configured to zero" are different values rather than the same nil. The Lua column's third line proves the point: it is indistinguishable from the first.
The or idiom becomes ??
value or default is the Lua idiom, and ?? is Roc's: it takes the value out of an Ok and supplies the fallback for an Err.
local function get_retries(settings) return settings.retries or 3 end print(get_retries({})) print(get_retries({retries = 5})) print(get_retries({retries = false})) -- 3, not false!
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({}) }
The difference is what is being tested. Lua's or tests truthiness, so a legitimate false is replaced by the default — the third line prints 3. ?? tests whether the operation succeeded, so a successfully-fetched false stays false.
No truthiness at all
Lua's truthiness rule is the most defensible of any dynamic language — only nil and false are falsy — and Roc still removes it, because a condition must be a Bool and nothing converts to one implicitly.
-- In Lua only nil and false are falsy, so 0 and "" -- are both TRUE. That is friendlier than most -- languages and still a rule to remember. if 0 then print("zero is truthy") end if "" then print("and so is the empty string") end
main! = |_args| { # There is no truthiness to remember, because a # condition must be a Bool and nothing converts # to one. if 0 { … } does not compile. count : I64 count = 0 if count == 0 { echo!("the question has to be written down") } Ok({}) }
What you give up is if value then as a shorthand for "is this present". What you get back is that every condition says what it is testing, so nobody has to recall which of 0, "" and {} counts as true in which language.
Numbers
Two number types vs a full menu
Lua 5.3 split its one number type into an integer and a float subtype, which you inspect with math.type. Roc exposes the machine: I8 through I128, U8 through U128, F32, F64 and Dec, all sharing one set of operators.
local whole = 7 local ratio = 2.5 print(math.type(whole)) print(math.type(ratio))
main! = |_args| { byte : U8 byte = 255 ratio : F64 ratio = 2.5 echo!("${byte.to_str()} ${ratio.to_str()}") Ok({}) }
Choosing U8 is a claim that the value fits in a byte, and the compiler holds you to it — byte = 256 would not compile. There is no automatic widening either, so mixing sizes means converting explicitly rather than hoping the subtype rules agree with you.
Exact decimals, without a library
Dec is a fixed-point decimal type and an ordinary member of the number menu. It is what an unannotated decimal literal becomes, so exactness is the default rather than the opt-in.
-- Printed to full precision, because Lua's default -- float formatting rounds the error out of sight. print(string.format("%.17g", 0.1 + 0.2)) print(string.format("%.1f", 0.1 + 0.2))
main! = |_args| { lossy : F64 lossy = 0.1 + 0.2 echo!(lossy.to_str()) precise : Dec precise = 0.1 + 0.2 echo!(precise.to_str()) Ok({}) }
The first line of each column is the familiar binary-floating-point answer. The second is where they part: Lua rounds for display and still holds the wrong number, while the Roc value is exact and stays exact through the next hundred operations.
Integer division and remainder
Roc spells these exactly as Lua 5.3 does: // floors, % is the remainder, and / is the dividing one. The difference is what / produces — a Dec rather than a float.
print(17 // 5) print(17 % 5) print(17 / 5)
main! = |_args| { quotient : I64 quotient = 17 // 5 echo!(quotient.to_str()) remainder : I64 remainder = 17 % 5 echo!(remainder.to_str()) exact : Dec exact = 17 / 5 echo!(exact.to_str()) Ok({}) }
Both languages give 3.4 here, and they would part on a value like 1 / 3 printed to enough places. Lua's / always produces a float even when both operands are integers, which is the rule // exists to escape.
tonumber and tostring become methods
Every conversion is a method on the type being converted from, and parsing is separate from widening because parsing can fail and widening cannot.
local count = 200 print(count / 4) print(tostring(count) .. " units") print(tonumber("42") + 1) print(tonumber("abc")) -- nil
main! = |_args| { count : I64 count = 200 echo!((count.to_f64() / 4).to_str()) echo!(count.to_str().concat(" units")) parsed = I64.from_str("42") ?? 0 echo!((parsed + 1).to_str()) Ok({}) }
Lua's tonumber returns nil on bad input, which is the right shape and the wrong value — it is indistinguishable from a table lookup that missed. I64.from_str returns a Try naming the failure, and ?? supplies the fallback.
Overflow is caught, not wrapped
Roc's integers have a fixed width, and an addition that would pass the top of the range is an error rather than a wrap. When both operands are known at compile time, as here, it is caught before the program runs.
-- Lua 5.3 integers wrap silently on overflow. local big = math.maxinteger print(big + 1 == math.mininteger)
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({}) }
The Lua column prints true: adding one to the largest integer produces the smallest one, silently, which is exactly the behavior a checked arithmetic type exists to prevent. Underscores as digit separators are a Roc convenience Lua does not have.
Strings
Concatenation becomes interpolation
Every Roc string can interpolate with ${}, so neither .. chains nor string.format is needed for the common case. The one catch is that interpolation takes a Str and will not convert a number for you.
local name = "Roc bird" local age = 10 print(name .. " is " .. age) local message = string.format("%s turns %d", name, age + 1) print(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({}) }
Lua's .. does convert, which is convenient until a nil arrives and the error names the concatenation rather than whatever produced the nil. "${age}" in Roc is a type error naming I64 where Str was expected.
Concatenation when you want it
Joining two strings is concat, available either as a method on the left-hand string or as a plain function.
print("Fast " .. "and friendly") print("also" .. " " .. "works")
main! = |_args| { echo!("Fast ".concat("and friendly")) echo!(Str.concat("also", " works")) Ok({}) }
The two forms are the same function: "a".concat("b") is resolved at compile time to Str.concat("a", "b"). Method syntax in Roc is sugar over a function call, with no metatable lookup and no __concat to define.
Everyday string methods
Roc names these directly — trim, repeat, starts_with, contains — where Lua reaches for its pattern language or for index arithmetic.
local padded = " systems " print((padded:gsub("^%s*(.-)%s*$", "%1"))) print(("ab"):rep(3)) print(("systems"):sub(1, 3) == "sys") print(("systems"):find("stem") ~= nil)
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 first Lua line is the standard trim idiom, and it is a pattern with four metacharacters in it. Lua's patterns are more powerful than any of these four functions; the trade is that the common cases need no pattern at all. Str.inspect turns a non-string value into something printable.
Splitting and joining
Splitting a string is one call, split_on, and joining is Str.join_with — which takes the list first and the separator second.
local parts = {} for piece in ("red,green,blue"):gmatch("[^,]+") do parts[#parts + 1] = piece end print(#parts) print(table.concat(parts, " | "))
main! = |_args| { parts = "red,green,blue".split_on(",") echo!(parts.len().to_str()) echo!(Str.join_with(parts, " | ")) Ok({}) }
Lua has no split function at all, which is why every Lua codebase contains the gmatch loop above. Roc has no pattern language to make up for it, though: splitting on a literal string is the only option here.
Strings are bytes in both languages
This is one of the closest correspondences on the page: a Lua string is a byte array and so is a Roc Str, so # and count_utf8_bytes answer the same question.
print("rocket: \u{1F680}") print(#"héllo") -- 6 bytes print(utf8.len("héllo")) -- 5 characters
main! = |_args| { echo!("rocket: \u(1F680)") echo!("héllo".count_utf8_bytes().to_str()) Ok({}) }
The difference is what else is available. Lua adds a utf8 library for character counting and offsets; Roc has no Char type and no way to subscript a string by position at all, so text is taken apart with operations that respect UTF-8 by construction.
Lists, and Counting From Zero
Roc counts from zero
Lua is famously one-based and Roc is zero-based, which is the single most mechanical adjustment on this page and the one most likely to produce an off-by-one on your first afternoon.
local numbers = {10, 20, 30} print(numbers[1]) -- the first print(numbers[#numbers]) print(numbers[0]) -- nil: there is no zero slot
main! = |_args| { numbers : List(I64) numbers = [10, 20, 30] echo!((numbers.get(0) ?? 0).to_str()) echo!((numbers.last() ?? 0).to_str()) # numbers.get(3) returns Err — one past the end. Ok({}) }
Reading past the end is where the two really differ, though. Lua answers nil for any index it does not have, including 0, so an off-by-one becomes a nil that travels. Roc answers Err, which the compiler makes you handle at the point of the read.
A length that is not a guess
Lua's # finds a "border" — an index n where n is present and n+1 is not — and with a hole in the table there may be several, so the manual explicitly permits any of them.
local sparse = {1, 2, 3} sparse[2] = nil -- punch a hole in it print(#sparse) -- 3 or 1: either is "correct"
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3] # A list has no holes to punch. Removing an # element produces a shorter list. shorter = numbers.drop_first(1) echo!(numbers.len().to_str()) echo!(shorter.len().to_str()) Ok({}) }
That permission is not theoretical: this very example prints 3 on some Lua implementations and 1 on others, from identical source, and both are conforming. A Roc list stores its length, so len() is a fact rather than a search and it is constant-time — and there is no way to create a hole in the first place, because there is no way to assign nil into the middle of anything.
map and filter, which Lua has none of
Lua's standard library has no map and no filter, so every project writes the loop above or its own helpers. Roc has both, and they chain.
local numbers = {1, 2, 3, 4, 5, 6} local doubled_evens = {} for _, number in ipairs(numbers) do if number % 2 == 0 then doubled_evens[#doubled_evens + 1] = number * 2 end end print(table.concat(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({}) }
keep_if is filter and drop_if is its opposite. Each call returns a real list rather than a lazy view, so there is no iterator protocol to understand and no ipairs-versus-pairs decision to make.
Accumulating with fold
fold takes a starting value and a function, and threads the accumulator through the list — the loop above with the bookkeeping already written.
local numbers = {1, 2, 3, 4} local total = 0 for _, number in ipairs(numbers) do total = total + number end print(total)
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 starting value is not optional, so there is no empty-list special case to get wrong. sum exists for this particular fold because it is so common, and Lua has no equivalent of either in its standard library.
table.sort mutates; Roc returns a new list
sort returns a new list and orders by the element type, because the element type is known. There is no comparator to pass for the ordinary case.
local numbers = {3, 1, 2} table.sort(numbers) -- in place, returns nothing print(table.concat(numbers, ","))
main! = |_args| { numbers : List(I64) numbers = [3, 1, 2] echo!(Str.inspect(numbers.sort())) echo!(Str.inspect(numbers.sort_reversed())) echo!(Str.inspect(numbers)) Ok({}) }
The last Roc line shows numbers unchanged after two sorts. table.sort has no such option — it always rearranges the table you gave it, which matters when that table came from somewhere else.
Taking and dropping
The operations a slice is used for have names in Roc: take_first, drop_first, take_last and drop_last.
local numbers = {1, 2, 3, 4, 5} print(table.concat(table.move(numbers, 1, 2, 1, {}), ",")) print(table.concat(table.move(numbers, 3, #numbers, 1, {}), ","))
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3, 4, 5] echo!(Str.inspect(numbers.take_first(2))) echo!(Str.inspect(numbers.drop_first(2))) echo!(Str.inspect(numbers.take_last(2))) Ok({}) }
Lua's nearest equivalent is table.move with four index arguments, which is powerful and easy to get wrong by one. Naming the operation removes the arithmetic from the call site.
any, all and find
Three more things Lua's standard library leaves to you: any, all and find_first, each taking a predicate.
local numbers = {2, 4, 6, 7} local has_odd = false for _, number in ipairs(numbers) do if number % 2 == 1 then has_odd = true end end print(has_odd) local found = nil for _, number in ipairs(numbers) do if found == nil and number > 5 then found = number end end print(found and ("found " .. found) or "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 Lua idiom for "the first match, or nothing" leans on nil twice — once as the sentinel and once in the and/or that reports it — and breaks if a legitimate element is false. find_first returns a Try, which has no such hole.
Index and element together
Roc's map passes only the element, so the index needs a different method: map_with_index, which takes the element first and the index second.
local words = {"one", "two"} local labeled = {} for index, word in ipairs(words) do labeled[index] = index .. ":" .. word end print(table.concat(labeled, ","))
main! = |_args| { words = ["one", "two"] labeled = words.map_with_index(|word, index| "${index.to_str()}:${word}") echo!(Str.inspect(labeled)) Ok({}) }
Note that the printed indices differ, and that is the zero-based change rather than anything about the method: ipairs starts at 1 and map_with_index starts at 0.
Records vs Hash-Style Tables
Building from defaults
Roc's .. spread copies a record and overrides named fields in one expression — and it cannot introduce a field the record did not already have.
local defaults = {verbose = false, retries = 3, timeout_seconds = 30} local custom = {} for key, value in pairs(defaults) do custom[key] = value end custom.retries = 5 print(custom.retries .. " " .. custom.timeout_seconds)
main! = |_args| { defaults = { verbose: Bool.False, retries: 3.I64, timeout_seconds: 30.I64 } custom = { ..defaults, retries: 5 } echo!(Str.inspect(custom)) Ok({}) }
Lua has no spread, so the copy is a loop, and there is nothing stopping custom.retrys = 5 from adding a fourth key. In Roc the same typo names a field the record does not have, and the program does not compile.
Naming a shape
A one-line alias names a record shape. Nothing constructs an Employee — the literal already is one, because it has those fields with those types.
-- Lua's answer is a comment and a constructor. local function Employee(name, department) return {name = name, department = department} end local function describe(employee) return employee.name .. " works in " .. employee.department end print(describe(Employee("Nia", "Compilers")))
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({}) }
The Lua constructor function is doing two jobs: documenting the shape and enforcing the field order. Neither is needed here, and equality and Str.inspect work on the Roc record without being asked, because they work on every record.
Tuples, and multiple returns
Lua's multiple return values become a single tuple in Roc. The call site reads almost identically; the difference is that the tuple is a value with a type, which can be stored in a list or passed on.
local function divide(numerator, denominator) return numerator // denominator, numerator % denominator end local quotient, remainder = divide(17, 5) print(quotient .. " remainder " .. remainder)
divide : I64, I64 -> (I64, I64) divide = |numerator, denominator| (numerator // denominator, numerator % denominator) main! = |_args| { (quotient, remainder) = divide(17, 5) echo!("${quotient.to_str()} remainder ${remainder.to_str()}") Ok({}) }
Lua's multiple returns are adjusted silently to the context — extra values dropped, missing ones filled with nil — so a function that returns two things and a caller expecting three both "work". A Roc tuple's length is part of its type and cannot disagree.
Deep equality comes free
Roc's == compares values, all the way down, on every type — records, lists and tag unions included. Nothing has an identity separate from its contents.
local first = {x = 1, y = 2} local second = {x = 1, y = 2} print(first == second) -- false: identity, not -- contents print(first == first)
main! = |_args| { first = { x: 1.I64, y: 2.I64 } second = { x: 1.I64, y: 2.I64 } echo!(Str.inspect(first == second)) Ok({}) }
Lua compares tables by reference unless a __eq metamethod says otherwise, and defining one correctly for a nested structure is real work. Because a Roc value cannot be mutated, there is no reference to compare instead, so structural equality is the only thing == could mean.
Tag Unions
Enums, which Lua does not have
A declared union is written with :=, which creates a genuinely new type rather than an alias. Its variants are reached through it, as Color.Green.
local Color = {RED = "#FF0000", GREEN = "#00FF00", BLUE = "#0000FF"} print(Color.GREEN) print(Color.GREN) -- nil, and no complaint
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({}) }
The table-of-constants idiom is the closest Lua gets, and the second line is its weakness: a misspelled member is nil rather than an error. Color.Gren in Roc does not compile.
Tags that need no declaration at all
A tag can be used with no declaration anywhere. Morning is a value the moment you write it, and its type is inferred as the set of tags that can reach that position.
local hour = 14 local period = hour < 12 and "morning" or "afternoon" local label = period == "morning" and "AM" or "PM" print(label)
main! = |_args| { hour : I64 hour = 14 period = if hour < 12 { Morning } else { Afternoon } label = match period { Morning => "AM" Afternoon => "PM" } echo!(label) Ok({}) }
Lua's stand-in is a string, and a string is a poor discriminant: "mornign" compares false and nothing complains. Roc knows the union is exactly [Morning, Afternoon], so a misspelling is an error and so is a missing branch. Note also that the Lua and/or idiom breaks entirely if the "then" value is ever false.
Open unions: room for tags you have not met
The .. in the type means "and possibly other tags". The function handles two by name and everything else with a wildcard, and callers may pass tags that did not exist when it was written.
local function describe(signal) if signal == "go" then return "go" end if signal == "stop" then return "stop" end return "something else" end print(describe("go")) print(describe({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({}) }
This is the part with no Lua analogue. A function accepting "a string or a table or something" has a type nobody can write down; here the openness is stated in the signature, and the compiler still checks the closed part exhaustively.
Recursive data structures
A declared union may mention itself, which is how trees and syntax trees are written. No indirection is spelled out — the compiler works out where a pointer is needed.
local function sum_tree(tree) if tree.kind == "leaf" then return tree.value end return sum_tree(tree.left) + sum_tree(tree.right) end local tree = { kind = "node", left = {kind = "leaf", value = 1}, right = { kind = "node", left = {kind = "leaf", value = 2}, right = {kind = "leaf", value = 3}, }, } print(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({}) }
Both columns do the same work; the difference is what can be built. A malformed Lua tree — a node missing its right — is nil at whatever depth it sits, and the error names the arithmetic rather than the structure. A malformed Roc tree cannot be constructed.
Pattern Matching
match — and it is not string patterns
Lua has nothing called match except string.match, which is a different idea entirely — a text pattern language. Roc's match destructures a value against the shapes it could have, and it is an expression that produces a result.
local status_code = 404 local message if status_code == 200 then message = "ok" elseif status_code == 404 then message = "not found" else message = "something else" end print(message)
main! = |_args| { status_code : I64 status_code = 404 message = match status_code { 200 => "ok" 404 => "not found" _ => "something else" } echo!(message) Ok({}) }
Because it is an expression, the whole thing sits on the right of one =, every branch must produce the same type, and a branch that forgets to produce anything is an error. The Lua column has to declare message first and hope every path assigns it.
Guards
A guard is a condition attached to a pattern, so a single match covers what Lua writes as a ladder of early returns.
local function describe(number) if number == 0 then return "zero" end if number < 0 then return "negative" end if number % 2 == 0 then return "positive even" end return "positive odd" end print(describe(0)) print(describe(-5)) print(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({}) }
The Lua column is not wrong, but its shape hides something: nothing checks that the ladder is complete, and the final return is doing the work of a wildcard without being marked as one.
Matching on a list's shape
A pattern can describe a list's shape directly: empty, exactly one element, or a first element plus the rest — with the rest bound to a name.
local function describe(numbers) if #numbers == 0 then return "empty" end if #numbers == 1 then return "one: " .. numbers[1] end return "first " .. numbers[1] .. ", " .. (#numbers - 1) .. " more" end print(describe({})) print(describe({7})) print(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({}) }
Lua has no way to match on shape, so the same logic becomes length comparisons and indexing. Roc's compiler rejects a match whose patterns miss a possible list, so these three arms are required rather than conventional.
Exhaustiveness is checked, not hoped for
This is the payoff for declaring the union. The compiler knows every tag the value can be, so it can name the one you forgot, at the moment you forget it.
local function to_hex(color) if color == "red" then return "#FF0000" end if color == "green" then return "#00FF00" end -- "blue" is missing: this returns nil end print(to_hex("green")) print(to_hex("blue"))
Color := [Red, Green, Blue] to_hex : Color -> Str to_hex = |color| match color { Red => "#FF0000" Green => "#00FF00" # Deleting the next line is a COMPILE ERROR # naming Blue as the case not handled. Blue => "#0000FF" } main! = |_args| { echo!(to_hex(Color.Green)) echo!(to_hex(Color.Blue)) Ok({}) }
The Lua column prints nil for blue, because a function that falls off its end returns nothing. Adding a member to a table of constants breaks nothing at the point of addition and everything later; adding a variant in Roc breaks every incomplete match immediately.
Or-patterns
Alternatives within one branch are written with |, so the value is named once rather than repeated per comparison.
local function size_class(number) if number == 1 or number == 2 or number == 3 then return "small" end return "big" end print(size_class(2)) print(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({}) }
This is a small win on numbers and a large one on tags with payloads, where each Lua alternative would otherwise repeat both the field access and the comparison.
Try vs pcall and error
pcall becomes a returned value
Lua's pcall already returns success-and-value as a pair, which is the closest thing to Roc's Try in any of the languages on this site. Roc makes it the return type rather than a wrapper around the call.
local function parse_score(text) local score = tonumber(text) -- The 0 suppresses the "file:line:" prefix Lua -- would otherwise prepend to the message. if score == nil then error("bad score: " .. text, 0) end return score end for _, candidate in ipairs({"95", "not a number"}) do local ok, result = pcall(parse_score, candidate) if ok then print("score: " .. result) else print(result) end 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| { for candidate in ["95", "not a number"] { match parse_score(candidate) { Ok(score) => echo!("score: ${score.to_str()}") Err(BadScore(bad)) => echo!("bad score: ${bad}") } } Ok({}) }
Two things change. The signature announces that this function can fail and names how, so no caller has to guess whether pcall is needed. And the error is a typed tag rather than a string, so matching on it does not mean parsing prose.
Letting a failure bubble up
A raised error propagates by itself until something calls pcall. Roc's ? is the explicit version: it unwraps an Ok and returns early from the enclosing function on an Err.
local function show_first(numbers) local first = numbers[1] if first == nil then error("empty") end print("first: " .. first * 2) end show_first({5, 6, 7})
show_first! = |numbers| { first = numbers.first()? echo!("first: ${(first * 2).to_str()}") Ok({}) } main! = |_args| { numbers : List(I64) numbers = [5, 6, 7] show_first!(numbers) }
One character marks every place a function can exit early, so reading the body tells you its failure paths. In Lua any call at all might raise, which is why a pcall boundary tends to be drawn around whole subsystems rather than individual operations.
Error types compose without strings
An error in Roc is a tag, so a new error kind needs no class and no registration. The set of errors a function can produce is written in its return type, as a union.
local function read_port(text) local value = tonumber(text) if value == nil or value < 0 or value > 65535 then error({code = "BadPort", text = text}) end return value end for _, candidate in ipairs({"8080", "eighty"}) do local ok, result = pcall(read_port, candidate) if ok then print(result) else print("bad port: " .. result.text) end 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| { for candidate in ["8080", "eighty"] { match read_port(candidate) { Ok(port) => echo!(port.to_str()) Err(BadPort(bad)) => echo!("bad port: ${bad}") } } Ok({}) }
Lua can raise a table rather than a string — the workaround above — but nothing checks its shape, so the handler's result.text is a guess. Choosing U16 in the Roc column also makes the range check part of the parse, so the bounds are not restated by hand.
crash, for what cannot happen
There is exactly one way to stop a Roc program abruptly, and it is deliberately unlike error: crash cannot be caught, so it can only ever mean "this state is impossible".
local function divide(numerator, denominator) assert(denominator ~= 0, "impossible: checked upstream") return numerator // denominator end print(divide(10, 2))
divide : I64, I64 -> I64 divide = |numerator, denominator| if denominator == 0 { # crash is not catchable. It is for states # the program has already established # cannot happen. crash "impossible: checked upstream" } else { numerator // denominator } main! = |_args| { echo!(divide(10, 2).to_str()) Ok({}) }
Lua's assert is close in spirit and weaker in practice, since a pcall anywhere up the stack will catch it and carry on with whatever state caused it. A crash has no handler to reach for.
Functions & Closures
One function form
Roc has one way to write a function, and it is the anonymous one. A named function is a name bound to a closure, so the top-level and local forms are identical.
local function add(left, right) return left + right end local also_add = function(left, right) return left + right end print(add(2, 3)) print(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({}) }
This will feel familiar: Lua's local function f() is already sugar for assigning an anonymous function, and functions are first-class values in both languages. The parameter list uses | rather than parentheses, which is the only visual change.
Closures capture values, not upvalues
Lua's closures capture variables, so a returned function can keep mutating the one it closed over — which is how every Lua counter, iterator and object-without-a-metatable is built.
local function make_counter() local count = 0 return function() count = count + 1 -- mutating the upvalue return count end end local next_value = make_counter() print(next_value()) print(next_value())
main! = |_args| { # A Roc closure captures VALUES, so it cannot # hold mutable state between calls. Thread the # state through instead: step : I64 -> I64 step = |count| count + 1 first = step(0) second = step(first) echo!(first.to_str()) echo!(second.to_str()) Ok({}) }
Roc closures capture values, so that whole idiom is unavailable and the state has to be passed along explicitly. This is a genuine loss of expressiveness in exchange for a genuine guarantee: nothing that was captured can change afterwards, so no two closures can disagree about it.
No varargs, no default arguments
Roc functions take a fixed number of arguments. There are no defaults and no ..., so the pattern that replaces both is a record of options with a named set of defaults.
local function connect(host, port, options) port = port or 8080 options = options or {} local verbose = options.verbose or false return host .. ":" .. port .. " verbose=" .. tostring(verbose) end print(connect("example.com")) print(connect("example.com", nil, {verbose = true}))
Options : { host : Str, port : U16, verbose : Bool } connect : Options -> Str connect = |options| "${options.host}:${options.port.to_str()} verbose=${Str.inspect(options.verbose)}" main! = |_args| { defaults = { host: "example.com", port: 8080.U16, verbose: Bool.False } echo!(connect(defaults)) echo!(connect({ ..defaults, verbose: Bool.True })) Ok({}) }
The Lua column shows the cost of the alternative: three lines of or defaulting before the function does any work, and a nil in the middle of the second call because there is no way to skip a positional argument. Adding an option in Roc is a change to one record rather than to every call site.
Generic functions
A lowercase name in a signature is a type variable. a -> a says the function returns exactly the type it was given, whatever that is.
local function identity(value) return value end print(identity("same")) print(identity(7))
identity : a -> a identity = |value| value main! = |_args| { echo!(identity("same")) echo!(identity(7.I64).to_str()) Ok({}) }
A Lua function is generic by accident, because nothing checks it. A Roc one is generic on purpose, and the signature is a promise: a -> a cannot inspect the value, because nothing at all is known about it.
Duck typing, checked in advance
A where clause says what the function needs from its type variable — here, a to_str method with that signature. It is duck typing with the bill checked before the meal.
local function announce(value) -- Works for anything tostring accepts — -- discovered when it does not. print(tostring(value)) 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({}) }
This is the closest thing Roc has to an interface, and it is structural: no type declares that it satisfies the clause, it simply has the method or it does not. Nothing has to be added to a metatable for it to qualify.
Control Flow
if is an expression
Roc has no if statement — if produces a value, so the multi-branch choice is written once and reads as a single expression.
local score = 85 local grade if score >= 90 then grade = "A" elseif score >= 80 then grade = "B" else grade = "C" end print(grade)
main! = |_args| { score : I64 score = 85 grade = if score >= 90 { "A" } else if score >= 80 { "B" } else { "C" } echo!(grade) Ok({}) }
Lua's cond and a or b idiom exists precisely because its if is a statement, and it is subtly broken whenever a can be false or nil. Roc needs no such trick, because if already is the expression form.
Iterating a list
Roc's for loop is available only in effectful code, because a loop that produces no value has nothing to do in a pure function.
for _, word in ipairs({"alpha", "beta", "gamma"}) do print(word) end
main! = |_args| { for word in ["alpha", "beta", "gamma"] { echo!(word) } Ok({}) }
There is one loop form rather than Lua's numeric-and-generic pair, and no index to discard — so the _ in ipairs disappears. The same loop can also be written as a method, words.for_each!(|word| echo!(word)), which is the form that chains.
while and break
Roc has a real while loop with break, which surprises people expecting a functional language to insist on recursion. It needs a var, since a loop over an unchanging condition would never end.
local count = 0 while count < 5 do count = count + 1 if count == 3 then break end end print(count)
main! = |_args| { var $count = 0.I64 while $count < 5 { $count = $count + 1 if $count == 3 { break } } echo!($count.to_str()) Ok({}) }
There is no repeat ... until and no goto, so a loop that wants either is usually asking to be a fold instead.
Guard clauses and early return
return exists and does what you expect, so the guard-clause style transfers unchanged. The last expression of a block is its value, so the final line needs no return.
local function clamp_positive(number) if number < 0 then return 0 end return number end print(clamp_positive(-5)) print(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({}) }
Lua requires return to be the last statement in a block, which is why its guard clauses sometimes need an extra do ... end. Roc has no such restriction, and a function cannot fall off its end producing nothing.
Tail calls, in both languages
A call in tail position — the last thing a function does — is compiled to a jump rather than a new stack frame, so the recursion runs in constant stack space.
local function count_down(limit, steps) if limit <= 0 then return steps end return count_down(limit - 1, steps + 1) end print(count_down(100000, 0))
count_down : I64, I64 -> I64 count_down = |limit, steps| { if limit <= 0 { steps } else { count_down(limit - 1, steps + 1) } } main! = |_args| { echo!(count_down(100_000, 0).to_str()) Ok({}) }
This is one of the few places Lua and Roc simply agree, and both columns print 100000. Lua guarantees proper tail calls too, which is why return f(x) in Lua is genuinely different from local y = f(x); return y — the second one is not in tail position and does grow the stack.
Purity, Effects & Coroutines
A pure function cannot print
Two things mark an effectful function: its name ends in ! and its arrow is => rather than ->. Only an effectful function may call another one, and the compiler enforces it.
local function describe(name) return "hello " .. name end local function announce(name) print(describe(name)) -- nothing stops a "pure" -- function from printing end announce("Roc")
# Pure: Str -> Str (thin 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({}) }
Adding an echo! to describe would not compile: its signature would have to change, and so would every caller. That is the same propagation Lua sandboxes try to achieve by removing functions from the environment, done in the type system instead.
No coroutines
Coroutines are one of Lua's best ideas and Roc has no equivalent: no yield, no resumable function, and no generator.
local generate = coroutine.wrap(function() for number = 1, 3 do coroutine.yield(number * number) end end) print(generate()) print(generate()) print(generate())
# Roc has no coroutines and no yield. A sequence # is produced all at once, as a value: squares : List(I64) squares = [1, 2, 3].map(|number| number * number) main! = |_args| { for square in squares { echo!(square.to_str()) } Ok({}) }
For the generator case the replacement is to build the whole sequence, which is fine when it is small and wrong when it is large or infinite. For the cooperative-scheduling case there is no replacement in the language at all — concurrency belongs to the platform, and what a given platform offers is its own decision.
expect is part of the language
expect is a keyword rather than a library function, and it can appear at the top level of a file as well as inside one — which is how Roc writes unit tests without a testing framework.
local total = 2 + 2 assert(total == 4) print("the assertion held")
main! = |_args| { total : I64 total = 2 + 2 expect total == 4 echo!("the assertion held") Ok({}) }
A failing expect prints every value that fed the expression, not just the expression that was false. Lua's assert prints only the message you remembered to pass it, which is why so many of them say nothing useful.
No Interpreter, No Collector
Reference counting, with no cycles to collect
Lua needs a tracing collector precisely because of the third line here: two tables holding each other would keep each other alive forever under simple reference counting. Roc needs no collector, and this row is why.
local first = {name = "first"} local second = {name = "second", peer = first} first.peer = second -- closing the cycle, which -- only works because first -- can still be changed print(second.peer.name) print(first.peer.name)
main! = |_args| { first = { name: "first" } second = { name: "second", peer: first } # first cannot be made to point back at second: # it was finished the moment it was defined, so # this program has no second line to print. echo!(second.peer.name) Ok({}) }
The Lua column closes the cycle and prints twice; the Roc column cannot, so it prints once. Nothing in Roc can be made to point back at a value that already exists, which is exactly the condition under which counting alone suffices — so the counts are inserted by the compiler and there is no collector to tune, step or pause.
Functional updates that mutate when it is safe
Semantically set builds a new list. When the reference count of the old one is one — nobody else is holding it — the compiler mutates in place and copies nothing.
local numbers = {1, 2, 3} local updated = {} for index, value in ipairs(numbers) do updated[index] = value end updated[2] = 99 print(table.concat(updated, ",")) print(table.concat(numbers, ","))
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3] updated = numbers.set(1, 99) ?? numbers echo!(Str.inspect(updated)) echo!(Str.inspect(numbers)) Ok({}) }
That is why writing in an immutable style costs less in Roc than the copy-the-table-first style costs in Lua. Here both lists are printed, so both must exist and a copy really is made; drop the second echo! and the copy disappears.
Top-level values are computed before the program starts
A top-level Roc definition is evaluated by the compiler, not at startup. By the time the program runs, squared is the constant 100 baked into the binary.
local limit = 10 local squared = limit * limit -- computed when the -- chunk is loaded print(squared)
limit : I64 limit = 10 squared : I64 squared = limit * limit main! = |_args| { echo!(squared.to_str()) Ok({}) }
Lua does this work every time the chunk is loaded. Both languages start fast — Lua's tiny interpreter is one of its selling points — but a compiled Roc binary has no load step at all and needs no interpreter alongside it.
Metatables vs Method Blocks
The metatable class idiom becomes a method block
The .{ } after a type definition is a block of functions associated with that type. There is no self, no __index and no setmetatable — each function takes the value as an ordinary parameter.
local Counter = {} Counter.__index = Counter function Counter.new(value) return setmetatable({value = value or 0}, Counter) end function Counter:increment() return Counter.new(self.value + 1) end function Counter:describe() return "count is " .. self.value end print(Counter.new():increment():increment():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({}) }
Method syntax works because the compiler resolves counter.describe() from the type it already knows, so there is no table to look up and no chain to walk. The colon-versus-dot distinction disappears with self, and so does the bug where one of them is written where the other was meant.
No operator overloading
Roc has no metamethods. + means numeric addition and nothing else, so a type that wants to be added needs an ordinary named function.
local Vector = {} Vector.__index = Vector Vector.__add = function(left, right) return setmetatable({x = left.x + right.x, y = left.y + right.y}, Vector) end local sum = setmetatable({x = 1, y = 2}, Vector) + setmetatable({x = 3, y = 4}, Vector) print(sum.x .. "," .. sum.y)
Vector : { x : I64, y : I64 } add : Vector, Vector -> Vector add = |left, right| { x: left.x + right.x, y: left.y + right.y } main! = |_args| { sum = add({ x: 1, y: 2 }, { x: 3, y: 4 }) echo!("${sum.x.to_str()},${sum.y.to_str()}") Ok({}) }
What you lose is the notation. What you lose along with it is __index, __newindex, __call and the rest — which means no value in a Roc program can behave differently from how it reads, and no library can change what an operator does to types it did not define.
A distinct type, not a renamed number
Defining a type with := rather than : makes it nominal — a genuinely distinct type that a plain number cannot stand in for, however similar the underlying data.
-- Lua's answer is a naming convention. local function greet(user_id) return "user #" .. user_id end print(greet(42)) print(greet("42")) -- also fine, and probably a bug
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)) # greet(42) does not compile. Ok({}) }
This is the mechanism behind "parse, do not validate": once a value has been through UserId, every function downstream knows it was checked, and the compiler will not let an unchecked number take its place.
Gotchas for Lua Programmers
An untyped integer prints as a decimal
This is the first thing that will confuse you. An unconstrained number literal defaults to Dec, so a list that looks like integers prints as [1.0, 2.0, 3.0].
print(1 + 2) print(math.type(1 + 2))
main! = |_args| { # No annotation: these literals become Dec, # and print with a decimal point. echo!(Str.inspect([1, 2, 3])) echo!(Str.inspect(1 + 2)) typed : List(I64) typed = [1, 2, 3] echo!(Str.inspect(typed)) Ok({}) }
The fix is an annotation or a suffix: typed : List(I64), or 42.I64 on the literal. Lua 5.3 has a mild version of the same surprise — 7 / 2 is a float even when both operands are integers — so the instinct to check which numeric type you have is already there.
A bare True is not a Bool
Bool is an ordinary tag union in Roc, and True and False written bare are just tags — not necessarily that union.
local ready = false print(not ready)
main! = |_args| { # Without the annotation, "False" is inferred as # a one-off structural tag rather than a Bool, # and ! would have nothing to negate. ready : Bool ready = Bool.False echo!(Str.inspect(!ready)) Ok({}) }
Annotating the binding, or writing Bool.True and Bool.False in full, pins it down. Note also that negation is ! rather than not, and that != replaces ~=.
There is no working [i] on a list
Subscript syntax exists in the grammar and does not work, which is worse than not existing — the error it produces talks about type variables rather than about indexing.
local numbers = {10, 20, 30} print(numbers[1]) print(numbers[#numbers])
main! = |_args| { numbers : List(I64) numbers = [10, 20, 30] # numbers[0] parses in this build but does not # type-check into a usable value. echo!((numbers.get(0) ?? 0).to_str()) echo!((numbers.last() ?? 0).to_str()) Ok({}) }
Use get(index), and first() or last() for the ends. Remember that the index is zero-based and is a U64, so there is no negative index and no #numbers arithmetic to do.
The standard library is still settling
Roc is pre-1.0 and its standard library is visibly incomplete. Functions that Lua has had since the 1990s are simply absent, and which ones are absent changes between nightly builds.
-- The extra parentheses discard gsub's second -- return value, the replacement count. print((("hello world"):gsub(" ", "_"))) print(("shout"):upper())
main! = |_args| { # There is no Str.replace in this build, and no # case conversion — compose what exists: parts = "hello world".split_on(" ") echo!(Str.join_with(parts, "_")) echo!("shout") Ok({}) }
Lua's standard library is famously small and this one is smaller. The compensation is in the other direction: Lua leaves you to write map, filter, split and a class system, all of which Roc provides.
Not yet an embedding language
It is worth being clear that Roc is not a replacement for what Lua is best at. There is no interpreter to embed, no load, no eval, and no way to run code that was not present when the program was compiled.
-- Lua's whole reason for existing: a 300 KB -- interpreter you drop into a C program, with a -- C API everybody already knows. print("embedded in: a game, a router, a database")
main! = |_args| { # Roc's platform model inverts the relationship. # The host does not call Roc as a scripting # language; the host IS the program, and the Roc # application is compiled into it ahead of time. echo!("no interpreter to embed, and no eval") Ok({}) }
A Roc platform and a Lua host answer different questions. A host embeds Lua so that behavior can be changed without recompiling; a Roc platform exists so that a pure application can be compiled against whichever set of effects a target needs. If your requirement is user-supplied scripts at run time, Roc has no answer for it.

Thank you — anything else?