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 only the structural kind. 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 optional documentation; deleting it changes nothing. 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 Roc has no closed alternative to fall back on, so this is the sum type. Exhaustiveness is still checked: each match must cover the tags its own inferred type admits, so deleting a branch is still an error. What you give up is the ability to say "these three cases and no others, forever, everywhere".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 there is one mechanism: Bool.True, Ok and Err are all tags, and the same matching and inference apply to them as to anything 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.xmain! = |_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 an x and a y fits anywhere one is expected. The update syntax is the same idea under different punctuation — { ..origin, x: 5 } for { origin with x = 5 }. What is lost is the ability to keep two same-shaped things apart, which is what OCaml's nominal typing buys and what the branded-type trick tries to recover in TypeScript.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.timeoutmain! = |_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, and neither has named arguments with defaults on a plain function, so this is the idiom in both. 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: there is no shadowing at all.
(* 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 valuemain! = |_args| {
# A name is bound once. A second binding of the same
# name is a compile error, not a shadow — so each
# step needs 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 rejects it — a duplicate definition is a compile error, the same rule Erlang enforces, and for the same reason: the language wants a name to mean one thing in a scope. The practical effect is the same as Erlang's, 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. Two practical notes from the pinned build. A numeric literal whose type nothing else determines stays an unresolved variable, and calling a method on it fails with "unresolved type variables have no methods" — hence the
.I64 suffix above. And the compiler rejects a match whose value it can fold at compile time as an "unconditional condition", so an example that matches must get its value from somewhere less obvious.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 UTF-8, so its operations are Unicode-aware, 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. A Str is UTF-8 and is
# taken apart with operations that respect it.
text = "hello"
# No indexing by position either — a Str is taken
# apart with operations that respect UTF-8.
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 manipulated with operations that work on whole grapheme boundaries or on explicit code points. 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 whose key requirement is an ability the compiler checks, 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. Note that this build's List is sparse — there is no sort of any spelling in it.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 on the pinned build. 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; this row uses if chains because the compiler rejects a match on a value it can fold at compile time.Iterating
The
for loop exists in effectful code, and pure code folds instead.let () =
List.iter (fun word -> print_endline word) [ "alpha"; "beta"; "gamma" ]main! = |_args| {
# A for loop over a list, available because main! is
# effectful — in pure code you would fold instead.
for word in ["alpha", "beta", "gamma"] {
echo!(word)
}
Ok({})
}OCaml's
List.iter takes a function returning unit, which is the same idea: a loop whose purpose is its effects. Roc's for is available where effects are, and a pure transformation uses List.map, List.fold or a comprehension-free pipeline. Because purity is tracked by the ! naming rather than by a monad, the split between "loop for effect" and "fold for value" is visible in the syntax without any type-level machinery.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, and the failure output is the interesting part.
(* 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. It is checked by
# roc test, and in development a failing one prints
# every relevant value rather than just the expression.
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, is compiled out of release builds, and when one fails the compiler prints the value of every expression involved rather than just reporting that a boolean was false. That is the same diagnostics-first instinct as the error messages, applied to tests, and it is a small thing that changes how much a failing test tells you.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.Abilities 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 polymorphic
# function with an ABILITY bound covers the common case.
largest : List(I64) -> I64
largest = |items|
items |> List.fold(0, |best, item| if item > best { item } else { best })
main! = |_args| {
echo!(largest([3, 9, 4]).to_str())
Ok({})
}A Roc module is a file with an export list, and it cannot be parameterized, passed as a value or constrained by a signature. What stands in for a functor is an ability, which is a constraint on a type variable saying it supports certain operations — the same idea as a Haskell type class or a Rust trait, 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.
The Built-in Abilities
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 generate Inspect, Eq and Hash, and the abilities system makes them available on every type that has not opted out. 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
An ability bound 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 ])# Equality comes from the Eq ability, which every
# structural type gets derived automatically — so this
# needs no instance and no declaration.
count_matching : List(I64), I64 -> U64
count_matching = |items, wanted|
List.len(List.keep_if(items, |item| item == wanted))
main! = |_args| {
echo!(count_matching([1, 2, 1, 3], 1).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 abilities it must implement, and the compiler supplies them — the example above is written at a concrete type because the where-clause spelling this build accepts differs from the one Roc's documentation shows, and the page reports what the compiler took rather than what the docs say. The built-in abilities — Eq, Hash, Inspect, Encode, Decode — are derived automatically for structural types, so most constraints are satisfied without anyone writing an instance. 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, and the vendored build uses that name rather than Result. 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
Neither column is pleasant, and the pinned build has no sugar for it — which is worth stating plainly.
(* 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| match parse(first) {
Ok(a) => match parse(second) {
Ok(b) => Ok(a + b)
Err(error) => Err(error)
}
Err(error) => Err(error)
}
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 can define a
let* binding operator and flatten the chain to one line per step, which the anchor column does. Roc's own documentation describes a ? operator for exactly this — parse(first)? propagating the error — and the build this page is written against does not accept it, so the target column nests instead. Whether that is because the operator is newer than this build or because it is spelled differently here, the page does not claim to know: what it shows is what the installed compiler accepts, which is the only thing it can verify.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.labelmain! = |_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.What Is Not There Yet
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 and does not accept ?.
# 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 rebuilds it from source when a newer one passes every example, so the page teaches whichever build is installed; the error-composition row above shows what that costs, since the documented ? operator is not one this build accepts. 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 }.xmain! = |_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:
Inspect, Eq, Hash, Encode and Decode are derived automatically for 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 from writing THIS page:
#
# ── ✗ missing method ─────────────── main.roc:11:11
# 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 writing this page. 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 because its type system has no closed alternative; 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.