Hello World & The Platform Model
Hello, World
main :: IO ()
main = putStrLn "Hello, World!" main! = |_args| {
echo!("Hello, World!")
Ok({})
} Haskell requires a named
main :: IO () entry point that the runtime calls once. Roc's main! plays the same role, but the ! suffix marks it as effectful — Roc tracks purity in the type system rather than bundling every top-level action inside an implicit IO monad. main! must return a Try; Ok({}) signals success.Where I/O comes from
main :: IO ()
main = putStrLn "printed via GHC's runtime system, talking directly to the OS" main! = |_args| {
echo!("printed via whatever platform is hosting this Roc program")
Ok({})
} GHC compiles straight to a native binary that talks to the OS on its own. Roc instead compiles to a "platform-agnostic app" that cannot perform I/O by itself at all — a separate platform (often written in Rust or Zig) supplies every effect, including
echo! here. This page runs on the vendored echo.wasm platform, which supports only echo!.Sequencing multiple outputs
main :: IO ()
main = do
putStrLn "First"
putStrLn "Second"
putStrLn "Third" main! = |_args| {
echo!("First")
echo!("Second")
echo!("Third")
Ok({})
} Haskell needs
do-notation to sequence IO actions, since it desugars to chained monadic binds. Roc has no monad to desugar — an effectful block is just an ordinary sequence of statements executed top to bottom, closer to how a strict, imperative-flavored language would read.Values & Immutability
Immutable by default in both languages
main :: IO ()
main =
let count = 0
in print count main! = |_args| {
count : I64
count = 0
echo!(count.to_str())
Ok({})
} Both languages default every binding to immutable, with no assignment operator for a plain
name = value binding. Roc, like Haskell, requires a completely separate, visually distinct construct to opt into local mutation at all (next row).Opting in to mutation
import Data.IORef
main :: IO ()
main = do
ref <- newIORef 0
modifyIORef ref (+1)
value <- readIORef ref
print value main! = |_args| {
var $count = 0.I64
$count = $count + 1
echo!($count.to_str())
Ok({})
} Haskell reaches for
IORef (or STRef/MVar) and threads it explicitly through IO with newIORef/modifyIORef/readIORef. Roc's var keyword (with the $-prefixed name it requires) offers a plain, local, in-place mutable variable directly — no wrapper type, no monad, and — because it cannot escape its own function — no risk of it leaking or racing the way a mutable reference might in a less disciplined language.Types & Inference
Both infer types confidently
double :: Int -> Int
double x = x * 2
main :: IO ()
main = print (double 21) double : I64 -> I64
double = |x| x * 2
main! = |_args| {
echo!(double(21).to_str())
Ok({})
} Haskell's Hindley-Milner-style inference makes explicit type signatures optional almost everywhere. Roc infers aggressively too, but idiomatic Roc code still writes a top-level type annotation above nearly every function — a style convention rather than a requirement, closer to how many Haskell codebases also always annotate top-level bindings even though the compiler could infer them.
Concrete number types instead of a numeric tower
main :: IO ()
main =
let count = 42 :: Int
ratio = 3.5 :: Double
in print (fromIntegral count + ratio) main! = |_args| {
count : I64
count = 42
ratio : F64
ratio = 3.5
echo!((count.to_f64() + ratio).to_str())
Ok({})
} Haskell's numeric literals are polymorphic over the
Num typeclass, and converting between number types goes through fromIntegral/realToFrac. Roc has no such typeclass-driven polymorphism for numbers: I64 and F64 are concrete, unrelated types from the start, and crossing between them always needs an explicit conversion method like .to_f64().Strings
String concatenation
main :: IO ()
main = putStrLn ("Hello, " ++ "World!") main! = |_args| {
echo!(Str.concat("Hello, ", "World!"))
Ok({})
} Haskell's
++ is the general list-concatenation operator, since String is really [Char] under the hood. Roc has a real, distinct Str type (not a list of characters), and the current pinned build has no + or ++ for strings at all — concatenation goes through the Str.concat function, callable in method style as "Hello, ".concat("World!") too.String interpolation
import Text.Printf (printf)
main :: IO ()
main = do
let name = "Ada"
age = 36 :: Int
printf "%s is %d\n" name age main! = |_args| {
name = "Ada"
age : I64
age = 36
echo!("${name} is ${age.to_str()}")
Ok({})
} Haskell has no native interpolation — the idiomatic route is
Text.Printf with positional format specifiers. Roc has true interpolated strings with \${ } placeholders, closer to Python f-strings — but note that, unlike Python or JavaScript, Roc will not implicitly stringify a non-Str value: age must be explicitly converted with .to_str() first, or the program fails to compile.A string is a list of characters — or a genuine opaque type
main :: IO ()
main = print (length "hello") main! = |_args| {
echo!("hello".count_utf8_bytes().to_str())
Ok({})
} Haskell's
String is literally a type alias for [Char], so ordinary list functions like length apply directly and count Unicode codepoints. Roc's Str is an opaque, UTF-8-guaranteed type with no list-of-characters escape hatch — this pinned build has no generic .len() for it either, only the explicit .count_utf8_bytes(), a reminder that Roc strings are measured in bytes, not characters.Lists
List literals
main :: IO ()
main = print [1, 2, 3, 4, 5] main! = |_args| {
numbers : List(I64)
numbers = [1, 2, 3, 4, 5]
echo!(Str.inspect(numbers))
Ok({})
} The literal syntax is nearly identical. Under the hood the two representations differ completely: a Haskell list is a lazy, singly-linked chain of cons cells, while Roc's
List is contiguous in memory (like a vector), despite the functional-language name. Str.inspect plays the role of Haskell's derived Show instance for quick debugging output.map and filter
main :: IO ()
main =
let doubledEvens = map (* 2) (filter even [1, 2, 3, 4, 5, 6])
in print doubledEvens 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({})
} Both languages call the map operation
map. Haskell's filter becomes Roc's keep_if (with siblings drop_if and count_if) — a naming choice Roc made deliberately for clarity over the more common "filter" term used almost everywhere else, including Haskell.Folding a list
main :: IO ()
main = print (foldl (+) 0 [1, 2, 3, 4]) main! = |_args| {
numbers : List(I64)
numbers = [1, 2, 3, 4]
total = numbers.fold(0, |accumulator, number| accumulator + number)
echo!(total.to_str())
Ok({})
} Haskell's
foldl and Roc's .fold take the same pieces in the same order — initial accumulator, then a two-argument function. Roc also ships fold_rev (Haskell's foldr), fold_with_index, and an early-exit fold_until that returns Break(value) to stop partway through.No `for` loop in this build — use `.for_each!`
main :: IO ()
main = mapM_ putStrLn ["one", "two", "three"] main! = |_args| {
words = ["one", "two", "three"]
words.for_each!(|word| echo!(word))
Ok({})
} Haskell's
mapM_ runs an effectful action over each element purely for its side effects, discarding the results. The Roc langref specifies a native for word in words { } loop, but the pinned nightly build this page runs on rejects it (the iterator protocol is still being wired up) — so the working form today is .for_each!(...), whose ! suffix marks that its closure performs effects, mirroring mapM_'s intent almost exactly.Records & Tuples
Records need no declaration in Roc
data Point = Point { x :: Double, y :: Double } deriving (Show)
main :: IO ()
main =
let point = Point { x = 1.5, y = 2.5 }
in print (x point, y point) main! = |_args| {
point = { x: 1.5, y: 2.5 }
echo!("(${point.x.to_str()}, ${point.y.to_str()})")
Ok({})
} A Haskell record needs an explicit
data declaration naming its type and fields before it can be used anywhere. Roc records are structural: { x: 1.5, y: 2.5 } simply exists at the point of use, no declaration required, and any two records sharing the same field names and types are considered the same type.Record update syntax
data Config = Config { verbose :: Bool, retries :: Int } deriving (Show)
main :: IO ()
main =
let defaults = Config { verbose = False, retries = 3 }
custom = defaults { retries = 5 }
in print custom main! = |_args| {
defaults = { verbose: Bool.False, retries: 3.I64 }
custom = { ..defaults, retries: 5 }
echo!(Str.inspect(custom))
Ok({})
} The idea is identical in both — produce a new record with one field replaced and every other field copied — but the syntax position of the "spread" flips: Haskell's
defaults { retries = 5 } names the source record first, Roc's { ..defaults, retries: 5 } spreads it first inside the braces.Tuples
main :: IO ()
main =
let pair = (1, "two")
(number, word) = pair
in putStrLn (show number ++ " " ++ word) main! = |_args| {
pair = (1.I64, "two")
(number, word) = pair
echo!("${number.to_str()} ${word}")
Ok({})
} Both languages destructure a tuple with the same parenthesized, comma-separated pattern. One Roc quirk with no Haskell parallel: a Roc tuple must have at least two elements — there is no such thing as a Roc one-tuple, whereas Haskell has no tuples at all below pairs either, making this a non-issue in practice for both.
Algebraic Data Types vs. Tag Unions
Modeling "one of several shapes"
data Shape = Circle Double | Rectangle Double Double
area :: Shape -> Double
area (Circle radius) = pi * radius * radius
area (Rectangle width height) = width * height
main :: IO ()
main = print (map area [Circle 2.0, Rectangle 3.0 4.0]) Shape := [Circle(F64), Rectangle(F64, F64)]
area : Shape -> F64
area = |shape| match shape {
Circle(radius) => 3.14159 * radius * radius
Rectangle(width, height) => width * height
}
main! = |_args| {
echo!(area(Shape.Circle(2.0)).to_str())
echo!(area(Shape.Rectangle(3.0, 4.0)).to_str())
Ok({})
} A nominal tag union (declared with
:=) is Roc's closest match to a Haskell algebraic data type: named, closed, and exhaustively matched, with payload-carrying variants destructured the same way. Construction is qualified as Shape.Circle(2.0), where Haskell just writes the bare constructor Circle 2.0.Tags with no declaration at all — genuinely foreign to Haskell
-- Haskell requires the type to be declared before use, always:
data Period = Morning | Afternoon
main :: IO ()
main =
let hour = 14 :: Int
period = if hour < 12 then Morning else Afternoon
label = case period of
Morning -> "AM"
Afternoon -> "PM"
in putStrLn label main! = |_args| {
hour : I64
hour = 14
period = if hour < 12 { Morning } else { Afternoon }
label = match period {
Morning => "AM"
Afternoon => "PM"
}
echo!(label)
Ok({})
} This is the feature with no Haskell equivalent whatsoever: structural tags spring into existence at the point of use, with no
data declaration anywhere. The if gives period the inferred type [Morning, Afternoon] on the spot, and the match is still checked for exhaustiveness — Haskell-level safety with none of the upfront ceremony.Open unions: extensibility baked into the type
-- Haskell has no built-in analogue to an "open", extensible sum type —
-- adding a new constructor always means editing the original data
-- declaration and every exhaustive case expression that matches on it:
data Signal = Go | Stop | Custom Int
describe :: Signal -> String
describe Go = "go"
describe Stop = "stop"
describe (Custom _) = "something else"
main :: IO ()
main = putStrLn (describe Go) 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({})
} Roc's
.. inside a tag-union type annotation marks it open: describe accepts Go, Stop, or genuinely any other tag at all, so long as there is a wildcard case to catch it. Haskell has no equivalent — every data declaration is a fixed, closed set of constructors, and widening it always means editing the original declaration.Pattern Matching
case versus match
describe :: Int -> String
describe n = case n of
0 -> "zero"
n | n > 0 -> "positive"
_ -> "negative"
main :: IO ()
main = putStrLn (describe (-5)) describe : I64 -> Str
describe = |n| match n {
0 => "zero"
n if n > 0 => "positive"
_ => "negative"
}
main! = |_args| {
echo!(describe(-5))
Ok({})
} Haskell's
case ... of and Roc's match { } read as close dialects of the same construct: patterns tried top to bottom, a guard attached to a pattern (Haskell reuses |, Roc uses if), and a wildcard _ catch-all.Both compilers check exhaustiveness
-- GHC warns here (with -Wincomplete-patterns) since the
-- negative case is missing entirely:
classify :: Int -> String
classify 0 = "zero"
classify n | n > 0 = "positive"
main :: IO ()
main = putStrLn (classify 5) classify : I64 -> Str
classify = |n| match n {
0 => "zero"
n if n > 0 => "positive"
# Roc's compiler would reject this match with a compile ERROR
# (not just a warning) if the negative case were missing:
_ => "negative"
}
main! = |_args| {
echo!(classify(5))
Ok({})
} Both compilers statically verify a pattern match covers every case, but they enforce it differently: GHC emits a configurable warning (elevated to an error only under stricter flags), while Roc treats a non-exhaustive
match as an unconditional compile error — no flag required, no way to ship the gap.Destructuring a tuple
main :: IO ()
main =
let point = (3, 4)
(x, y) = point
in print (x + y) main! = |_args| {
point = (3.I64, 4.I64)
(x, y) = point
echo!((x + y).to_str())
Ok({})
} Tuple destructuring is essentially identical syntax in both languages: a parenthesized, comma-separated pattern on the left of a binding, matched positionally against the tuple's shape.
Maybe/Either vs. Try
Either becomes Try — but Maybe has no equivalent at all
divide :: Int -> Int -> Either String Int
divide _ 0 = Left "divide by zero"
divide x y = Right (x `div` y)
main :: IO ()
main = print (divide 10 0) divide : I64, I64 -> Try(I64, Str)
divide = |x, y|
if y == 0 {
Err("divide by zero")
} else {
Ok(x // y)
}
main! = |_args| {
echo!(Str.inspect(divide(10, 0)))
Ok({})
} Haskell's
Either String Int renames directly to Roc's Try(I64, Str) — Left/Right become Err/Ok (note the argument order also flips: Roc puts the success type first). But Roc has no built-in Maybe at all — where Haskell would reach for Maybe Int to mean "no error info, just absence," idiomatic Roc either uses Try anyway or an ad-hoc structural tag union like [Found(Int), Missing].Skipping the failure case is a compile error in both
divide :: Int -> Int -> Either String Int
divide _ 0 = Left "divide by zero"
divide x y = Right (x `div` y)
main :: IO ()
main = case divide 10 0 of
Right value -> print value
Left reason -> putStrLn ("Error: " ++ reason) divide : I64, I64 -> Try(I64, Str)
divide = |x, y|
if y == 0 {
Err("divide by zero")
} else {
Ok(x // y)
}
main! = |_args| {
match divide(10, 0) {
Ok(value) => echo!(value.to_str())
Err(reason) => echo!("Error: ${reason}")
}
Ok({})
} Neither compiler lets you treat a fallible value as if it were the bare success value. Matching both branches — or explicitly calling a combinator that forces a decision about the error case, like Roc's
?? default operator or Haskell's either function — is the only way out in either language.Early-exit on failure: `?` versus do-notation
firstDoubled :: [Int] -> Maybe Int
firstDoubled numbers = do
first <- if null numbers then Nothing else Just (head numbers)
return (first * 2)
main :: IO ()
main = print (firstDoubled [5, 6, 7]) show_first! = |numbers| {
first = numbers.first()?
echo!("first doubled: ${(first * 2).to_str()}")
Ok({})
}
main! = |_args| {
numbers : List(I64)
numbers = [5, 6, 7]
show_first!(numbers)
} Haskell's
do-notation over Maybe short-circuits on the first Nothing via the generic Monad typeclass, with no visible branching. Roc has no generalized monadic bind, but it does have ? — borrowed directly from Rust, not from Haskell — which unwraps an Ok or early-returns the surrounding function's Err/failure case, reaching the same practical effect through a completely different mechanism.Defaults: fromMaybe versus ??
import Data.Maybe (fromMaybe)
main :: IO ()
main =
let numbers = [] :: [Int]
first = fromMaybe 0 (if null numbers then Nothing else Just (head numbers))
in print first main! = |_args| {
numbers : List(I64)
numbers = []
first = numbers.first() ?? 0
echo!(first.to_str())
Ok({})
} Haskell's
fromMaybe defaultValue maybeValue and Roc's value ?? defaultValue do the same job — use the value if present, fall back otherwise — but Roc expresses it as an infix operator rather than a named function, reading closer to null-coalescing operators in JavaScript or C#.Functions & Closures
Currying: automatic in Haskell, opt-in in Roc
add :: Int -> Int -> Int
add x y = x + y
main :: IO ()
main =
let addFive = add 5
in print (addFive 10) add : I64, I64 -> I64
add = |x, y| x + y
add_five : I64 -> I64
add_five = |y| add(5, y)
main! = |_args| {
echo!(add_five(10).to_str())
Ok({})
} This is a genuine capability gap in Roc's favor of clarity over cleverness. In Haskell,
add 5 alone is already valid — every function is secretly a chain of one-argument functions. Roc functions take a fixed argument list (|x, y| ..., comma-separated, much closer to most mainstream languages), so getting a partial application means writing the wrapping function by hand, exactly as Erlang or C would require.Anonymous functions (lambdas)
main :: IO ()
main = print (map (\x -> x * x) [1, 2, 3, 4]) main! = |_args| {
numbers : List(I64)
numbers = [1, 2, 3, 4]
echo!(Str.inspect(numbers.map(|x| x * x)))
Ok({})
} Haskell spells a lambda
\x -> ... (the backslash evoking the Greek letter lambda). Roc spells the same idea |x| ..., pipe-delimited parameters with no arrow at all before the body — the same syntax Roc uses for every named function too, since add = |x, y| x + y is just a lambda bound to a name.No built-in composition operator
main :: IO ()
main =
let addOneThenDouble = (* 2) . (+ 1)
in print (addOneThenDouble 5) add_one : I64 -> I64
add_one = |x| x + 1
double : I64 -> I64
double = |x| x * 2
add_one_then_double : I64 -> I64
add_one_then_double = |x| double(add_one(x))
main! = |_args| {
echo!(add_one_then_double(5).to_str())
Ok({})
} Haskell's
. composes functions directly as values, right-to-left. Roc has no composition operator in this build at all (and, per the language churn warning, the |> pipe operator that once approximated left-to-right chaining has since been removed) — composing two functions means writing an ordinary wrapping function that calls one inside the other, spelling out the order explicitly rather than gluing function values together.Control Flow
if is an expression in both languages
main :: IO ()
main =
let n = 5
message = if n > 0 then "positive" else "non-positive"
in putStrLn message main! = |_args| {
n : I64
n = 5
message = if n > 0 { "positive" } else { "non-positive" }
echo!(message)
Ok({})
} In both languages
if produces a value rather than merely branching, and else is mandatory in both, since without it there would be no value on the false path. Roc additionally requires braces around each branch's body, where Haskell needs none.Roc has real while loops — Haskell has none
-- Haskell has no native while/for loop that mutates a counter —
-- recursion (often through a helper with an explicit accumulator
-- parameter) is the only native repetition construct:
countUp :: Int -> Int -> IO ()
countUp count target
| count >= target = return ()
| otherwise = do
print count
countUp (count + 1) target
main :: IO ()
main = countUp 0 5 main! = |_args| {
var $count = 0.I64
while $count < 5 {
echo!($count.to_str())
$count = $count + 1
}
Ok({})
} This is a genuine, notable surprise for a Haskeller: Roc — a language that is pure by default, immutable by default, and effect-tracked in its types — still has real, honest
while loops with break/continue. Mutation through var is local to the enclosing function and cannot leak or race, so the language designers were comfortable allowing it, unlike Haskell, which has no loop construct whatsoever and relies on recursion for every repeated computation.Recursion works identically in both
countdown :: Int -> IO ()
countdown 0 = putStrLn "liftoff!"
countdown n = do
print n
countdown (n - 1)
main :: IO ()
main = countdown 3 countdown! : I64 => {}
countdown! = |n| {
if n == 0 {
echo!("liftoff!")
} else {
echo!(n.to_str())
countdown!(n - 1)
}
}
main! = |_args| {
countdown!(3)
Ok({})
} Even with real loops available, recursion is still ordinary and idiomatic in Roc — the base-case-then-recursive-case shape translates directly from Haskell, just with an
if/else standing in for Haskell's separate pattern-matched function clauses.Laziness vs. Strictness
Infinite lists exist only because Haskell is lazy
main :: IO ()
main = print (take 5 [1..]) main! = |_args| {
# No infinite-list constructor exists at all — a bounded literal is
# how Roc gets "the first five numbers" directly:
numbers : List(I64)
numbers = [1, 2, 3, 4, 5]
echo!(Str.inspect(numbers))
Ok({})
} Haskell's
[1..] is an ordinary list that only works because Haskell is lazy, generating each element on demand as take asks for it. Roc is strict, like nearly every other language on this site, and its List is a genuinely eager, fully-realized value — there is no way to construct an infinite one at all, so a bounded literal is simply how Roc gets "the first five numbers" directly.No thunks: every binding is fully evaluated
main :: IO ()
main = do
let expensive = 2 ^ 20 -- not computed until actually demanded
putStrLn "before use"
print expensive main! = |_args| {
# Roc has no thunks — "expensive" is computed right here, on this
# line, not deferred until first use:
expensive : I64
expensive = 1024 * 1024
echo!("before use")
echo!(expensive.to_str())
Ok({})
} In Haskell, every binding is lazy unless forced otherwise —
expensive is not actually computed until print demands it, and repeated use of the same thunk is cached automatically. Roc has no thunks at all: expensive is computed the moment its binding line runs, exactly once, in program order — a much easier execution model to predict, at the cost of Haskell's free memoization-via-laziness trick.Top-level values run at compile time, not lazily at first use
main :: IO ()
main =
let limit = 10 :: Int
squared = limit * limit -- computed lazily, on demand, at runtime
in print squared limit : I64
limit = 10
squared : I64
squared = limit * limit
main! = |_args| {
echo!(squared.to_str())
Ok({})
} Haskell computes a top-level pure binding lazily, the first time something actually demands its value, at runtime. Roc evaluates every top-level value at compile time — purity guarantees this is always safe — so a top-level definition that would crash (say, an overflow) becomes a compile error rather than something discovered only when the program runs.
do-notation vs. Effect Types
Effects are visible in every signature — different mechanism, same visibility
-- Haskell marks effectfulness in the return type, IO a, which
-- "infects" every caller up the chain:
quietLookingHelper :: String -> IO String
quietLookingHelper name = do
putStrLn "(surprise: I/O happened here)"
return ("hello " ++ name)
main :: IO ()
main = quietLookingHelper "Haskell" >>= putStrLn # Pure: Str -> Str (arrow ->)
describe : Str -> Str
describe = |name| "hello ${name}"
# Effectful: Str => {} (fat arrow =>, name ends in !)
announce! : Str => {}
announce! = |name| {
echo!(describe(name))
}
main! = |_args| {
announce!("Roc")
Ok({})
} Both languages make effectfulness visible in the type, but the mechanism differs. Haskell wraps the return type in
IO, letting any function that "contains" IO stay a normal-looking function whose effectfulness only shows up in its result type. Roc instead marks the function ITSELF: a ! suffix on the name and a => arrow in the signature, and — unlike Haskell — a pure function can never even call an effectful one without adopting the ! itself.Monad unifies Haskell's "effects"; Roc has nothing equivalent
-- Haskell's do-notation works UNCHANGED for any Monad — IO, Maybe,
-- lists, or a custom type — because Monad is one typeclass with one
-- bind operation (>>=) every instance implements:
main :: IO ()
main = do
let doubled = do
x <- [1, 2, 3]
return (x * 2)
print (doubled :: [Int]) # Roc has no Monad typeclass and no do-notation at all. The closest
# thing — chaining "Try" values through "?" — only works for Try,
# not lists, not a hypothetical custom effect type:
main! = |_args| {
numbers : List(I64)
numbers = [1, 2, 3]
doubled = numbers.map(|x| x * 2)
echo!(Str.inspect(doubled))
Ok({})
} This is the sharpest conceptual gap between the two languages. Haskell's
Monad typeclass is a single abstraction: any type implementing >>= gets do-notation, uniformly, whether it is IO, Maybe, a list, or a hand-rolled parser. Roc has no typeclasses and no equivalent generalized abstraction — ? works specifically for Try, effectful ! functions are sequenced by ordinary statement order, and there is no way to write one block of syntax that works across all three the way Haskell's do does.crash versus error/undefined
main :: IO ()
main =
let configurationFound = False
in if not configurationFound
then error "no configuration — cannot continue"
else putStrLn "configured" main! = |_args| {
configuration_found : Bool
configuration_found = Bool.False
if !configuration_found {
crash "no configuration — cannot continue"
} else {
echo!("configured")
}
Ok({})
} Haskell's
error/undefined and Roc's crash serve the same purpose: an unrecoverable abort for a state the program considers impossible, as opposed to an expected failure that should be represented as a value (Either/Try). This row is display-only because the WASM host powering this page halts its own instance on a crash, rather than producing inspectable output.Type Classes vs. Structural Constraints
Type classes have no nominal equivalent in Roc
class Describable a where
describe :: a -> String
data Dog = Dog
instance Describable Dog where
describe _ = "a dog"
main :: IO ()
main = putStrLn (describe Dog) Dog := {}
describe : Dog -> Str
describe = |_dog| "a dog"
main! = |_args| {
echo!(describe(Dog.{}))
Ok({})
} Haskell resolves "which implementation for which type" through type classes, with the compiler automatically picking the right instance based on the type at the call site — genuine, nominal, type-directed dispatch. Roc has no type classes and no
instance mechanism whatsoever; here, with only one type involved, the Roc side collapses to an ordinary function with no dispatch machinery at all.A `where` clause: Roc's closest analogue, and it is structural, not nominal
class Printable a where
toDisplay :: a -> String
instance Printable Int where
toDisplay n = show n
instance Printable Double where
toDisplay d = show d
main :: IO ()
main = do
putStrLn (toDisplay (42 :: Int))
putStrLn (toDisplay (2.5 :: Double)) print_value! : a => {} where [a.to_str : a -> Str]
print_value! = |value| {
echo!(value.to_str())
}
main! = |_args| {
print_value!(42.I64)
print_value!(2.5.Dec)
Ok({})
} Roc's
where [a.to_str : a -> Str] constrains a generic type parameter structurally — "any type that happens to have a matching to_str method" — closer to duck typing or a structural interface than Haskell's nominal type classes, where a type must explicitly opt in via a declared instance. In Roc there is no such opt-in step: if the method exists with the right shape, the constraint is satisfied, with no instance declaration linking the two together anywhere.deriving (Show) versus universal Str.inspect
data Point = Point Int Int deriving (Show)
main :: IO ()
main = print (Point 1 2) Point := (I64, I64)
main! = |_args| {
echo!(Str.inspect(Point((1, 2))))
Ok({})
} Haskell requires an explicit
deriving (Show) clause to opt a type into human-readable printing (mechanically, an automatically generated type class instance). Roc needs no such opt-in at all: Str.inspect works structurally on any value whatsoever, with no derivation step and no way to forget it — a case where Roc's lack of type classes actually removes a small piece of Haskell ceremony rather than adding any.Gotchas for Haskell Developers
Untyped number literals default to Dec, not Int
-- Haskell's default numeric type is Integer, and an unannotated
-- literal used as an Int prints as a plain whole number:
main :: IO ()
main = print (1 + 2) main! = |_args| {
# Without an annotation, Roc infers Dec (a fixed-point decimal) for
# an ambiguous numeric literal — so this prints "3.0", not "3":
echo!((1 + 2).to_str())
Ok({})
} Haskell's numeric default is
Integer, so an unannotated whole-number computation prints without a decimal point. Roc's numeric default, absent any type annotation, is Dec (a fixed-point decimal type designed to avoid float rounding surprises) — so the exact same-looking expression prints "3.0" in Roc. Annotate with : I64 or suffix a literal (42.I64) whenever the printed form matters.Bare True/False are tags, not Bool, until annotated
-- In Haskell, True and False ARE values of the Bool type — full stop,
-- no ambiguity possible:
main :: IO ()
main = print (not True) main! = |_args| {
# In Roc, a bare "True"/"False" is first inferred as a one-off
# STRUCTURAL TAG, not necessarily Bool, until something pins it
# down — an explicit ": Bool" annotation is the idiomatic fix:
flag : Bool
flag = Bool.True
echo!(Str.inspect(!flag))
Ok({})
} In Haskell,
True/False are unambiguously values of the built-in Bool type — there is nothing else they could be. In Roc, a bare True/False is first read as an ordinary structural tag (indistinguishable from writing Foo), and only unifies with the real Bool type once something forces it to, such as an explicit : Bool annotation or being passed where a Bool is required — skip the annotation and operators like !/.not() may fail to type-check.No working list[i] subscript — use .get(i)
main :: IO ()
main = print ([10, 20, 30] !! 1) main! = |_args| {
numbers : List(I64)
numbers = [10, 20, 30]
# The "numbers[1]" subscript sugar PARSES in this pinned build but
# fails to type-check into a usable value — .get(i) is the form
# that actually works, returning a Try since the index might be
# out of bounds:
match numbers.get(1) {
Ok(value) => echo!(value.to_str())
Err(_) => echo!("out of bounds")
}
Ok({})
} Haskell's
!! operator indexes a list but crashes with an uncatchable runtime error on an out-of-bounds index — a genuine safety gap. Roc's .get(i) returns a Try, forcing the out-of-bounds case to be handled explicitly, which is the safer of the two designs — but note that the more familiar numbers[1] subscript syntax parses without error in this pinned nightly yet fails to type-check into anything usable, a trap for anyone reaching for it out of habit.Roc is pre-1.0: expect real churn
-- Haskell (via GHC/Hackage) has decades of stability to rely on —
-- Text.Printf, Data.Map, and IORef have not changed their APIs
-- in years:
main :: IO ()
main = putStrLn "stable stdlib" main! = |_args| {
# No Str.replace in this build — compose what exists instead:
parts = "hello world".split_on(" ")
echo!(Str.join_with(parts, "_"))
Ok({})
} Haskell's base library and its common ecosystem packages have been stable for a very long time. Roc is pre-1.0 and this page pins a specific nightly compiler, so familiar-sounding operations are sometimes missing or renamed: there is no
Str.replace yet, string length is count_utf8_bytes(), the |> pipe operator was removed in favor of plain method chains, and triple-quoted multiline strings are not implemented. Expect this table to shrink as Roc approaches its first stable release.