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 I8…I128, U8…U128, 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.