PONY λ M2 Modula-2

Elm.CodeCompared.To/Roc

An interactive executable cheatsheet comparing Elm and Roc

Elm 0.19.1 Roc nightly (2026-07-02)
Hello World & Effects
Hello, World
Neither language has a bare print statement — output happens through a value the runtime handles. In Elm that value is main; in Roc it is main!, whose ! marks it as effectful.
module Main exposing (main) import Html exposing (text) main = text "Hello, World!"
main! = |_args| { echo!("Hello, World!") Ok({}) }
A Roc main! is a function of the program arguments that returns a Try (Ok({}) is success), and echo! is the one output effect the platform on this page provides. Where Elm renders main through its managed runtime, Roc runs main! on a platform that supplies each effect — the ! suffix is how the type system tracks which functions perform them.
The ! effect marker
In Elm, a function is either pure or it produces a Cmd the runtime performs; there is no marker on the function itself. Roc instead brands effectful functions with a trailing !.
module Main exposing (main) import Html exposing (text) greet : String -> String greet name = "Hello, " ++ name main = text (greet "Roc")
greet : Str -> Str greet = |name| "Hello, ${name}" main! = |_args| { echo!(greet("Roc")) Ok({}) }
greet is pure in both, and its type signature reads almost identically. The difference is echo!: any function that calls an effect must itself end in !, so effectfulness is visible right in the name and checked by the compiler — a lighter-weight version of the discipline The Elm Architecture enforces by routing all effects through update.
Values & Immutability
Local bindings
Both languages name intermediate values with immutable local bindings and have no reassignment anywhere.
module Main exposing (main) import Html exposing (text) main = let greeting = "Hello" name = "Roc" in text (greeting ++ ", " ++ name ++ "!")
main! = |_args| { greeting = "Hello" name = "Roc" echo!("${greeting}, ${name}!") Ok({}) }
Roc drops Elm's let ... in keywords: inside a block, a plain name = value introduces a binding, and the block's final expression is its result. Both forbid rebinding, so neither has var or mutation. Note the string handling already diverging — Elm concatenates with ++, Roc interpolates with ${...}.
Types & Annotations
Type annotations
Type signatures look almost the same in both — a name, a colon, argument types, an arrow, and a return type — and both compilers infer them when omitted.
module Main exposing (main) import Html exposing (text) double : Int -> Int double n = n * 2 main = text (String.fromInt (double 21))
double : I64 -> I64 double = |n| n * 2 main! = |_args| { echo!(double(21).to_str()) Ok({}) }
The only surface change is the numeric type name — Elm's Int is one of Roc's several fixed-width integers, here I64 — and the definition's |n| lambda. Roc converts a number to a string with the method .to_str() where Elm calls String.fromInt.
Type aliases
A type alias gives a record shape a name; in both languages it is transparent, so any record with the right fields fits.
module Main exposing (main) import Html exposing (text) type alias Person = { name : String, age : Int } describe : Person -> String describe person = person.name ++ " is " ++ String.fromInt person.age main = text (describe { name = "Ada", age = 36 })
Person : { name : Str, age : I64 } describe : Person -> Str describe = |person| "${person.name} is ${person.age.to_str()}" main! = |_args| { echo!(describe({ name: "Ada", age: 36 })) Ok({}) }
Roc writes the alias with a single colon and no type alias keywords: Person : { ... }. Record fields use : in the type and in the literal, where Elm uses : in the type but = in the literal. When you want a nominal type that will not unify with a look-alike record, Roc uses := instead — shown under Custom Types.
Strings
Interpolation vs concatenation
Building a string from parts is one of the few places Roc is the more convenient of the two.
module Main exposing (main) import Html exposing (text) main = let name = "Roc" year = 2019 in text (name ++ " first appeared in " ++ String.fromInt year)
main! = |_args| { name = "Roc" year : I64 year = 2019 echo!("${name} first appeared in ${year.to_str()}") Ok({}) }
Roc has string interpolation — "${expr}" — which Elm deliberately omits, so Elm joins pieces with ++ and explicit String.fromInt conversions. Note Roc still converts the number by hand inside the braces (year.to_str()); interpolation splices strings, it does not stringify for you.
String functions
Both keep string operations in a module of functions rather than as methods on the string, and every one returns a new string.
module Main exposing (main) import Html exposing (text) main = let joined = String.join " / " [ "one", "two", "three" ] in text (joined ++ " — " ++ String.fromInt (String.length joined) ++ " chars")
main! = |_args| { joined = Str.join_with(["one", "two", "three"], " / ") echo!("${joined} — ${joined.count_utf8_bytes().to_str()} bytes") Ok({}) }
Elm writes String.join sep list; Roc offers the same as Str.join_with(list, sep), which can also be called method-style. Measuring size differs on purpose: Elm's String.length counts characters, while Roc has no String.length at all — "length" is ambiguous for Unicode, so you ask for exactly what you mean, here .count_utf8_bytes().
Numbers
Int and Float
Both keep whole numbers and fractional numbers as distinct types, but Roc splits each into several fixed-width kinds.
module Main exposing (main) import Html exposing (text) main = let whole = 7 // 2 exact = 7 / 2 in text (String.fromInt whole ++ " and " ++ String.fromFloat exact)
main! = |_args| { whole : I64 whole = 7 // 2 exact : F64 exact = 7.0 / 2.0 echo!("${whole.to_str()} and ${exact.to_str()}") Ok({}) }
// is integer division in both. Where Elm has just Int and Float, Roc has I8I128, U8U128, F32/F64, and the fixed-point Dec. That precision has a catch: an unannotated literal defaults to Dec and prints as 1.0, so integers whose output matters need an annotation (whole : I64) or a suffix (42.I64).
Lists
map and filter
Transforming a list with higher-order functions is core to both languages; watch how the calls are arranged.
module Main exposing (main) import Html exposing (text) main = [ 1, 2, 3, 4, 5, 6 ] |> List.filter (\n -> modBy 2 n == 0) |> List.map (\n -> n * n) |> List.map String.fromInt |> String.join ", " |> text
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3, 4, 5, 6] result = numbers .keep_if(|n| n % 2 == 0) .map(|n| n * n) echo!(Str.inspect(result)) Ok({}) }
The operations are the same idea with different names and shape: Elm's List.filter is Roc's .keep_if, and both have .map. But Elm threads the list through with the |> pipe, while Roc chains methods with dots — because this build of Roc has no |> operator at all (see The Pipe Operator). Lambdas are \n -> in Elm, |n| in Roc.
Folding a list
A fold reduces a whole list to one value by threading an accumulator through it.
module Main exposing (main) import Html exposing (text) main = let total = List.foldl (+) 0 [ 10, 20, 30 ] in text (String.fromInt total)
main! = |_args| { numbers : List(I64) numbers = [10, 20, 30] total = numbers.fold(0, |accumulator, n| accumulator + n) echo!(total.to_str()) echo!(numbers.sum().to_str()) Ok({}) }
Elm's List.foldl and Roc's .fold are the same operation; Roc puts the starting accumulator first, then the combining function. Roc also ships shortcuts Elm's core lacks, like .sum(). And where Elm passes the bare operator (+) as a function, this Roc build wants an explicit |accumulator, n| closure.
Indexing returns a wrapped value
Neither language lets you index a list and get back a bare element that might not exist — the "might be missing" shows up in the type.
module Main exposing (main) import Html exposing (text) main = let numbers = [ 10, 20, 30 ] shown = case List.head numbers of Just first -> String.fromInt first Nothing -> "empty" in text shown
main! = |_args| { numbers : List(I64) numbers = [10, 20, 30] match numbers.first() { Ok(first) => echo!(first.to_str()) Err(_) => echo!("empty") } Ok({}) }
Elm's List.head returns a Maybe, matched with Just/Nothing. Roc's .first() (and .get(i)) returns a Try instead, matched with Ok/Err — Roc has no Maybe type, so it reuses Try for "present or absent" as well as "succeeded or failed".
Records
Defining and reading a record
Records — values with a fixed, named set of fields, compared by value — work almost identically in the two languages.
module Main exposing (main) import Html exposing (text) main = let alice = { name = "Alice", age = 30 } in text (alice.name ++ " is " ++ String.fromInt alice.age)
main! = |_args| { alice = { name: "Alice", age: 30.I64 } echo!("${alice.name} is ${alice.age.to_str()}") Ok({}) }
Dot access is the same. The one keystroke that differs: a record literal uses = in Elm ({ name = "Alice" }) and : in Roc ({ name: "Alice" }). Both are pure data with no methods, no this, and structural typing, so two records with the same fields are the same type.
Updating a record
Because nothing mutates, "changing" a record means producing a new one with some fields replaced.
module Main exposing (main) import Html exposing (text) main = let alice = { name = "Alice", age = 30 } older = { alice | age = alice.age + 1 } in text (older.name ++ " is now " ++ String.fromInt older.age)
main! = |_args| { alice = { name: "Alice", age: 30.I64 } older = { ..alice, age: alice.age + 1 } echo!("${older.name} is now ${older.age.to_str()}") Ok({}) }
The idea is identical; the syntax differs. Elm writes the record first and the changes after a bar: { alice | age = ... }. Roc spreads the original with two leading dots and lists the changes after: { ..alice, age: ... }. Both reuse the original's memory when nothing else references it, so the "copy" is often free.
Custom Types & Tags
Enumerations
Elm's custom types and Roc's tags both let you name a fixed set of alternatives and match on them exhaustively.
module Main exposing (main) import Html exposing (text) type Light = Red | Yellow | Green next : Light -> Light next light = case light of Red -> Green Yellow -> Red Green -> Yellow main = text (Debug.toString (next Red))
next : [Red, Yellow, Green] -> [Red, Yellow, Green] next = |light| match light { Red => Green Yellow => Red Green => Yellow } main! = |_args| { echo!(Str.inspect(next(Red))) Ok({}) }
Elm requires the type declared up front (type Light = Red | ...); Roc's tags are structural and need no declaration at all — writing Red gives the value the inferred type [Red, Yellow, Green]. Both check the case/match for exhaustiveness. Elm's Debug.toString and Roc's Str.inspect both render a value for display.
Variants that carry data
Each alternative can carry its own payload, making the type a true tagged union rather than a plain enum.
module Main exposing (main) import Html exposing (text) type Shape = Circle Float | Rectangle Float Float area : Shape -> Float area shape = case shape of Circle radius -> pi * radius * radius Rectangle width height -> width * height main = text (String.fromFloat (area (Rectangle 3 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.Rectangle(3, 4)).to_str()) Ok({}) }
The payload rides along in both. Two differences: Roc wraps a variant's data in parentheses (Circle(radius)) where Elm uses juxtaposition (Circle radius); and here := declares Shape as a nominal type — one that will not unify with a look-alike tag union — which is Roc's opt-in when it wants Elm's default nominal behavior.
Maybe & Result vs Tags & Try
Maybe becomes an ad-hoc tag union
Elm reaches for Maybe whenever a value might be absent. Roc has no Maybe type at all.
module Main exposing (main) import Html exposing (text) lookup : String -> Maybe Int lookup key = if key == "answer" then Just 42 else Nothing main = text (case lookup "answer" of Just value -> String.fromInt value Nothing -> "missing" )
lookup : Str -> [Found(I64), Missing] lookup = |key| if key == "answer" { Found(42) } else { Missing } main! = |_args| { match lookup("answer") { Found(value) => echo!(value.to_str()) Missing => echo!("missing") } Ok({}) }
Instead of Elm's single built-in Maybe with its fixed Just/Nothing, Roc writes an ad-hoc tag union like [Found(I64), Missing] — no declaration needed, and the "absent" case gets a name that means something. When absence is really a failure, Roc uses Try instead; either way the missing case is a tag the compiler makes you handle.
Result becomes Try
A computation that can fail returns a two-branch value the caller must inspect — Elm's Result, Roc's Try.
module Main exposing (main) import Html exposing (text) safeDivide : Float -> Float -> Result String Float safeDivide numerator denominator = if denominator == 0 then Err "cannot divide by zero" else Ok (numerator / denominator) main = text (Debug.toString (safeDivide 10 2))
safe_divide : F64, F64 -> Try(F64, Str) safe_divide = |numerator, denominator| { if denominator == 0.0 { Err("cannot divide by zero") } else { Ok(numerator / denominator) } } main! = |_args| { echo!(Str.inspect(safe_divide(10, 2))) Ok({}) }
The shape is the same — Ok on success, Err on failure, matched by pattern — so this transfers almost verbatim. Roc's type is spelled Try(ok, err) where Elm's is Result err ok (note the order flips), and Ok/Err take parenthesized payloads. Roc reuses this one type for both "might fail" and, as the previous rows showed, "might be absent".
andThen chains vs the ? operator
When several steps can each fail, you want the first failure to short-circuit the rest. Elm threads that with Result.andThen; Roc has dedicated syntax.
module Main exposing (main) import Html exposing (text) parsePositive : String -> Result String Int parsePositive raw = case String.toInt raw of Nothing -> Err "not a number" Just n -> if n > 0 then Ok n else Err "not positive" main = text (Debug.toString (parsePositive "7"))
parse_positive : Str -> Try(I64, Str) parse_positive = |raw| { n = I64.from_str(raw) ? |_| "not a number" if n > 0 { Ok(n) } else { Err("not positive") } } main! = |_args| { echo!(Str.inspect(parse_positive("7"))) Ok({}) }
Elm chains fallible steps explicitly with Result.andThen (or nested case). Roc's postfix ? does the threading for you: it unwraps an Ok and early-returns any Err to the caller, with ? |_| "not a number" here mapping the underlying error into our own. It is the ergonomic step Elm intentionally leaves as an explicit function call.
Pattern Matching
case ... of becomes match
Pattern matching is the primary control-flow tool in both languages, choosing a branch by a value's shape.
module Main exposing (main) import Html exposing (text) describe : List Int -> String describe numbers = case numbers of [] -> "empty" [ only ] -> "one: " ++ String.fromInt only first :: _ -> "starts with " ++ String.fromInt first main = text (describe [ 1, 2, 3 ])
describe : List(I64) -> Str describe = |numbers| match numbers { [] => "empty" [only] => "one: ${only.to_str()}" [first, ..] => "starts with ${first.to_str()}" } main! = |_args| { echo!(describe([1, 2, 3])) Ok({}) }
Same tool, lighter syntax: Elm's case x of with -> arrows becomes Roc's match x { } with => arrows and braces instead of indentation. List patterns line up closely — Elm's head/tail first :: _ is Roc's [first, ..]. Both compilers reject a match that misses a case.
Functions
Lambdas and definitions
A top-level function in both languages is just a name bound to a lambda — no function keyword, no return.
module Main exposing (main) import Html exposing (text) add : Int -> Int -> Int add a b = a + b main = let triple = \n -> n * 3 in text (Debug.toString (List.map triple [ 1, 2, 3 ]))
add : I64, I64 -> I64 add = |a, b| a + b main! = |_args| { triple = |n| n * 3 echo!(Str.inspect([1, 2, 3].map(triple))) Ok({}) }
The building blocks match, but the notation differs in two places: Roc's lambda wraps its parameters in pipes, |n| n * 3, where Elm uses a backslash and arrow, \n -> n * 3; and Roc separates a function's argument types with commas (I64, I64 -> I64) where Elm chains arrows (Int -> Int -> Int). Both curry, so partial application works in each.
The Pipe Operator
The |> pipe is gone
|> is everywhere in idiomatic Elm — the operator that threads a value through a series of functions, left to right. This is the single biggest habit to unlearn moving to Roc.
module Main exposing (main) import Html exposing (text) main = [ 5, 3, 8, 1 ] |> List.sort |> List.reverse |> List.map String.fromInt |> String.join ", " |> text
main! = |_args| { numbers : List(I64) numbers = [5, 3, 8, 1] result = numbers .sort_with(|left, right| if left < right { LT } else if left > right { GT } else { EQ }) .rev() echo!(Str.inspect(result)) Ok({}) }
This build of Roc has removed |> entirely. The same left-to-right reading is recovered with method-call chaining — value.first().second() instead of value |> first |> second — which works because Roc lets any function be called as a method on its first argument. It reads similarly, but a longtime Elm developer will reach for |> by reflex and have to stop.
Effects & The Platform
The platform vs The Elm Architecture
Neither language lets a pure function secretly perform I/O. They enforce that in strikingly different ways.
module Main exposing (main) import Html exposing (text) main = -- In Elm, effects are Cmd values the runtime performs, -- wired through Model/update/view (The Elm Architecture). -- A pure function can never do I/O on its own. text "effects run through the runtime"
main! = |_args| { # In Roc, effects are ! functions provided by a platform. # main! may call them; a plain function (no !) may not. echo!("effects run through the platform") Ok({}) }
Elm centralizes effects: your code returns Cmd values and the runtime performs them, so every effect flows through update. Roc marks effects at the function level with ! and hands the actual implementations to a platform the program is compiled against — the same Roc source can target a CLI, a web server, or this page's echo platform. Elm has one platform (the browser); Roc makes the platform a swappable choice.
Memory: No Garbage Collector
No garbage collector
Elm runs on JavaScript, so it inherits a garbage collector and never asks you to think about memory. Roc has neither a GC nor manual management.
module Main exposing (main) import Html exposing (text) main = let original = [ 1, 2, 3 ] updated = List.map (\n -> n + 1) original in text (Debug.toString updated)
main! = |_args| { original : List(I64) original = [1, 2, 3] updated = original.map(|n| n + 1) echo!(Str.inspect(updated)) Ok({}) }
The code looks the same and both keep values immutable, but underneath they differ sharply. Elm relies on the JavaScript runtime's tracing garbage collector. Roc uses automatic reference counting decided at compile time, and — because it can see when a value is no longer shared — it mutates in place instead of copying (opportunistic mutation), giving immutable code the performance of mutable code with no runtime GC pauses.