PONYλM2Modula-2

OCaml.CodeCompared.To/Roc

An interactive executable cheatsheet comparing OCaml and Roc

OCaml 5.2 Roc nightly
Hello World & the Platform
Hello, World
Three things to notice: the ! on both names, the argument nobody uses, and the Ok({}) at the end.
let () = print_endline "Hello, World!"
main! = |_args| { echo!("Hello, World!") Ok({}) }
The ! suffix marks something effectful — main! performs effects and echo! is one, and the naming is enforced rather than conventional. _args is the command-line arguments, which this program ignores. The Ok({}) is the program's exit status as a value: Ok of the empty record means success, and returning an Err is how a program fails. The whole shape is decided by the platform the application is built against, which is the subject of the next row and the most unusual idea in the language.
The Platform Provides the Effects
The idea with no counterpart in any other language on this site, and the one most worth understanding before judging the rest.
(* An OCaml program reaches the outside world directly: the standard library has the file system, the clock and the network, and any function may call them. *) let () = print_endline "OCaml: stdin, stdout and the file system are just there"
# A Roc APPLICATION declares which PLATFORM it is built # against, and the platform supplies every effect it may # perform. This one provides exactly echo!. # # app [main!] { pf: platform "…/echo/main.roc" } # # Swap the platform and the same application code targets # a CLI, a web server, or an embedded device — with a # different set of effects available and no changes to the # pure parts. main! = |_args| { echo!("Roc: the platform decides what effects exist") Ok({}) }
A Roc platform owns the entry point, the effects and the memory allocator; an application is pure code plugged into one. That inverts the usual arrangement, where a program owns main and calls libraries for I/O. The consequences are real: an application cannot perform an effect the platform did not provide, so the set of things it can do is bounded by construction; platform authors write the unsafe, host-specific half in Rust or Zig once; and the same application can target a command-line tool, a server or a microcontroller by changing one line. It is the same problem OCaml 5 addresses with effect handlers, answered at the build-system level instead of the language level.
Compilation Is Meant to Be Instant
A stated design goal rather than a benchmark claim, and one that shapes what the language will accept.
(* ocamlopt is fast by the standards of optimizing compilers, and a large project still takes a while. *) let () = print_endline "dune build: seconds to minutes"
# Roc's compiler is written for interactive speed, with # a development mode that skips optimization entirely # and a target of sub-second feedback. main! = |_args| { echo!("roc run: intended to feel immediate") Ok({}) }
Roc treats compile speed as a feature to design for, which is part of why the type system is structural — there is no separate declaration to resolve — and why it monomorphizes rather than boxing. OCaml is itself fast by the standards of optimizing compilers, and a large dune project still takes real time. Whether Roc holds this at scale is unproven; the language has no large codebases yet. It is worth knowing as intent, not as measurement.
Tags Are Structural Unions
Your Most Exotic Feature Is the Default
The row this pair exists for: Roc took the feature OCaml programmers use least and made it the default.
(* A polymorphic variant needs no declaration and the inferred type records which tags may appear. This is OCaml's most unusual feature and is used sparingly. *) let describe value = match value with | `Circle radius -> Printf.sprintf "circle %d" radius | `Square side -> Printf.sprintf "square %d" side let () = print_endline (describe (`Circle 3)); print_endline (describe (`Square 4))
# Exactly the same idea, and here it is the ORDINARY way # to write a union. No declaration, and the type is the # set of tags the function accepts. describe : [Circle(I64), Square(I64)] -> Str describe = |value| match value { Circle(radius) => "circle ${radius.to_str()}" Square(side) => "square ${side.to_str()}" } main! = |_args| { echo!(describe(Circle(3))) echo!(describe(Square(4))) Ok({}) }
OCaml has two kinds of sum type — declared variants, which almost all code uses, and polymorphic variants, which are structural, need no declaration, and are reached for occasionally. Roc has both, and makes the structural kind the ordinary one: a nominal union, declared with := as in Shape := [Circle(I64), Square(I64)], is there for when you want a closed, named type, and most code never writes one. A tag is written where it is used, two unrelated functions can accept overlapping sets, and the inferred type is the exact set of tags that can appear. The annotation above is checked documentation, and here it also does work: delete it and the example stops compiling, because nothing else says what type radius is, and the compiler reports to_str being called on an unresolved type variable. That is a genuinely different design bet from every other language on this anchor, and for an OCaml programmer it is the most familiar-looking unfamiliar thing here.
A Union Grows Where It Is Used
Because tags are structural, two functions can disagree about how many cases exist and both be correct.
(* A declared variant is closed: adding a case means editing the type and every match over it. *) type shape = Circle of int | Square of int let area = function | Circle radius -> radius * radius * 3 | Square side -> side * side let () = Printf.printf "%d\n" (area (Circle 2)); Printf.printf "%d\n" (area (Square 3))
# Two functions, two DIFFERENT tag sets, no shared # declaration and no coordination between them. area : [Circle(I64), Square(I64)] -> I64 area = |shape| match shape { Circle(radius) => radius * radius * 3 Square(side) => side * side } name : [Circle(I64), Square(I64), Point] -> Str name = |shape| match shape { Circle(_) => "circle" Square(_) => "square" Point => "point" } main! = |_args| { echo!(area(Circle(2)).to_str()) echo!(area(Square(3)).to_str()) echo!(name(Point)) Ok({}) }
area handles two tags and name handles three, with nothing declaring either set and nothing relating them. A value built as Circle(2) can be passed to both. In OCaml that is exactly what polymorphic variants do, and it is why they are useful for open, extensible sums — the difference is that in Roc this is the everyday sum type, and the closed alternative — a nominal union declared with := — is the one reached for occasionally. Exhaustiveness is still checked: each match must cover the tags its own inferred type admits, so deleting a branch is still an error. Saying "these three cases and no others, everywhere" takes a nominal union, which is how Bool and Try themselves are declared.
Tags Model Everything
A state machine reads almost identically in both, and the Roc version declared no type to do it.
(* A state machine as a declared variant, with a payload per case. *) type connection = | Disconnected | Connecting of int | Connected of string let describe = function | Disconnected -> "disconnected" | Connecting seconds -> Printf.sprintf "connecting for %ds" seconds | Connected session -> Printf.sprintf "connected as %s" session let () = List.iter (fun state -> print_endline (describe state)) [ Disconnected; Connecting 2; Connected "abc" ]
describe : [Disconnected, Connecting(I64), Connected(Str)] -> Str describe = |state| match state { Disconnected => "disconnected" Connecting(seconds) => "connecting for ${seconds.to_str()}s" Connected(session) => "connected as ${session}" } main! = |_args| { for state in [Disconnected, Connecting(2), Connected("abc")] { echo!(describe(state)) } Ok({}) }
This is where the structural approach pays off in ordinary code: the union exists only as the annotation on describe, so a state machine can be sketched and changed without maintaining a separate type declaration alongside it. Roc also uses tags where other languages use dedicated types — Bool.True and Bool.False are tags, and so are Ok and Err. Note the string interpolation, "${expression}", which OCaml has never grown and which reads better than sprintf for short cases.
Even Booleans Are Tags
Worth noticing because it shows how far the structural union idea reaches.
(* bool is a built-in type with two constructors that are part of the language. *) let () = let ready = true in print_endline (if ready then "yes" else "no")
main! = |_args| { # Bool.True and Bool.False are ordinary tags. So are # Ok and Err. The union machinery is not a special # case bolted on — it is how the primitives are built. ready = Bool.True echo!(if ready { "yes" } else { "no" }) Ok({}) }
In OCaml, bool, option and result are declared types in the standard library, and polymorphic variants are a separate mechanism used for other things. In Roc they are all tags: Bool is the nominal union [False, True] and Try the nominal union [Ok(ok), Err(err)], declared in the standard library with the same := any program can use, and matched exactly like tags you write. That uniformity is the argument for the design — there is less language to learn, because the primitives are made of the same parts as your own code.
Records Are Structural Too
Records Need No Declaration Either
Records are structural for the same reason tags are, so the second half of OCaml's type declarations disappears too.
(* A record type is declared, and two types with the same fields are still different types. *) type point = { x : int; y : int } let () = let origin = { x = 0; y = 0 } in let shifted = { origin with x = 5 } in Printf.printf "%d %d\n" origin.x shifted.x
main! = |_args| { # No declaration. The type IS the set of fields, and # .. copies the rest, exactly like OCaml's with. origin = { x: 0.I64, y: 0.I64 } shifted = { ..origin, x: 5 } echo!("${origin.x.to_str()} ${shifted.x.to_str()}") Ok({}) }
An OCaml record is nominal: type point and type coordinate with identical fields are different types, and a value belongs to exactly one of them. A Roc record's type is simply its set of fields, so any record with exactly those fields fits anywhere one is expected — a record with an extra field is a different type and is rejected. The update syntax is the same idea under different punctuation — { ..origin, x: 5 } for { origin with x = 5 }. Keeping two same-shaped things apart takes a nominal type declared with :=, and two such types are not interchangeable even when their fields match: the default is structural and nominal is opt-in, the reverse of OCaml.
Building From Defaults
The options-record pattern works the same way, and Roc adds one thing worth having.
(* Functional update from a default value, which is the same mechanism. *) type settings = { verbose : bool; retries : int; timeout : int } let defaults = { verbose = false; retries = 3; timeout = 30 } let () = let custom = { defaults with retries = 5 } in Printf.printf "%b %d %d\n" custom.verbose custom.retries custom.timeout
main! = |_args| { defaults = { verbose: Bool.False, retries: 3.I64, timeout: 30.I64 } custom = { ..defaults, retries: 5 } echo!(Str.inspect(custom)) Ok({}) }
Both languages build a variant of a default by copying and overriding. OCaml also has optional labeled arguments with defaults (?(retries = 3)); in Roc, a defaults record like this one is how the same effect is written. What Roc adds is Str.inspect, which renders any value structurally with no declaration and no deriver — the equivalent of Haskell's deriving Show or Rust's #[derive(Debug)], available by default because the type system is structural and the compiler always knows the shape. OCaml needs a ppx deriver for the same thing.
Destructuring
Destructuring in a parameter position works the same way, and the record type is written inline because there is nothing to declare.
type person = { name : string; age : int } let describe { name; age } = Printf.sprintf "%s is %d" name age let () = print_endline (describe { name = "Ada"; age = 36 })
describe : { name : Str, age : I64 } -> Str describe = |{ name, age }| "${name} is ${age.to_str()}" main! = |_args| { echo!(describe({ name: "Ada", age: 36 })) Ok({}) }
Both languages destructure a record directly in a function parameter, and both allow field punning so { name, age } binds those names. The difference is the annotation: OCaml names a declared type, Roc writes the field set inline because the type is that set. That makes small functions self-describing without a type declaration nearby, and it means two functions taking "a thing with a name and an age" need no shared definition.
Variables & Types
Bindings Are Immutable and Not Shadowable
Immutable in both, and Roc goes further: it discourages shadowing altogether.
(* let shadows: a second binding hides the first, and both exist. This is idiomatic. *) let () = let value = 5 in let value = value * 2 in let value = string_of_int value ^ " points" in print_endline value
main! = |_args| { # A name is bound once. A second binding of the same # name is a "duplicate definition" warning, and the # roc command exits with status 2 — so each step # gets its own name. value = 5.I64 doubled = value * 2 labelled = "${doubled.to_str()} points" echo!(labelled) Ok({}) }
OCaml's let creates a new binding that hides the old one, so refining a value under one name is idiomatic. Roc discourages it: a duplicate definition, shadowing included, draws a warning, and the roc command exits with status 2 so the rebinding cannot pass a continuous-integration check. The language wants a name to mean one thing in a scope, much as Erlang does. The practical effect is a chain of slightly different names, and the practical benefit is that reading unfamiliar code you never have to ask which binding of a name you are looking at.
Annotations Are Optional Everywhere
Roc keeps OCaml's whole-program inference, so a type annotation is documentation rather than a requirement.
(* Whole-program Hindley-Milner: nothing is annotated and every type is still known. *) let add first second = first + second let double value = value * 2 let () = Printf.printf "%d\n" (add 3 4); Printf.printf "%d\n" (double 21)
# Same inference, and the annotation — when written — is # checked but never required. add : I64, I64 -> I64 add = |first, second| first + second double = |value| value * 2 main! = |_args| { echo!(add(3, 4).to_str()) echo!(double(21.I64).to_str()) Ok({}) }
This is one of the places Roc sits closer to OCaml than any of the mainstream targets on this anchor — Rust, Go and TypeScript all stop inference at the function boundary, and Roc does not. One practical note: a number literal whose type nothing else determines becomes a Dec, and prints with a decimal point — hence the .I64 suffix above.
Numeric Types
Roc has more numeric types than OCaml and fewer operators, which is the opposite trade from the one you are used to.
(* int is 63 bits, float is a double, and they are different types with different operators. *) let () = Printf.printf "%d\n" (7 / 2); Printf.printf "%.1f\n" (7.0 /. 2.0)
main! = |_args| { # Sized integers (I8 … I128, U8 … U128), Dec for exact # decimal arithmetic, F32/F64 for floats — and ONE set # of operators across all of them. whole = 7.I64 / 2 exact = 7.Dec / 2 echo!(whole.to_str()) echo!(exact.to_str()) Ok({}) }
OCaml has one integer type at 63 bits and one float, with separate operator families (+ versus +.) because it has no overloading. Roc has sized integers from I8 to I128, unsigned versions, F32/F64, and Dec — a fixed-point decimal with exact arithmetic, so 0.1 + 0.2 is exactly 0.3, which no other target on this anchor offers by default. One operator set covers them all, resolved by type. Integer overflow is a runtime error rather than a wrap, and the compiler rejects a literal that cannot fit its type.
Dec Is Exact
A small type with a large consequence for anything touching money.
(* float is an IEEE double, so this is not 0.3. *) let () = Printf.printf "%.17f\n" (0.1 +. 0.2)
main! = |_args| { # Dec is fixed-point with exact decimal arithmetic, # so this really is 0.3 — no other target on this # anchor gives you that by default. exact = 0.1.Dec + 0.2 echo!(exact.to_str()) Ok({}) }
0.1 + 0.2 in binary floating point is 0.30000000000000004, which is true in OCaml, Rust, Go, Python, JavaScript and every other target on this anchor. Roc ships Dec, a 128-bit fixed-point decimal, and makes it the type a bare decimal literal defaults to — so the common case is exact and you opt into F64 when you want speed or transcendental functions. OCaml needs a library (decimal, or zarith rationals) for the same guarantee.
Strings
Strings and Interpolation
Interpolation is built in, and it accepts only strings — so the conversion stays visible.
let () = let name = "OCaml" in let year = 1996 in print_endline (Printf.sprintf "%s appeared in %d" name year)
main! = |_args| { name = "Roc" year = 2019.I64 echo!("${name} appeared in ${year.to_str()}") Ok({}) }
OCaml has no interpolation syntax and reaches for sprintf, whose format string is typed and checked. Roc's ${…} requires its expression to already be a Str, which is why year.to_str() is spelled out rather than happening silently — the same discipline ReScript enforces, and stricter than JavaScript or Python, where anything is stringified. A Roc Str is guaranteed to be valid UTF-8, where an OCaml string is bytes with no declared encoding.
There Is No Char
Roc has no character type at all, which is a deliberate consequence of taking Unicode seriously.
(* char is a distinct type, one byte wide, and indexing a string gives one. *) let () = let text = "hello" in (* A char is a byte, and indexing gives one. *) Printf.printf "%c %d\n" text.[0] (Char.code text.[0]); print_endline (String.concat " " [ text; "world" ])
main! = |_args| { # No Char type and no indexing by position: a Str is # UTF-8, and it is taken apart with operations such as # split_on, or turned into its bytes with to_utf8. text = "hello" echo!(Str.join_with([text, "world"], " ")) Ok({}) }
OCaml's char is one byte, so indexing a string gives a byte and an accented letter comes apart — the mojibake seen on several pages of this anchor. Roc refuses the question: there is no Char, no indexing by position, and a Str is taken apart with operations such as split_on and join_with, or turned into its UTF-8 bytes with to_utf8; grapheme-aware work is left to a Unicode library, which is also what asking a string for .len() tells you. That is stricter than Rust, which has char as a Unicode scalar, and much stricter than OCaml. The cost is that character-by-character algorithms need rethinking; the benefit is that the wrong answer is not available.
Collections
Lists Are Arrays
The word "list" means an array here, which is the same trap Python and ReScript set.
(* An immutable singly linked list: consing is cheap, length walks it. *) let () = let numbers = [ 1; 2; 3 ] in let extended = 0 :: numbers in Printf.printf "%d %d\n" (List.length extended) (List.length numbers)
main! = |_args| { # Roc's List is a flat ARRAY, not a linked list — # contiguous, with O(1) length and indexing. numbers : List(I64) numbers = [1, 2, 3] extended = List.prepend(numbers, 0) echo!("${extended.len().to_str()} ${numbers.len().to_str()}") Ok({}) }
OCaml's list is a singly linked list, so :: is constant time and List.length is a traversal. Roc's List is a contiguous array — len is free, indexing is free, and prepending is not. It is still immutable in the language's semantics: List.prepend produces a new list and leaves the original intact, which is why both lengths print. What makes that affordable rather than ruinous is the memory model in a later section — the compiler mutates in place when it can prove nobody else holds a reference.
Pipelines
The pipeline operator is the same one, and Roc's version threads into the first argument rather than the last.
let () = [ 1; 2; 3; 4; 5; 6 ] |> List.filter (fun number -> number mod 2 = 0) |> List.map (fun number -> number * 2) |> List.fold_left ( + ) 0 |> Printf.printf "total = %d\n"
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3, 4, 5, 6] total = numbers |> List.keep_if(|number| number % 2 == 0) |> List.map(|number| number * 2) |> List.sum() echo!("total = ${total.to_str()}") Ok({}) }
OCaml's |> is pipe-last and works because the List functions are curried with the data last. Roc's is pipe-first — x |> f(y) is f(x, y) — which is the same choice ReScript made with -> and for the same reason: the standard library takes its data first. keep_if is filter. Note also the method-call style available alongside it, numbers.len(), which is static dispatch resolved at compile time rather than anything dynamic.
Indexing Returns a Try
Bounds-checking is in the return type rather than in an exception, and there is no unchecked alternative to reach for by accident.
(* Indexing raises. The safe version is a separate function with a different name. *) let () = let numbers = [| 10; 20; 30 |] in (match (if 9 < Array.length numbers then Some numbers.(9) else None) with | Some value -> Printf.printf "%d\n" value | None -> print_endline "out of bounds"); Printf.printf "%d\n" numbers.(1)
main! = |_args| { numbers : List(I64) numbers = [10, 20, 30] match numbers.get(9) { Ok(value) => echo!(value.to_str()) Err(_) => echo!("out of bounds") } # ?? supplies a default, which is the common case. fallback = numbers.get(1) ?? 0 echo!(fallback.to_str()) Ok({}) }
OCaml's numbers.(9) raises Invalid_argument, and the safe form is a different function you have to know about — the same inversion Belt corrects in ReScript. Roc has no raising version at all: get returns a Try, so the out-of-bounds case is in the type and cannot be skipped. ?? supplies a default in one character for the common case. That is a small design decision with a large effect, and it is the same one Rust makes with Option for slice::get.
Dict
A dictionary needs no functor application, and lookup returns a Try like everything else that can fail.
module StringMap = Map.Make (String) let () = let ages = StringMap.add "ada" 36 StringMap.empty in match StringMap.find_opt "ada" ages with | Some age -> Printf.printf "%d\n" age | None -> print_endline "absent"
main! = |_args| { ages = Dict.from_list([("ada", 36.I64)]) match ages.get("ada") { Ok(age) => echo!(age.to_str()) Err(_) => echo!("absent") } Ok({}) }
OCaml's Map.Make (String) builds a module specialized to string keys. Roc's Dict is an ordinary generic type, so there is nothing to instantiate. Lookup returns Try rather than an option-shaped type of its own, which is the same uniformity the tags section described: one mechanism for "this might not be there", used everywhere.
Transforming Without Mutating
Every list operation returns a new list and leaves the original intact, which sets both languages apart from Go and JavaScript.
let () = let numbers = [ 1; 2; 3 ] in let reversed = List.rev numbers in Printf.printf "%d %d\n" (List.hd numbers) (List.hd reversed)
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3] reversed = numbers.rev() # Both still exist: rev built a new list rather than # reversing this one in place. echo!(Str.inspect(numbers)) echo!(Str.inspect(reversed)) Ok({}) }
Reversing produces a new list in both, and the original is still usable — where Go's slices.Reverse and JavaScript's Array.reverse modify in place and the original ordering is gone. What makes the immutable version affordable in Roc is the memory model two sections down: if the refcount shows nobody else holds the input, the compiler reuses its allocation rather than copying. So the semantics are immutable and the execution frequently is not.
Control Flow
if and match Are Expressions
Both are expressions producing a value, with braces where OCaml uses then and else.
let classify number = match number with | 0 -> "zero" | n when n < 0 -> "negative" | _ -> "ordinary" let () = List.iter (fun number -> Printf.printf "%d is %s\n" number (classify number)) [ 0; -5; 42 ]
classify : I64 -> Str classify = |number| if number == 0 { "zero" } else if number < 0 { "negative" } else { "ordinary" } main! = |_args| { for number in [0.I64, -5, 42] { echo!("${number.to_str()} is ${classify(number)}") } Ok({}) }
The semantics are OCaml's: every branch must agree on a type and the whole thing produces a value. Two syntactic notes. Braces are required and there is no then. And match arms are written Pattern => value with no leading | and no separator, which reads cleanly but is one more thing to retrain. Guards exist in match too.
Iterating
A for loop is an ordinary statement, in pure functions as well as effectful ones.
let () = List.iter (fun word -> print_endline word) [ "alpha"; "beta"; "gamma" ]
main! = |_args| { # A for loop over a list. It works in pure functions # too, updating a var as it goes. for word in ["alpha", "beta", "gamma"] { echo!(word) } Ok({}) }
OCaml's List.iter takes a function returning unit: a loop whose purpose is its effects. Roc's for is a statement that works in pure functions too — updating a var as it goes, as in a running total — and List.map and List.fold are there for when the result is the point. What keeps a pure function pure is not the absence of loops but the ! rule: a loop in a function whose name has no ! still cannot print.
There Is No Early Return
Neither language has a return statement, which is one of the few places they agree by both leaving something out.
(* OCaml has no return statement either: a function's value is its final expression, and an early exit means restructuring or an exception. *) let classify number = if number < 0 then "negative" else if number = 0 then "zero" else "positive" let () = print_endline (classify 5)
classify : I64 -> Str classify = |number| if number < 0 { "negative" } else if number == 0 { "zero" } else { "positive" } main! = |_args| { echo!(classify(5)) Ok({}) }
A function's value is its final expression in both, so there is nothing to return from and no early exit to write. That is unremarkable to an OCaml programmer and is worth stating because every other target on this anchor except Haskell has a return — and because it is the reason both languages push you toward expressions that produce a value rather than statements that assign one. Roc adds crash for the genuinely unreachable case, which terminates rather than returning.
expect Is a Language Feature
Tests are a language construct rather than a library.
(* Testing means an opam package — Alcotest, OUnit, QCheck — plus a dune test stanza. assert exists and is for invariants rather than for tests. *) let add first second = first + second let () = assert (add 2 2 = 4); print_endline "ok"
add : I64, I64 -> I64 add = |first, second| first + second # expect is part of the LANGUAGE, and roc test runs # every top-level one. expect add(2, 2) == 4 main! = |_args| { echo!("ok") Ok({}) }
OCaml's assert is for invariants and testing means choosing a framework. Roc's expect is syntax: it can appear at the top level or inside a function, it is skipped in optimized builds, and roc test runs every top-level one and reports each failure by showing the expect itself. There is no framework to choose and nothing to install before the first test.
Functions
No Currying
Note the type signature: I64, I64 -> I64, with a comma. That comma is the whole story.
let add first second = first + second let () = let add_ten = add 10 in Printf.printf "%d\n" (add_ten 5); Printf.printf "%d\n" (add_ten 32)
add : I64, I64 -> I64 add = |first, second| first + second # Partial application means writing the closure out. add_ten : I64 -> I64 add_ten = |second| add(10, second) main! = |_args| { echo!(add_ten(5).to_str()) echo!(add_ten(32).to_str()) Ok({}) }
OCaml writes int -> int -> int, arrows all the way down, because a two-argument function is a function returning a function — so add 10 is ordinary application. Roc writes I64, I64 -> I64: one function taking two arguments, and calling it with one is an error. That was a deliberate decision for the same reason ReScript made it — uncurried calls compile to direct calls with no closure allocation, which matters for a language aiming at systems work. Partial application means a closure, written out.
Closures
Closures capture the enclosing scope in both, and Roc's |arg| syntax is borrowed from Rust.
let () = let factor = 3 in let scale value = value * factor in List.iter (fun value -> Printf.printf "%d " (scale value)) [ 1; 2; 3 ]; print_newline ()
main! = |_args| { factor = 3.I64 scale = |value| value * factor scaled = [1, 2, 3] |> List.map(scale) echo!(Str.inspect(scaled)) Ok({}) }
Capture works the same way and the values are immutable in both, so there is no question about whether a closure sees a later change — there are no later changes. The syntax is Rust's pipes rather than OCaml's fun … ->. Str.inspect renders the result structurally, which is worth noticing again: it works on any value, including a record or a tag union, with no declaration and no deriver, because the compiler always knows the shape of a structural type.
Method Syntax Without Objects
Dot-call syntax with none of the machinery — the two lines below compile to the same thing.
(* Functions live in modules and are applied by juxtaposition. Reading a pipeline means reading right to left, or threading with |>. *) let () = let numbers = [ 1; 2; 3 ] in Printf.printf "%d\n" (List.length numbers)
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3] # numbers.len() is STATIC dispatch: resolved at # compile time from the type, with no vtable and no # object. It is sugar for List.len(numbers). echo!(numbers.len().to_str()) echo!(List.len(numbers).to_str()) Ok({}) }
Roc resolves numbers.len() at compile time from the type of numbers, so it is exactly List.len(numbers) with no dynamic dispatch, no vtable and no object model. The value is readability: a chain reads left to right in call order without needing a pipeline operator, which is the ergonomic win object-oriented languages get and functional ones usually give up. OCaml has |>, which gets there for pipelines and not for a single call.
Constraints Instead of Functors
There Are No Functors
Roc's modules are namespaces, so the entire functor apparatus has no counterpart — and the substitute is what Rust and Haskell also chose.
module type Comparable = sig type t val compare : t -> t -> int end module MakeLargest (Element : Comparable) = struct let largest items = List.fold_left (fun best item -> if Element.compare item best > 0 then item else best) (List.hd items) (List.tl items) end module IntCompare = struct type t = int let compare = compare end module LargestInt = MakeLargest (IntCompare) let () = Printf.printf "%d\n" (LargestInt.largest [ 3; 9; 4 ])
# No module system to parameterize. A where clause # names the method a type must have, which covers the # common case. largest : List(a), a -> a where [a.is_gt : a, a -> Bool] largest = |items, start| items.fold(start, |best, item| if item > best { item } else { best }) main! = |_args| { echo!(largest([3.I64, 9, 4], 0).to_str()) Ok({}) }
A Roc module cannot be parameterized, passed as a value or constrained by a signature. What stands in for a functor is a where clause, which constrains a type variable by naming the methods it must have — a.is_gt above, which is what > calls — close to a Haskell class constraint or a Rust trait bound, and resolved statically. That covers "one type with a standard capability" and does not cover bundling a type with several operations and instantiating it more than once. This is the same loss F#, Haskell, Go and Rust all impose; ReScript is the only target on this anchor that kept functors.
Printing and Equality Come Free
Printing and comparison work on every type with nothing declared, which is the practical payoff of structural typing.
(* Without a ppx deriver, printing and comparison are hand-written. The polymorphic = compares any type by walking its representation. *) type point = { x : int; y : int } let to_string point = Printf.sprintf "{ x: %d, y: %d }" point.x point.y let () = print_endline (to_string { x = 1; y = 2 }); Printf.printf "%b\n" ({ x = 1; y = 2 } = { x = 1; y = 2 })
main! = |_args| { first = { x: 1.I64, y: 2.I64 } second = { x: 1.I64, y: 2.I64 } # Inspect and equality come for free on every # structural type — no declaration, no deriving. echo!(Str.inspect(first)) echo!(Str.inspect(first == second)) Ok({}) }
Haskell needs deriving (Show, Eq), Rust needs #[derive(Debug, PartialEq)], and OCaml needs a ppx deriver or a hand-written function. Roc needs nothing: because a type is its structure, the compiler can always print and compare it, so Str.inspect and == work on any structural type with no declaration. OCaml gets close with its polymorphic =, which compares anything by walking runtime representations — at the cost of raising on functions and looping on cyclic values, both of which Roc rejects at compile time.
Constraining a Type Variable
A where clause is Roc's constraint mechanism, and it is the same shape as a Haskell class constraint or a Rust trait bound.
(* Parametric polymorphism with no constraint mechanism: a function either works for all types or takes the operations it needs as arguments. *) let largest_by compare items = List.fold_left (fun best item -> if compare item best > 0 then item else best) (List.hd items) (List.tl items) let () = Printf.printf "%d\n" (largest_by compare [ 3; 9; 4 ])
# The where clause names the method a type must have: # is_eq, which == calls. Any type with it works — here # integers and strings. count_matching : List(a), a -> U64 where [a.is_eq : a, a -> Bool] count_matching = |items, wanted| List.len(List.keep_if(items, |item| item == wanted)) main! = |_args| { echo!(count_matching([1.I64, 2, 1, 3], 1).to_str()) echo!(count_matching(["a", "b", "a"], "a").to_str()) Ok({}) }
OCaml has no constraint mechanism on a plain function: a type variable is either fully general or the operations are passed in explicitly, which is what a functor formalizes. Roc constrains a type variable with a where clause naming the methods the type must have, and the compiler checks each call against it — == is the method is_eq, so the clause above is what lets count_matching compare. Structural types come with equality and printing, so both calls work without anyone declaring anything. That is the payoff of structural typing showing up again.
Purity & Effects
Effects Are Marked With !
Purity is tracked, and it is tracked by a naming convention the compiler enforces rather than by a monad.
(* Any OCaml function may print, read a file or mutate, and the type says nothing about it. *) let double value = print_endline "(doubling)"; value * 2 let () = Printf.printf "%d\n" (double 21)
# An effectful function's NAME ends in !, and only an # effectful function may call one. A pure function # cannot print, and the compiler enforces it. double! : I64 => I64 double! = |value| { echo!("(doubling)") value * 2 } main! = |_args| { echo!(double!(21).to_str()) Ok({}) }
OCaml's double has type int -> int while printing, and nothing warns a caller. Haskell puts the effect in the type as IO Int, which is precise and colors every caller. Roc splits the difference: an effectful function must be named with a trailing ! and its arrow is => rather than ->, so effects are visible at every call site and the compiler rejects a pure function that tries to perform one. It still colors callers — double! can only be called from something effectful — but the marker is in the name, where you read it, rather than in a wrapper type you have to unwrap.
Platforms Versus Effect Handlers
Two answers to "who decides what an effect means", and the difference is where the decision is made.
(* OCaml 5: a computation suspends and a HANDLER decides what the effect means. The interpretation is chosen at the call site, in ordinary code. *) open Effect open Effect.Deep type _ Effect.t += Log : string -> unit Effect.t let task () = perform (Log "working"); 42 let () = let result = match_with task () { retc = (fun value -> value) ; exnc = raise ; effc = (fun (type a) (performed : a Effect.t) -> match performed with | Log text -> Some (fun (continuation : (a, _) continuation) -> print_endline ("logged: " ^ text); continue continuation ()) | _ -> None) } in Printf.printf "%d\n" result
# Roc: the PLATFORM decides what effects exist and what # they mean, chosen at BUILD time rather than at the call # site. The application just performs them. task! : {} => I64 task! = |{}| { echo!("logged: working") 42 } main! = |_args| { echo!(task!({}).to_str()) Ok({}) }
OCaml 5's handler is chosen at the call site, in ordinary code: the same task can be run under a handler that logs, one that collects, one that mocks, and the choice is a value. That is why effect handlers subsume schedulers, generators and async I/O. Roc's platform is chosen at build time: the whole application is compiled against one set of effects, with the platform author supplying the implementation in Rust or Zig. Roc's is coarser and buys something OCaml's does not — the platform also owns memory allocation and the entry point, so an application can target a microcontroller with no runtime at all.
The Pure Core Is Most of the Program
Worth seeing after the effects rows, because the impression they leave is that everything is marked, and it is not.
let summarize numbers = let total = List.fold_left ( + ) 0 numbers in Printf.sprintf "%d values, total %d" (List.length numbers) total let () = print_endline (summarize [ 4; 8; 15; 16 ])
# No ! anywhere: this function computes and cannot # perform an effect, which the compiler enforces. summarize : List(I64) -> Str summarize = |numbers| { total = numbers |> List.sum() "${numbers.len().to_str()} values, total ${total.to_str()}" } main! = |_args| { echo!(summarize([4, 8, 15, 16])) Ok({}) }
Neither column performs an effect, and the Roc version says so structurally — no ! in the name means the compiler will reject any attempt to print, read a file or reach the outside world from inside it. That is the same architecture Haskell's IO enforces and the same one careful OCaml achieves by discipline, with the marker in the name rather than in a wrapper type. The majority of any Roc program looks like this; the effectful shell is thin, which is the point of pushing effects to the platform.
Error Handling
Try Instead of result
The same type under a different name, and the error side is a tag union that needs no declaration.
let parse_positive text = match int_of_string_opt text with | None -> Error `NotANumber | Some number when number <= 0 -> Error `NotPositive | Some number -> Ok number let () = List.iter (fun text -> match parse_positive text with | Ok number -> Printf.printf "%d\n" number | Error `NotANumber -> print_endline "not a number" | Error `NotPositive -> print_endline "must be positive") [ "42"; "oops"; "-1" ]
parse_positive : Str -> Try(I64, [NotANumber, NotPositive]) parse_positive = |text| match I64.from_str(text) { Ok(number) => if number <= 0 { Err(NotPositive) } else { Ok(number) } Err(_) => Err(NotANumber) } main! = |_args| { for candidate in ["42", "oops", "-1"] { match parse_positive(candidate) { Ok(number) => echo!(number.to_str()) Err(NotANumber) => echo!("not a number") Err(NotPositive) => echo!("must be positive") } } Ok({}) }
Roc's Try(ok, err) is OCaml's result under a different name. The interesting half is the error type: [NotANumber, NotPositive] is a structural tag union written inline, so the exact set of failures a function can produce is part of its signature without declaring an exception type or an error enum. The OCaml column uses polymorphic variants to get the same shape, which is possible and unusual; a typical OCaml codebase would declare a variant or use strings.
Composing Failures
A ? after an expression unwraps the Ok and returns early on Err, so a chain of fallible steps reads straight down with one character per step marking where it can leave.
(* Chaining needs Result.bind, or a let* operator defined first. *) let ( let* ) = Result.bind let parse text = match int_of_string_opt text with | Some number -> Ok number | None -> Error `NotANumber let total first second = let* a = parse first in let* b = parse second in Ok (a + b) let () = match total "3" "4" with | Ok value -> Printf.printf "%d\n" value | Error `NotANumber -> print_endline "not a number"
parse : Str -> Try(I64, [NotANumber]) parse = |text| match I64.from_str(text) { Ok(number) => Ok(number) Err(_) => Err(NotANumber) } total : Str, Str -> Try(I64, [NotANumber]) total = |first, second| { left = parse(first)? right = parse(second)? Ok(left + right) } main! = |_args| { for pair in [("3", "4"), ("3", "oops")] { (first, second) = pair match total(first, second) { Ok(value) => echo!(value.to_str()) Err(NotANumber) => echo!("not a number") } } Ok({}) }
OCaml needs a binding operator to get here — let*, defined by hand once and then usable — and the anchor column defines one. Roc's ? is the same flattening built in, and the error type it propagates has to fit the function's own, which is why total declares [NotANumber]. When you annotate an error type that a ? inside main! must satisfy, leave the union open with ..; a closed one will not unify with what the platform declares.
crash Is for the Impossible
Roc separates "this can fail" from "this cannot happen" at the language level, and only one of them is catchable.
(* An exception is idiomatic for both expected failures and broken invariants, and the distinction lives in naming conventions. *) let divide numerator denominator = if denominator = 0 then failwith "impossible: checked upstream" else numerator / denominator let () = Printf.printf "%d\n" (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({}) }
OCaml uses one mechanism for both: failwith and a declared exception are the same kind of thing, and whether a raise is expected or catastrophic is a matter of convention. Roc splits them — a failure a caller should handle is Err in a Try, and a state that cannot occur is crash, which is not catchable at all. That is the same distinction Rust draws between Result and panic!, and Go between error and panic, and it is worth adopting in OCaml even though the language does not enforce it.
No Garbage Collector
Reference Counting, Not Tracing
Roc has neither a garbage collector nor manual memory management, which is a third option worth understanding.
(* A tracing collector: allocation is a pointer bump, deallocation is never written, and a collection pauses the program briefly. *) let () = let numbers = List.init 5 (fun index -> index * index) in List.iter (Printf.printf "%d ") numbers; print_newline (); Printf.printf "%d words\n" ((Gc.stat ()).Gc.heap_words > 0 |> fun _ -> 1)
main! = |_args| { # No tracing collector and no pauses. Values are # reference counted, and the counts are inserted by # the compiler rather than written by you. numbers : List(I64) numbers = [0, 1, 4, 9, 16] echo!(Str.inspect(numbers)) echo!("no GC pause, and no free() either") Ok({}) }
OCaml uses a generational tracing collector: allocation is cheap, deallocation is invisible, and collections pause the program — briefly and incrementally, but they exist. C makes you write every free. Roc reference counts, with the counts inserted by the compiler, so memory is released deterministically at the moment the last reference goes away and there is no pause. Because the language is pure there are no reference cycles to leak, which is the usual fatal flaw of refcounting and the reason Rust needs Weak. The cost is the counting itself, which the next row is about.
Opportunistic In-Place Mutation
The performance idea behind the whole language: immutable semantics, mutable execution, decided by the reference count.
(* Updating an immutable structure allocates a new one and shares what it can. The old version stays reachable and stays alive. *) let () = let numbers = [| 1; 2; 3 |] in let updated = Array.copy numbers in updated.(0) <- 99; Printf.printf "%d %d\n" numbers.(0) updated.(0)
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3] # Semantically this builds a NEW list. If the refcount # of "numbers" is 1 — nobody else holds it — the # compiler mutates in place and copies nothing. updated = numbers |> List.set(0, 99) echo!(Str.inspect(updated)) Ok({}) }
In a pure language, updating a structure means building a new one — which is why functional code is often assumed to be slower. Roc's answer is that the reference count already knows whether anyone else can observe the old version: if the count is one, mutating in place is unobservable, so the compiler does it and copies nothing. Write a fold that updates a list a million times and, if the intermediate values are not retained, it runs as an in-place loop. That is the same insight Rust encodes as ownership, obtained at runtime from the refcount instead of at compile time from the type system — less predictable, and requiring nothing from the programmer.
Purity Means No Reference Cycles
The reason reference counting is sufficient here, where it would leak in Rust or Swift.
(* A tracing collector handles cycles without being asked, which is why OCaml can have cyclic values at all. *) type node = { label : string; mutable next : node option } let () = let first = { label = "a"; next = None } in first.next <- Some first; (* a cycle, collected fine *) print_endline first.label
main! = |_args| { # A cycle cannot be built: values are immutable, so # nothing can be made to point back at something that # already exists. That is why refcounting is enough. first = { label: "a" } echo!(first.label) Ok({}) }
Reference counting's classic failure is a cycle: two values pointing at each other keep each other's count above zero forever, which is why Rust has Weak and Swift has unowned. Roc cannot build one — creating a cycle requires mutating something to point back at a value that already exists, and there is no mutation. So refcounting is complete rather than a best effort, with no tracing collector needed and no cycle detector. OCaml's tracing collector handles cycles without being asked, which is what lets it offer mutable fields in the first place.
Maturity and Ecosystem
It Is Pre-1.0, and This Page Is Pinned
Stated plainly rather than buried, because it is the main reason not to use Roc for anything that has to keep working.
(* OCaml is thirty years old. Its syntax, its module system and its standard library are stable, and code written a decade ago still compiles. *) let () = print_endline "stable since 1996"
# Roc has no 1.0 release, no stability guarantee, and a # syntax that has changed repeatedly — "when" became # "match", backpassing was added and removed, the # effects design has been revised more than once. # # This page is written against a specific nightly, which # uses Try rather than Result. # The build advances — it is rebuilt from source and # re-verified — but any given page is a snapshot. main! = |_args| { echo!("pinned to one nightly, on purpose") Ok({}) }
Roc has no 1.0, no stability promise, and a history of significant syntax changes — when became match, the backpassing syntax was introduced and then removed, and the effects design has been revised more than once. This site pins a nightly, and moves to a newer one only when every example still runs and prints what it printed before, so the page teaches the build that is installed. None of that is a reason to ignore the language — the ideas are worth knowing now and the compiler is genuinely pleasant — but it is a reason not to start a production system in it this year.
The Ecosystem Is Small
The practical constraint that bites before the syntax instability does.
(* opam carries several thousand packages, and the platform question — web framework, concurrency library — has several mature answers. *) let () = print_endline "several thousand opam packages"
# The package story is young, and the PLATFORM story is # younger: a Roc application needs a platform, and the # set of production-ready platforms is small. # # Writing one means Rust or Zig plus a C ABI, which is a # larger undertaking than writing a library. main! = |_args| { echo!("few packages, and fewer platforms") Ok({}) }
A Roc application cannot run without a platform, and the platform supplies every effect it can perform — so the question is not only "is there a library for this" but "is there a platform that can do this at all". The set of mature platforms is small, and writing one is a much larger undertaking than writing a library, since it means implementing the host in Rust or Zig against a C ABI and taking responsibility for memory allocation. OCaml's opam ecosystem is modest next to npm or crates.io and is enormous next to this.
No Async, and No Effect Handlers
A real gap, and one whose shape is unusual: the feature is not missing so much as delegated.
(* OCaml 5 has effect handlers, so concurrency is a library concern with no keyword and no function coloring. *) let () = print_endline "OCaml 5: effects, domains, Eio"
# Concurrency is the PLATFORM's business. An application # performs effects the platform provides; whether those # are asynchronous underneath is not the application's # concern, and there is no async keyword to write. # # What that means in practice depends entirely on which # platform you are on, and the mature ones are few. main! = |_args| { echo!("concurrency lives in the platform, not the language") Ok({}) }
Roc has no async, no threads, no channels and no effect handlers in the language. Concurrency belongs to the platform, so an application that wants it needs a platform that provides it — and the platform author implements it in Rust or Zig. In principle that is elegant: no colored functions, and the concurrency model is chosen with the deployment target. In practice it means the answer to "how do I run two things at once" is "depends on your platform", and for a language this young that is a thin answer. OCaml 5's effect handlers put the same flexibility in the language, where it can be used without writing a host.
No Macros, No ppx
A missing feature that mostly does not bite, because the thing it is usually used for is already there.
(* ppx rewrites the parse tree at build time, which is how deriving, inline tests and DSLs are written. *) type point = { x : int; y : int } (* [@@deriving show, eq] with ppx_deriving *) let () = Printf.printf "%d\n" { x = 1; y = 2 }.x
main! = |_args| { # No macros and no preprocessor. What deriving would # give you is built in instead, because the compiler # always knows a structural type's shape. point = { x: 1.I64, y: 2.I64 } echo!(Str.inspect(point)) Ok({}) }
OCaml's ppx rewrites the parse tree at build time and is how deriving, inline tests and embedded DSLs are written — genuinely powerful and genuinely hard to write. Roc has no macro system of any kind, and the most common use of one is unnecessary: printing and equality come with every structural type. What is left with no answer is the DSL case and anything wanting to generate code from a schema, which in Roc means a build step outside the language.
Why It Is Worth Watching
The Error Messages Are the Product
Worth a row of its own, because it is a stated design goal rather than a side effect, and it shows.
(* OCaml's errors have improved and are still often reported far from the actual mistake, because unification fails wherever it happens to fail. *) let () = let numbers = [ 1; 2; 3 ] in Printf.printf "%d\n" (List.length numbers)
# Roc treats diagnostics as a primary feature. A real # one: delete the annotation from the tags example at # the top of this page, and the compiler says # # ── ✗ missing method ────────────────────────────── # This is trying to dispatch a method named to_str on an # unresolved type variable, but unresolved type # variables have no methods. # Hint: You can replace this static dispatch call with # an ordinary function call, or force the type variable # to become more concrete—for example, by adding a type # annotation that narrows its type. main! = |_args| { numbers : List(I64) numbers = [1, 2, 3] echo!(numbers.len().to_str()) Ok({}) }
The message quoted in the comment is verbatim from the compiler this page runs on. It names the problem, points at the expression, explains why the situation is impossible, and suggests two concrete fixes. Roc's compiler treats that as a feature to be designed rather than a byproduct of type checking, and the difference from a unification failure reported wherever unification happened to fail is large. This is the thing most worth stealing from Roc regardless of whether the language succeeds, and Elm made the same argument first.
What Is Worth Taking From It
The last row, and the useful one: what an OCaml programmer can take home today without waiting for Roc to stabilize.
(* Two Roc ideas that already work in OCaml: use polymorphic variants where the set of cases is genuinely open, and put the exact failure set in the signature rather than behind a string. *) let parse text = match int_of_string_opt text with | Some number when number > 0 -> Ok number | Some _ -> Error `NotPositive | None -> Error `NotANumber let () = match parse "42" with | Ok number -> Printf.printf "%d\n" number | Error `NotPositive -> print_endline "must be positive" | Error `NotANumber -> print_endline "not a number"
# The same two ideas in their native form, where they # are simply how the language works rather than a choice. parse : Str -> Try(I64, [NotANumber, NotPositive]) parse = |text| match I64.from_str(text) { Ok(number) => if number > 0 { Ok(number) } else { Err(NotPositive) } Err(_) => Err(NotANumber) } main! = |_args| { match parse("42") { Ok(number) => echo!(number.to_str()) Err(NotPositive) => echo!("must be positive") Err(NotANumber) => echo!("not a number") } Ok({}) }
Two things transfer immediately. Use polymorphic variants where the set of cases is genuinely open — error types are the clearest case, since a function's exact failure set belongs in its signature and a closed variant forces every caller to know about failures that cannot reach them. And make the error type as precise as the success type: Error of string throws away everything the caller could have matched on. Roc makes both the default; OCaml offers both and its culture leans nominal, which is worth revisiting rather than assuming.
It Targets Places OCaml Struggles To
The most concrete argument for the platform design, and this page is an instance of it.
(* ocamlopt produces a native binary linked against the OCaml runtime — a collector, an exception mechanism and the standard library. *) let () = print_endline "native binary plus a runtime"
# The platform owns the allocator and the entry point, # so an application can target an environment with no # runtime at all — WebAssembly, or a microcontroller — # by being built against a platform written for it. # # This very page runs Roc IN THE BROWSER, compiled to # WebAssembly, with no server involved. main! = |_args| { echo!("compiled to wasm, running in your browser") Ok({}) }
Because the platform supplies the allocator and the entry point, a Roc application carries no runtime of its own and can be built for an environment that has none. The Roc column you are reading was compiled to WebAssembly and is running in the browser with no server. OCaml compiles to WebAssembly too — through js_of_ocaml, Melange or the newer wasm backend — and carries its runtime and collector along, which is the right trade for most programs and the wrong one for a microcontroller. This is the clearest thing Roc's architecture buys.
Should You Use It
Both columns print the same sentence; the row exists to say the thing plainly rather than leaving it implied.
(* The honest answer for production work today. *) let () = print_endline "not yet, and worth reading anyway"
# Same answer, stated from the other side. main! = |_args| { echo!("not yet, and worth reading anyway") Ok({}) }
For production work today, no: no 1.0, a syntax that has changed repeatedly, few platforms, and a small ecosystem. For an OCaml programmer's attention, yes — because Roc is the only language making structural tags and structural records the default, and it is worth seeing what a language looks like when your most exotic feature is its ordinary one. The platform split, the refcount-driven in-place mutation, and the diagnostics-first compiler are each worth understanding independently of whether Roc succeeds. That is a different question from whether to build something in it.

Thank you — anything else?