PONYλM2Modula-2

JavaScript.CodeCompared.To/Roc

An interactive executable cheatsheet comparing JavaScript and Roc

JavaScript Roc nightly
Hello World & The Platform Model
Hello, World
A Roc program is a definition of main!, a function the platform calls with the command-line arguments. The ! on the end of a name means "this performs effects", and the compiler enforces it.
console.log("Hello, World!");
main! = |_args| { echo!("Hello, World!") Ok({}) }
The _args parameter is named with a leading underscore because it is required by the signature and unused by the body. Ok({}) is the return value — {} is the empty record, which is Roc's way of returning nothing.
There is no ambient runtime
JavaScript always runs inside a host that has already decided what exists — window in a browser, process in Node. Roc formalizes that host as a platform: a program written in Rust or Zig that owns the entry point, the allocator and the complete list of effects an application may perform.
// Node or the browser is simply there: console, // fetch, setTimeout and process all exist without // anyone arranging for them. console.log(typeof console, typeof setTimeout);
main! = |_args| { # Nothing is ambient. This program can call # echo! and nothing else, because the platform # it was built against provides only echo!. echo!("every effect comes from the platform") Ok({}) }
The difference is that the list is checked. A Roc application cannot reach for an effect its platform does not provide, so there is no equivalent of code that works in Node and throws ReferenceError: window is not defined in the browser.
How a program reports failure
JavaScript signals failure by throwing, or by setting process.exitCode somewhere along the way. Roc's main! returns a Try: Ok for success and Err for failure, and the platform turns that into whatever the operating system wants.
function main() { console.log("all good"); return 0; } const status = main(); // In Node, this line makes it the process's exit status: // process.exitCode = status;
main! = |_args| { echo!("all good") Ok({}) }
Because the exit status is a return value rather than a mutable global, the compiler type-checks it. There is no way to set it from three places and lose track of which one won.
Comments
Comments start with #, and there is no block form, so a multi-line comment is several # lines.
// A single-line comment const count = 42; // an inline comment /* A block comment, which can span lines. */ console.log(count);
main! = |_args| { # A single-line comment count : I64 count = 42 # an inline comment # Roc has no block comment syntax — every # comment line starts with its own #. echo!(count.to_str()) Ok({}) }
The count : I64 line is a type annotation. Writing it is optional — Roc would infer a type — but it pins down which number type this is, and that decides what gets printed.
Dynamic Types vs Full Inference
Types checked before the program runs
JavaScript asks at the moment of multiplication whether the operands can be multiplied, and answers yes far too often. Roc infers a type for every expression in the whole program and rejects the mismatches before anything runs.
function double(number) { return number * 2; } console.log(double(21)); console.log(double("21")); // 42 — the string is // coerced to a number console.log(double("ab")); // NaN, silently
double : I64 -> I64 double = |number| number * 2 main! = |_args| { echo!(double(21).to_str()) # double("21") and double("ab") are both # COMPILE errors, named before the run. Ok({}) }
The third JavaScript line is the one worth staring at: NaN is not an error, it is a value, and it propagates silently through every subsequent calculation. Roc has no value that means "this arithmetic did not work".
No typeof, because the type is never in doubt
Runtime type inspection has no place in Roc, because a value's type is fixed and known. Where JavaScript would branch on typeof, Roc makes the possibilities explicit as a tag union and branches on the tag.
function describe(value) { if (typeof value === "number") return "a number"; if (typeof value === "string") return "a string"; return "something else"; } console.log(describe(42)); console.log(describe("hi")); console.log(describe(null)); // typeof null is // "object", so this // lands in the catch-all
describe : [Number(I64), Text(Str)] -> Str describe = |value| match value { Number(_) => "a number" Text(_) => "a string" } main! = |_args| { echo!(describe(Number(42))) echo!(describe(Text("hi"))) Ok({}) }
The Roc version has two cases and needs no fallback, because the type says there are exactly two. The JavaScript version needs a third branch it can never fully trust — and typeof null === "object", a bug too old to fix, is exactly the kind of thing that lands in it.
Structural types — you already think this way
A Roc record type is its set of fields, so an object literal with the right fields already is one. That is the same instinct as duck-typed JavaScript — with the difference that the shape is checked.
function describe(point) { return `(${point.x}, ${point.y})`; } // Any object with x and y works. Nothing declares // that it is a Point, and nothing checks. console.log(describe({ x: 1, y: 2 })); console.log(describe({ x: 1 })); // "(1, undefined)"
Point : { x : I64, y : I64 } describe : Point -> Str describe = |point| "(${point.x.to_str()}, ${point.y.to_str()})" main! = |_args| { echo!(describe({ x: 1, y: 2 })) # describe({ x: 1 }) does not compile. Ok({}) }
Point is an alias, not a class: nothing is constructed and nothing is registered. The missing field in the second JavaScript call produces undefined and a plausible-looking string; in Roc it is a compile error naming y.
Equality Without Coercion
One equality operator, and it never coerces
JavaScript has two equality operators because the first one was a mistake. Roc has one, and it takes two values of the same type — so the question "which operator should I use" never arises.
console.log(1 == "1"); console.log(1 === "1"); console.log([] == false); console.log(null == undefined);
main! = |_args| { # There is one == and it compares values of the # SAME type. Comparing an I64 with a Str is a # compile error, not false. echo!(Str.inspect(1.I64 == 1)) echo!(Str.inspect("1" == "1")) Ok({}) }
Everything the JavaScript column prints is a fact you had to memorize. In Roc there is nothing to memorize: an equality between different types does not compile, so there is no coercion table and no [] == false.
Deep equality comes free
Roc's == compares values, all the way down, on every type — including records, lists and tag unions. Nothing has an identity separate from its contents.
const first = { x: 1, y: 2 }; const second = { x: 1, y: 2 }; console.log(first === second); // false — identity console.log(JSON.stringify(first) === JSON.stringify(second));
main! = |_args| { first = { x: 1.I64, y: 2.I64 } second = { x: 1.I64, y: 2.I64 } echo!(Str.inspect(first == second)) Ok({}) }
The JavaScript workaround in the second line is a well-known bad idea: it depends on key order and cannot represent undefined, functions or cycles. In Roc there is no reference to compare instead, so structural equality is the only thing == could mean.
No truthiness
A Roc condition must be a Bool. No value converts to one implicitly, so the question being asked has to be written down.
const items = []; if (!items.length) { console.log("an empty array is truthy in JavaScript"); } if ("") { console.log("never reached"); } else { console.log("but an empty string is falsy"); }
main! = |_args| { items : List(I64) items = [] if items.is_empty() { echo!("the condition must be a real Bool") } # if items { … } or if "" { … } would be # COMPILE errors — nothing is implicitly truthy. echo!("and an empty string is just a string") Ok({}) }
JavaScript's falsy set — 0, "", NaN, null, undefined, false — is why if (count) silently skips zero and why an empty array being truthy surprises everyone once. Naming the test, is_empty(), removes the whole family.
Values & Immutability
const protects the binding; Roc protects the value
const in JavaScript means the name cannot be pointed at something else. It says nothing about the object, which is why const objects are mutated all day long.
const settings = { retries: 3 }; settings.retries = 5; // allowed — const only // stops rebinding console.log(settings);
main! = |_args| { settings = { retries: 3.I64 } # settings.retries = 5 is not a statement Roc # has. Build a new record instead: updated = { ..settings, retries: 5 } echo!(Str.inspect(settings)) echo!(Str.inspect(updated)) Ok({}) }
Roc has no assignment to a field, so there is no shallow-versus-deep distinction to keep in your head. The original record is still intact on the second printed line, which is the guarantee Object.freeze is reaching for and only partly delivers.
A name is bound once
Roc's = defines a name once. A second definition in the same scope is reported as a duplicate definition rather than accepted as a reassignment, and there is no let to opt out with.
let greeting = "hello"; greeting = "rebound"; console.log(greeting);
main! = |_args| { greeting = "hello" # greeting = "rebound" # ^ warns "duplicate definition", by design: the # program still runs, but the roc command exits # with status 2, so the rebinding cannot pass CI. echo!(greeting) Ok({}) }
Shadowing in a nested scope draws the same warning, which is stricter than const in a nested block. Each step of a calculation gets its own name, so a variable cannot quietly mean something different fifteen lines further down. A duplicate definition is a warning rather than an error, and that is deliberate: Roc runs code that has problems so you can try something quickly, and the roc command then exits with status 2, so the rebinding cannot survive a continuous-integration check. This page runs the program regardless and shows it as a success, with the name taking the new value from that point on.
Opting in to mutation
When something genuinely has to change, var declares it and a $ sigil marks every use — so mutation is visible where you read it rather than fifty lines up at the declaration.
let total = 0; total = total + 5; total = total + 10; console.log(total);
main! = |_args| { var $total = 0.I64 $total = $total + 5 $total = $total + 10 echo!($total.to_str()) Ok({}) }
A var is local to its function and cannot escape, so there is no Roc equivalent of a mutable module-level binding, and no hoisting for it to interact with.
Destructuring
Both forms of destructuring transfer almost unchanged. Roc uses a tuple (x, y) where JavaScript destructures an array, because a Roc list holds one element type and would not fit a pair of different ones.
const [x, y] = [3, 4]; console.log(`${x}, ${y}`); const person = { name: "Grace", age: 85 }; const { name, age } = person; console.log(`${name}: ${age}`);
main! = |_args| { (x, y) = (3.I64, 4.I64) echo!("${x.to_str()}, ${y.to_str()}") person = { name: "Grace", age: 85.I64 } { name, age } = person echo!("${name}: ${age.to_str()}") Ok({}) }
There is no default-value syntax (const { retries = 3 } = options) and no rest element in a record pattern, because a record's fields are known at compile time — a missing field is an error rather than something to supply a default for.
Numbers
One number type vs a full menu
JavaScript has one number type and it is a float, so integers above 2⁵³ lose precision without saying so. Roc has the whole menu — I8 through I128, U8 through U128, F32, F64 and Dec — sharing one set of operators.
// Every number is a 64-bit float, so the largest // exact integer is 2**53 - 1. console.log(Number.MAX_SAFE_INTEGER); console.log(9007199254740993); // silently wrong
main! = |_args| { byte : U8 byte = 255 big : I128 big = 170_141_183_460_469_231_731_687_303_715_884_105_727 echo!("${byte.to_str()} ${big.to_str()}") Ok({}) }
The second JavaScript line prints 9007199254740992, an integer one less than the one written. Choosing U8 in Roc is a claim that the value fits in a byte, and the compiler holds you to it: byte = 256 would not compile.
Exact decimals, without a library
Dec is a fixed-point decimal type and an ordinary member of the number menu — not a library, not a wrapper class, and not something you have to remember to build from a string.
console.log(0.1 + 0.2); console.log((0.1 + 0.2).toFixed(1)); // the usual // workaround
main! = |_args| { lossy : F64 lossy = 0.1 + 0.2 echo!(lossy.to_str()) precise : Dec precise = 0.1 + 0.2 echo!(precise.to_str()) Ok({}) }
The first line of each column is the familiar binary-floating-point answer. The second is where they part: JavaScript rounds for display and still holds the wrong number, while the Roc value is exact and stays exact through the next hundred operations.
Integer division and remainder
Roc has a dedicated floor-division operator, //, so integer division does not go through a Math function. / divides and produces a Dec.
console.log(Math.floor(17 / 5)); console.log(17 % 5); console.log(17 / 5); console.log(-7 % 3); // -1, not 2
main! = |_args| { quotient : I64 quotient = 17 // 5 echo!(quotient.to_str()) remainder : I64 remainder = 17 % 5 echo!(remainder.to_str()) exact : Dec exact = 17 / 5 echo!(exact.to_str()) Ok({}) }
JavaScript's % is a remainder rather than a modulo, which is why -7 % 3 is -1 and catches people writing a wrap-around index. Roc's % returns a non-negative result for a positive divisor.
There is no NaN to propagate
NaN is JavaScript's answer to arithmetic that did not work, and it is a poor one: it is a number, it spreads through every calculation that touches it, and it is not equal to itself.
const parsed = parseInt("abc", 10); console.log(parsed); // NaN console.log(parsed + 1); // NaN console.log(parsed === parsed); // false!
main! = |_args| { # Parsing returns a Try, so a failure is a value # the compiler makes you deal with rather than # one that quietly infects the arithmetic. match I64.from_str("abc") { Ok(parsed) => echo!((parsed + 1).to_str()) Err(_) => echo!("not a number") } Ok({}) }
Roc has no such value in its integer types. A conversion that can fail returns a Try, so the failure is visible at the point it happens rather than surfacing as a NaN three functions later with no clue where it came from.
Overflow is caught, not silently approximated
Roc's integers have a fixed width, so an addition that would pass the top of the range is an error — and when both operands are known at compile time, as here, it is caught before the program runs.
const big = 9223372036854775807; console.log(big); // already imprecise console.log(big + 1); // and no warning
main! = |_args| { big : I64 big = 9_223_372_036_854_775_807 # echo!((big + 1).to_str()) # ^ compile time crash: "Integer addition overflowed" echo!(big.to_str()) Ok({}) }
Underscores as digit separators work in both languages. The JavaScript column prints 9223372036854776000 for both lines — the literal was rounded to the nearest representable double on the way in, so adding one to it changes nothing and nobody is told.
Strings
Template literals become ordinary strings
The ${} syntax is the same, and there is no backtick to remember — every Roc string can interpolate. The one difference is that interpolation takes a Str and will not convert anything for you.
const name = "Roc bird"; const age = 10; console.log(`${name} is ${age}`); const message = `${name} turns ${age + 1}`; console.log(message);
main! = |_args| { name = "Roc bird" age : I64 age = 10 echo!("${name} is ${age.to_str()}") message = "${name} turns ${(age + 1).to_str()}" echo!(message) Ok({}) }
A JavaScript template literal calls String() on whatever it finds, which is how [object Object] gets into log lines. "${age}" in Roc is a type error naming I64 where Str was expected.
Concatenation without +
Roc reserves + for numbers. Joining two strings is concat, either as a method on the left-hand string or as a plain function.
console.log("Fast " + "and friendly"); console.log("count: " + 5); // "count: 5" console.log(5 + " is the count"); // "5 is the count" console.log(1 + 2 + " three"); // "3 three" console.log("one " + 2 + 3); // "one 23"
main! = |_args| { echo!("Fast ".concat("and friendly")) echo!(Str.concat("also ", "works")) # "count: ".concat(5) does not compile: # concat takes two Str values. Ok({}) }
The last two JavaScript lines are the reason this matters: + means addition or concatenation depending on operand types, and it associates left, so the answer depends on where the number sits. Roc's + has exactly one meaning.
Everyday string methods
The names are almost the ones you know, converted to snake case — starts_with for startsWith, contains for includes. Str.inspect turns a non-string value into something printable.
const padded = " systems "; console.log(padded.trim()); console.log("ab".repeat(3)); console.log("systems".startsWith("sys")); console.log("systems".includes("stem"));
main! = |_args| { padded = " systems " echo!(padded.trim()) echo!("ab".repeat(3)) echo!(Str.inspect("systems".starts_with("sys"))) echo!(Str.inspect("systems".contains("stem"))) Ok({}) }
Method syntax here is sugar: "ab".repeat(3) is resolved at compile time to Str.repeat("ab", 3). There is no prototype chain being walked and no method that could be missing at run time.
Splitting and joining
Splitting is split_on and joining is Str.join_with, which takes the list first and the separator second.
const parts = "red,green,blue".split(","); console.log(parts.length); console.log(parts.join(" | "));
main! = |_args| { parts = "red,green,blue".split_on(",") echo!(parts.len().to_str()) echo!(Str.join_with(parts, " | ")) Ok({}) }
JavaScript's split also accepts a regular expression and a limit; split_on takes one literal separator.
Unicode, without UTF-16 leaking through
A JavaScript string is a sequence of UTF-16 code units, so an emoji has a length of two and slice can cut one in half. A Roc Str is UTF-8 with no character index at all, so the question it answers directly is how many bytes there are.
console.log("rocket: \u{1F680}"); console.log("🚀".length); // 2 — UTF-16 code units console.log([..."🚀"].length); // 1
main! = |_args| { echo!("rocket: \u(1F680)") echo!("🚀".count_utf8_bytes().to_str()) Ok({}) }
Roc has no Char type and no way to subscript a string by position. That sounds restrictive until you count how much JavaScript string code is quietly wrong for anything outside the Basic Multilingual Plane.
Arrays vs Lists
A list holds one element type
The literal syntax matches, but a Roc List is a flat array of one element type rather than a sequence of arbitrary references. That is what makes length and indexing constant-time with nothing boxed.
const numbers = [3, 1, 4, 1, 5]; console.log(numbers.length); console.log(numbers); const mixed = [1, "two", { three: 3 }]; console.log(mixed);
main! = |_args| { numbers : List(I64) numbers = [3, 1, 4, 1, 5] echo!(numbers.len().to_str()) echo!(Str.inspect(numbers)) # A List holds ONE element type. For a mixture, # use a tag union — see the Tag Unions section. Ok({}) }
A heterogeneous list is not a list in Roc: the values would have to share a type, which in practice means wrapping each one in a tag. That is more typing, and it is also the compiler asking what the mixture is supposed to mean.
map and filter
This is the JavaScript idiom almost unchanged: the same chain, in the same order, with keep_if in place of filter and |number| in place of the arrow.
const numbers = [1, 2, 3, 4, 5, 6]; const doubledEvens = numbers .filter((number) => number % 2 === 0) .map((number) => number * 2); console.log(doubledEvens);
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3, 4, 5, 6] doubled_evens = numbers .keep_if(|number| number % 2 == 0) .map(|number| number * 2) echo!(Str.inspect(doubled_evens)) Ok({}) }
drop_if is the opposite of keep_if and has no JavaScript counterpart short of negating the predicate. Because each call returns a real list rather than a lazy view, there is no iterator-versus-array distinction to hold in mind.
reduce becomes fold
fold takes the starting value first and the function second, which is the reverse of reduce.
const numbers = [1, 2, 3, 4]; console.log(numbers.reduce((total, number) => total + number, 0)); console.log(numbers.reduce((total, number) => total + number));
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3, 4] total = numbers.fold(0, |accumulator, number| accumulator + number) echo!(total.to_str()) echo!(numbers.sum().to_str()) Ok({}) }
The starting value is not optional in Roc. JavaScript's reduce without one throws TypeError on an empty array, which is a runtime failure for a case the type system could have ruled out — and sum covers the common case in both languages anyway.
Indexing cannot return undefined
Reading past the end of a Roc list produces a Try — a value that says whether the read worked — rather than a value that pretends the read worked.
const numbers = [10, 20, 30]; console.log(numbers[9]); // undefined console.log(numbers[1]); console.log(numbers[9] + 1); // NaN, two bugs deep
main! = |_args| { numbers : List(I64) numbers = [10, 20, 30] match numbers.get(9) { Ok(value) => echo!(value.to_str()) Err(_) => echo!("out of bounds") } fallback = numbers.get(1) ?? 0 echo!(fallback.to_str()) Ok({}) }
The third JavaScript line is the point: undefined is not an error, so it flows onward and becomes NaN, and the stack trace you eventually get names neither. ?? supplies a default when that is what you want, which keeps the Roc version about as short.
sort() mutates and compares as strings
Roc's sort returns a new list and orders by the element type, since the element type is known.
const numbers = [3, 1, 2, 10]; console.log(numbers.sort()); // [1, 10, 2, 3] console.log(numbers); // mutated in place
main! = |_args| { numbers : List(I64) numbers = [3, 1, 2, 10] echo!(Str.inspect(numbers.sort())) echo!(Str.inspect(numbers)) Ok({}) }
JavaScript's sort without a comparator converts every element to a string, which is why 10 lands between 1 and 2 — and it mutates the array while returning it, so both printed lines are the sorted one. The Roc column's second line shows the original untouched.
slice becomes take and drop
Roc has no slice syntax and no negative indices. The things a slice is actually used for have names: take_first, drop_first, take_last and drop_last.
const numbers = [1, 2, 3, 4, 5]; console.log(numbers.slice(0, 2)); console.log(numbers.slice(2)); console.log(numbers.slice(-2));
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3, 4, 5] echo!(Str.inspect(numbers.take_first(2))) echo!(Str.inspect(numbers.drop_first(2))) echo!(Str.inspect(numbers.take_last(2))) Ok({}) }
Naming the operation removes the question of what a negative or out-of-range bound means — a question slice and splice answer differently from each other, which is its own small trap.
some, every and find
some and every are any and all; find is find_first.
const numbers = [2, 4, 6, 7]; console.log(numbers.some((number) => number % 2 === 1)); console.log(numbers.every((number) => number > 0)); const found = numbers.find((number) => number > 5); console.log(found === undefined ? "none" : `found ${found}`);
main! = |_args| { numbers : List(I64) numbers = [2, 4, 6, 7] echo!(Str.inspect(numbers.any(|number| number % 2 == 1))) echo!(Str.inspect(numbers.all(|number| number > 0))) match numbers.find_first(|number| number > 5) { Ok(found) => echo!("found ${found.to_str()}") Err(_) => echo!("none") } Ok({}) }
find returns undefined when nothing matches, which is indistinguishable from finding an element that happens to be undefined. find_first returns a Try, so "no match" and "matched a value" are different shapes rather than the same one.
Index and element together
Roc's map passes only the element, so the index needs a different method: map_with_index.
const words = ["one", "two"]; const labeled = words.map((word, index) => `${index}:${word}`); console.log(labeled);
main! = |_args| { words = ["one", "two"] labeled = words.map_with_index(|word, index| "${index.to_str()}:${word}") echo!(Str.inspect(labeled)) Ok({}) }
This split is deliberate. In JavaScript the extra parameters to map are the cause of the classic ["1","2","3"].map(parseInt) puzzle, where parseInt silently receives the index as its radix.
Objects vs Records
An object with known keys is a record
A JavaScript object with fixed, known keys is a record in Roc. The difference is when the key set is decided: a record's fields are part of its type, while an object's keys are data the program discovers as it goes.
const point = { x: 1.5, y: 2.5 }; console.log(`(${point.x}, ${point.y})`); console.log(point.z); // undefined console.log(point["z"]); // also undefined
main! = |_args| { point = { x: 1.5, y: 2.5 } echo!("(${point.x.to_str()}, ${point.y.to_str()})") # point.z is a COMPILE error: no such field. Ok({}) }
This is the biggest change of habit. A typo in a field name stops being undefined — flowing onward to become NaN or [object Object] somewhere else entirely — and becomes a compile error naming the field and the line.
Spread, but it cannot invent a field
Roc's .. is the object spread you already use, with one restriction that turns out to be the whole point: it cannot add a field the record did not already have.
const defaults = { verbose: false, retries: 3, timeoutSeconds: 30 }; const custom = { ...defaults, retries: 5 }; console.log(custom); const typo = { ...defaults, retrys: 5 }; // four keys console.log(Object.keys(typo).length);
main! = |_args| { defaults = { verbose: Bool.False, retries: 3.I64, timeout_seconds: 30.I64 } custom = { ..defaults, retries: 5 } echo!(Str.inspect(custom)) # { ..defaults, retrys: 5 } does not compile. Ok({}) }
The JavaScript column prints 4 for the typo case, because spread-then-override is really spread-then-add and the language cannot tell the two apart. Roc can, because it knows the field list.
Naming a shape
A one-line alias names a record shape. Nothing constructs an Employee — the literal already is one, because it has those fields with those types.
/** @typedef {{name: string, department: string}} Employee */ /** @param {Employee} employee */ function describe(employee) { return `${employee.name} works in ${employee.department}`; } console.log(describe({ name: "Nia", department: "Compilers" }));
Employee : { name : Str, department : Str } describe : Employee -> Str describe = |employee| "${employee.name} works in ${employee.department}" main! = |_args| { employee = { name: "Nia", department: "Compilers" } echo!(describe(employee)) Ok({}) }
The JSDoc comment in the JavaScript column is the closest equivalent, and it is a comment: it helps an editor and changes nothing about what runs. Equality and Str.inspect work on the Roc record without being asked, because they work on every record.
Map becomes Dict
When the keys really are data — counted words, a lookup built from input — Roc has Dict. Every key shares one type and so does every value.
const scores = new Map(); scores.set("math", 90); scores.set("art", 95); console.log(scores.size); console.log(scores.get("art") ?? 0); console.log(scores.get("music") ?? 0);
main! = |_args| { scores = Dict.empty() .insert("math", 90.I64) .insert("art", 95.I64) echo!(scores.len().to_str()) echo!((scores.get("art") ?? 0).to_str()) echo!((scores.get("music") ?? 0).to_str()) Ok({}) }
Roc's ?? is JavaScript's nullish coalescing operator doing the same job on a different shape: it takes the value out of an Ok and supplies the fallback for an Err. insert returns a new dict, which is why the calls chain.
Tuples, which JavaScript fakes with arrays
A tuple holds a fixed number of values of possibly different types, and is indexed with a dot. It is the type JavaScript is approximating whenever it returns an array of two different things.
const pair = [1, "two"]; // an array of mixed // types, length unchecked console.log(`${pair[0]} ${pair[1]}`); const [number, word] = pair; console.log(`${number} ${word}`);
main! = |_args| { pair = (1.I64, "two") echo!("${pair.0.to_str()} ${pair.1}") (number, word) = pair echo!("${number.to_str()} ${word}") Ok({}) }
Because the index is syntax rather than a runtime number, pair.2 is a compile error rather than undefined. The length is part of the type, so a two-tuple can never arrive with one element in it.
Tag Unions vs the type: Field
The type: field, done properly
This is the pattern every JavaScript codebase reinvents: an object with a type field and a switch over it. In Roc the discriminant is the value, and each variant declares exactly which fields it carries.
function area(shape) { switch (shape.type) { case "circle": return 3.14159 * shape.radius * shape.radius; case "rectangle": return shape.width * shape.height; default: throw new Error(`unknown shape ${shape.type}`); } } console.log(area({ type: "circle", radius: 2 })); console.log(area({ type: "rectangle", width: 3, height: 4 }));
Shape := [Circle(Dec), Rectangle(Dec, Dec)] area : Shape -> Dec area = |shape| match shape { Circle(radius) => 3.14159 * radius * radius Rectangle(width, height) => width * height } main! = |_args| { echo!(area(Shape.Circle(2)).to_str()) echo!(area(Shape.Rectangle(3, 4)).to_str()) Ok({}) }
The JavaScript version needs a default branch for a case that should be impossible, and nothing checks that the branches match the shapes. The Roc version needs no such branch — and adding a third variant makes the match incomplete, so the program stops compiling until it is handled.
Enums, which JavaScript does not have
A declared union is written with :=, which creates a genuinely new type rather than an alias. Its variants are reached through it, as Color.Green.
const Color = Object.freeze({ RED: "#FF0000", GREEN: "#00FF00", BLUE: "#0000FF", }); console.log(Color.GREEN); console.log(Color.GREN); // undefined, no error
Color := [Red, Green, Blue] to_hex : Color -> Str to_hex = |color| match color { Red => "#FF0000" Green => "#00FF00" Blue => "#0000FF" } main! = |_args| { echo!(to_hex(Color.Green)) Ok({}) }
The frozen-object idiom is the closest JavaScript gets, and the second line shows its weakness: a misspelled member is undefined rather than an error. Color.Gren in Roc does not compile.
Tags that need no declaration at all
A tag can be used with no declaration anywhere. Morning is a value the moment you write it, and its type is inferred as the set of tags that can reach that position.
const hour = 14; const period = hour < 12 ? "morning" : "afternoon"; const label = period === "morning" ? "AM" : "PM"; console.log(label);
main! = |_args| { hour : I64 hour = 14 period = if hour < 12 { Morning } else { Afternoon } label = match period { Morning => "AM" Afternoon => "PM" } echo!(label) Ok({}) }
JavaScript's stand-in is a string, and a string is a poor discriminant: "mornign" compares false and nothing complains. Roc knows this union is exactly [Morning, Afternoon], so a misspelling is an error and so is a missing branch.
Open unions: room for tags you have not met
The .. in the type means "and possibly other tags". The function handles two by name and everything else with a wildcard, and callers may pass tags that did not exist when it was written.
function describe(signal) { if (signal === "go") return "go"; if (signal === "stop") return "stop"; return "something else"; } console.log(describe("go")); console.log(describe({ custom: 7 }));
describe : [Go, Stop, ..] -> Str describe = |signal| match signal { Go => "go" Stop => "stop" _ => "something else" } main! = |_args| { echo!(describe(Go)) echo!(describe(Custom(7.I64))) Ok({}) }
This is the part with no JavaScript analogue at all. A function that accepts "a string or an object or something" has a type nobody can write down; here the openness is in the signature, and the compiler still checks the closed part.
Recursive data structures
A declared union may mention itself, which is how trees and syntax trees are written. No indirection is spelled out — the compiler works out where a pointer is needed.
function sumTree(tree) { if (tree.type === "leaf") return tree.value; return sumTree(tree.left) + sumTree(tree.right); } const tree = { type: "node", left: { type: "leaf", value: 1 }, right: { type: "node", left: { type: "leaf", value: 2 }, right: { type: "leaf", value: 3 }, }, }; console.log(sumTree(tree));
Tree := [Leaf(I64), Node(Tree, Tree)] sum_tree : Tree -> I64 sum_tree = |tree| match tree { Leaf(value) => value Node(left, right) => sum_tree(left) + sum_tree(right) } main! = |_args| { tree = Tree.Node(Tree.Leaf(1), Tree.Node(Tree.Leaf(2), Tree.Leaf(3))) echo!(sum_tree(tree).to_str()) Ok({}) }
Both columns do the same work; the difference is what can be built. A malformed JavaScript tree — a node missing its right — is undefined at whatever depth it happens to sit, then NaN at the top. A malformed Roc tree cannot be constructed.
Pattern Matching, Which You Lack
switch is a statement; match is an expression
JavaScript's switch is a statement, so each arm assigns to a variable declared outside it. Roc's match produces a value, so the whole thing sits on the right of one =.
const statusCode = 404; let message; switch (statusCode) { case 200: message = "ok"; break; case 404: message = "not found"; break; default: message = "something else"; } console.log(message);
main! = |_args| { status_code : I64 status_code = 404 message = match status_code { 200 => "ok" 404 => "not found" _ => "something else" } echo!(message) Ok({}) }
There is no fall-through and therefore no break to forget — the single most common switch bug simply has no way to occur. Every arm must also produce the same type, so an arm that forgets to produce anything is an error rather than an undefined later.
Guards
A guard is a condition attached to a pattern, so a single match covers what JavaScript writes as a ladder of early returns.
function describe(number) { if (number === 0) return "zero"; if (number < 0) return "negative"; if (number % 2 === 0) return "positive even"; return "positive odd"; } console.log(describe(0)); console.log(describe(-5)); console.log(describe(8));
describe : I64 -> Str describe = |number| match number { 0 => "zero" n if n < 0 => "negative" n if n % 2 == 0 => "positive even" _ => "positive odd" } main! = |_args| { echo!(describe(0)) echo!(describe(-5)) echo!(describe(8)) Ok({}) }
The JavaScript column is not wrong, but the shape hides something: nothing checks that the ladder is complete, and the final return is doing the work of a wildcard without being marked as one.
Or-patterns, without fall-through
Alternatives within one branch are written with |.
function sizeClass(number) { switch (number) { case 1: case 2: case 3: return "small"; default: return "big"; } } console.log(sizeClass(2)); console.log(sizeClass(9));
size_class : I64 -> Str size_class = |number| match number { 1 | 2 | 3 => "small" _ => "big" } main! = |_args| { echo!(size_class(2)) echo!(size_class(9)) Ok({}) }
JavaScript expresses the same thing by deliberately falling through empty cases, which reads identically to the accidental fall-through that comes from a forgotten break. Roc has one spelling and it means only the intended thing.
Matching on a list's shape
A pattern can describe a list's shape directly: empty, exactly one element, or a first element plus the rest.
function describe(numbers) { if (numbers.length === 0) return "empty"; if (numbers.length === 1) return `one: ${numbers[0]}`; const [first, ...rest] = numbers; return `first ${first}, ${rest.length} more`; } console.log(describe([])); console.log(describe([7])); console.log(describe([1, 2, 3]));
describe : List(I64) -> Str describe = |numbers| match numbers { [] => "empty" [single] => "one: ${single.to_str()}" [first, .. as rest] => "first ${first.to_str()}, ${rest.len().to_str()} more" } main! = |_args| { echo!(describe([])) echo!(describe([7])) echo!(describe([1, 2, 3])) Ok({}) }
JavaScript has destructuring but no way to match on shape, so the same logic becomes length comparisons. Roc's compiler rejects a match whose patterns miss a possible list, so these three arms are required rather than conventional.
Exhaustiveness is checked, not hoped for
This is the payoff for declaring the union. The compiler knows every tag the value can be, so it can name the one you forgot, at the moment you forget it.
function toHex(color) { switch (color) { case "red": return "#FF0000"; case "green": return "#00FF00"; // "blue" is missing — this returns undefined } } console.log(toHex("green")); console.log(toHex("blue"));
Color := [Red, Green, Blue] to_hex : Color -> Str to_hex = |color| match color { Red => "#FF0000" Green => "#00FF00" # Deleting the next line is a COMPILE ERROR # naming Blue as the case not handled. Blue => "#0000FF" } main! = |_args| { echo!(to_hex(Color.Green)) echo!(to_hex(Color.Blue)) Ok({}) }
The JavaScript column prints undefined for blue, because a switch that matches nothing simply falls out of the function. Adding a member to a frozen-object enum breaks nothing at the point of addition and everything later; adding a variant in Roc breaks every incomplete match immediately.
No null, No undefined
Neither of the two empty values exists
Roc has no null and no undefined. Absence is modeled by a tag that says what is absent, and the function's type says so out loud.
function findUser(userId) { return userId === 1 ? "Ada" : null; } const name = findUser(1); console.log(name !== null ? `found ${name}` : "missing");
find_user : U32 -> [Found(Str), Missing] find_user = |user_id| { if user_id == 1 { Found("Ada") } else { Missing } } main! = |_args| { match find_user(1) { Found(name) => echo!("found ${name}") Missing => echo!("missing") } Ok({}) }
JavaScript has two empty values with different histories and slightly different behavior, which is why every codebase has an opinion about which to use and a helper that treats them the same. Removing both removes the question and, with it, Cannot read properties of null.
?. and ?? become one operator
Roc's ?? reads exactly like JavaScript's nullish coalescing operator and does the corresponding job: it takes the value out of an Ok and supplies the fallback for an Err.
const numbers = []; console.log(numbers[0] ?? 0); const settings = new Map([["verbose", "true"]]); console.log(settings.get("retries") ?? "3");
main! = |_args| { numbers : List(I64) numbers = [] first = numbers.first() ?? 0 echo!(first.to_str()) settings = Dict.empty().insert("verbose", "true") echo!(settings.get("retries") ?? "3") Ok({}) }
There is no ?., because there is nothing to guard against — a record field always exists, so point?.x would be asking a question with only one answer. The chains of ?. that appear in real JavaScript are guarding against a shape the type system here does not permit.
Try vs throw
try/catch becomes a returned value
Roc has no exceptions and no stack unwinding. A function that can fail returns Try(ok, err), and the caller gets the failure as an ordinary value to match on.
function parseScore(text) { const score = Number(text.trim()); if (Number.isNaN(score)) throw new Error(`bad score: ${text}`); return score; } for (const candidate of ["95", "not a number"]) { try { console.log(`score: ${parseScore(candidate)}`); } catch (error) { console.log(error.message); } }
parse_score : Str -> Try(I64, [BadScore(Str)]) parse_score = |text| match I64.from_str(text.trim()) { Ok(score) => Ok(score) Err(_) => Err(BadScore(text)) } main! = |_args| { for candidate in ["95", "not a number"] { match parse_score(candidate) { Ok(score) => echo!("score: ${score.to_str()}") Err(BadScore(bad)) => echo!("bad score: ${bad}") } } Ok({}) }
The difference that matters is the signature. parse_score announces that it can fail and names how; a JavaScript caller has to know from documentation, or from being bitten, that this function throws — and catch gives you an any that might not even be an Error.
Letting a failure bubble up
A thrown error propagates invisibly. Roc's ? is the explicit version: it unwraps an Ok and returns early from the enclosing function on an Err.
function showFirst(numbers) { const first = numbers[0]; if (first === undefined) throw new Error("empty"); console.log(`first: ${first * 2}`); } showFirst([5, 6, 7]);
show_first! = |numbers| { first = numbers.first()? echo!("first: ${(first * 2).to_str()}") Ok({}) } main! = |_args| { numbers : List(I64) numbers = [5, 6, 7] show_first!(numbers) }
One character marks every place a function can exit early, so reading the body tells you its failure paths. In JavaScript any call at all might throw, which is why a try block tends to grow until it wraps everything.
Error types compose without a class hierarchy
An error in Roc is a tag, so a new error kind needs no class, no extends Error and no registration. The set of errors a function can produce is written in its return type.
class BadPort extends Error {} function readPort(text) { const value = Number(text); if (!Number.isInteger(value) || value < 0 || value > 65535) { throw new BadPort(`bad port: ${text}`); } return value; } for (const candidate of ["8080", "eighty"]) { try { console.log(readPort(candidate)); } catch (error) { console.log(error.message); } }
read_port : Str -> Try(U16, [BadPort(Str)]) read_port = |text| match U16.from_str(text) { Ok(port) => Ok(port) Err(_) => Err(BadPort(text)) } main! = |_args| { for candidate in ["8080", "eighty"] { match read_port(candidate) { Ok(port) => echo!(port.to_str()) Err(BadPort(bad)) => echo!("bad port: ${bad}") } } Ok({}) }
Choosing U16 makes the range check part of the parse, so the Roc function has one failure and cannot grow a second one silently. The JavaScript version has to restate the bounds by hand and its catch will also swallow a TypeError from anywhere in the block.
crash, for what cannot happen
There is exactly one way to stop a Roc program abruptly, and it is deliberately unlike an exception: crash cannot be caught, so it can only ever mean "this state is impossible".
function divide(numerator, denominator) { if (denominator === 0) { throw new Error("impossible: checked upstream"); } return Math.trunc(numerator / denominator); } console.log(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({}) }
A thrown Error is catchable by definition, which means someone three frames up can turn a genuine invariant violation into a logged warning and carry on with corrupt state. A crash has no handler to reach for.
Functions & Closures
Three function forms become one
There is one way to write a function, and it is the anonymous one. A named function is a name bound to a closure, so the top-level and local forms are identical.
function add(left, right) { return left + right; } const alsoAdd = function (left, right) { return left + right; }; const arrowAdd = (left, right) => left + right; console.log(add(2, 3)); console.log(alsoAdd(2, 3)); console.log(arrowAdd(2, 3));
add : I64, I64 -> I64 add = |left, right| left + right main! = |_args| { also_add = |left, right| left + right echo!(add(2, 3).to_str()) echo!(also_add(2.I64, 3.I64).to_str()) Ok({}) }
The three JavaScript forms differ in ways that have nothing to do with adding numbers: hoisting, this, arguments, and whether the function can be a constructor. Roc's single form has none of those distinctions to learn.
Closures capture values, not bindings
A Roc closure captures the values it mentions. There is no binding left behind for something else to change, and no let-versus-var question about which one was captured.
const makeCounters = () => { const counters = []; for (var index = 0; index < 3; index++) { counters.push(() => index); } return counters; }; console.log(makeCounters().map((counter) => counter()));
main! = |_args| { counters = [0.I64, 1, 2].map(|index| |{}| index) results = counters.map(|counter| counter({})) echo!(Str.inspect(results)) Ok({}) }
The JavaScript column prints [3, 3, 3] — the classic var loop-capture bug, fixed in modern code by using let and by nothing in the language itself. The Roc version cannot express the bug: what was captured cannot afterwards change.
There is no this
A Roc method takes the value as an ordinary first parameter — which is what this already is, spelled honestly. counter.describe() is sugar the compiler resolves to Counter.describe(counter).
const counter = { value: 7, describe() { return `count is ${this.value}`; }, }; console.log(counter.describe()); const loose = counter.describe; console.log(loose()); // the method, detached from // its receiver
Counter := { value : I64 }.{ describe : Counter -> Str describe = |counter| "count is ${counter.value.to_str()}" } main! = |_args| { counter = Counter.{ value: 7 } echo!(counter.describe()) # The same function, called the plain way: echo!(Counter.describe(counter)) Ok({}) }
Both Roc lines print the same answer, because the two spellings are the same call. The JavaScript column's second line prints count is undefined: detaching the method lost its receiver, and nothing complained. That is why bind, call, apply and the arrow-function-to-preserve-this trick exist — and none of them is needed here, because the receiver is a parameter and a parameter cannot go missing.
No default or rest parameters
Roc functions take a fixed number of positional arguments. There are no defaults and no rest parameters — the pattern that replaces both is a record of options with a named set of defaults.
function connect(host, port = 8080, { verbose = false } = {}) { return `${host}:${port} verbose=${verbose}`; } console.log(connect("example.com")); console.log(connect("example.com", 8080, { verbose: true }));
Options : { host : Str, port : U16, verbose : Bool } connect : Options -> Str connect = |options| "${options.host}:${options.port.to_str()} verbose=${Str.inspect(options.verbose)}" main! = |_args| { defaults = { host: "example.com", port: 8080.U16, verbose: Bool.False } echo!(connect(defaults)) echo!(connect({ ..defaults, verbose: Bool.True })) Ok({}) }
This is more ceremony than a JavaScript signature and it buys two things: the option set has a name and a type, and adding an option is a change to one record rather than to every call site. It also removes the destructuring-with-defaults-in-the-signature form, which is where JavaScript signatures become unreadable.
Generic functions
A lowercase name in a signature is a type variable. a -> a says the function returns exactly the type it was given, whatever that is.
const identity = (value) => value; console.log(identity("same")); console.log(identity(7));
identity : a -> a identity = |value| value main! = |_args| { echo!(identity("same")) echo!(identity(7.I64).to_str()) Ok({}) }
A JavaScript function is generic by accident, because nothing checks it. A Roc one is generic on purpose, and the signature is a promise: a -> a cannot inspect the value, because nothing at all is known about it.
Duck typing, checked in advance
A where clause says what the function needs from its type variable — here, a to_str method with that signature. It is duck typing with the bill checked before the meal.
function announce(value) { // Works for anything with a toString — // discovered when it does not. console.log(String(value)); } announce(42); announce(2.5);
announce! : a => {} where [a.to_str : a -> Str] announce! = |value| { echo!(value.to_str()) } main! = |_args| { announce!(42.I64) announce!(2.5.Dec) Ok({}) }
This is the closest thing Roc has to an interface, and it is structural: no type declares that it satisfies the clause, it simply has the method or it does not. Nothing has to be added to a prototype for it to qualify.
Control Flow
if is an expression, so the ternary disappears
Roc has no if statement — if produces a value, so the multi-branch choice is written once and reads as a single expression.
const score = 85; const grade = score >= 90 ? "A" : score >= 80 ? "B" : "C"; console.log(grade);
main! = |_args| { score : I64 score = 85 grade = if score >= 90 { "A" } else if score >= 80 { "B" } else { "C" } echo!(grade) Ok({}) }
JavaScript needs the ternary precisely because its if is a statement, and a chain of them is the form everyone argues about formatting. Roc's braces make the nesting explicit without changing the operator.
for...of
Roc's for is a statement that works in pure functions too, updating a var as it goes, as well as in effectful code like this.
for (const word of ["alpha", "beta", "gamma"]) { console.log(word); }
main! = |_args| { for word in ["alpha", "beta", "gamma"] { echo!(word) } Ok({}) }
The same loop can be written as a method, words.for_each!(|word| echo!(word)), which is the form that chains. There is no for...in to confuse it with, and no C-style counting loop at all.
while and break
Roc has a real while loop with break, which surprises people expecting a functional language to insist on recursion. It needs a var, since a loop over an unchanging condition would never end.
let count = 0; while (count < 5) { count = count + 1; if (count === 3) { break; } } console.log(count);
main! = |_args| { var $count = 0.I64 while $count < 5 { $count = $count + 1 if $count == 3 { break } } echo!($count.to_str()) Ok({}) }
There are no labeled breaks and no continue, so a loop that needs either is usually asking to be a fold or a keep_if instead.
Guard clauses and early return
return exists and does what you expect, so the guard-clause style transfers unchanged. The last expression of a block is its value, so the final line needs no return.
function clampPositive(number) { if (number < 0) { return 0; } return number; } console.log(clampPositive(-5)); console.log(clampPositive(9));
clamp_positive : I64 -> I64 clamp_positive = |number| { if number < 0 { return 0 } number } main! = |_args| { echo!(clamp_positive(-5).to_str()) echo!(clamp_positive(9).to_str()) Ok({}) }
Writing return on that last line would also work; leaving it off is the convention, and it is the same convention that makes if and match expressions. A Roc function cannot fall off the end and produce nothing, the way a JavaScript one returns undefined.
Recursion without blowing the stack
A call in tail position — the last thing a function does — is compiled to a jump rather than a new stack frame, so a tail-recursive Roc function runs in constant stack space however deep the recursion goes.
function sumTo(limit, accumulator) { if (limit <= 0) return accumulator; return sumTo(limit - 1, accumulator + limit); } try { console.log(sumTo(100000, 0)); } catch (error) { console.log(error.constructor.name); }
sum_to : I64, I64 -> I64 sum_to = |limit, accumulator| { if limit <= 0 { accumulator } else { sum_to(limit - 1, accumulator + limit) } } main! = |_args| { echo!(sum_to(100_000, 0).to_str()) Ok({}) }
Both columns are the same function with the same limit, so the printed answers are the whole story: Roc gives 5000050000 and JavaScript gives RangeError. Proper tail calls are in the JavaScript specification and implemented by almost nothing — V8 shipped them behind a flag and removed them — so every frame is real, and the try exists only to make the failure visible instead of fatal.
Effects Are Colored, Like async
A pure function cannot print
Two things mark an effectful function: its name ends in ! and its arrow is => rather than ->. Only an effectful function may call another one, and the compiler enforces it.
function describe(name) { return `hello ${name}`; } function announce(name) { console.log(describe(name)); // nothing stops a // "pure" function // from doing this } announce("Roc");
# Pure: Str -> Str (thin arrow) describe : Str -> Str describe = |name| "hello ${name}" # Effectful: Str => {} (fat arrow, name ends in !) announce! : Str => {} announce! = |name| { echo!(describe(name)) } main! = |_args| { announce!("Roc") Ok({}) }
Adding an echo! to describe would not compile: its signature would have to change, and so would every caller. That propagation is exactly what async does to a JavaScript call stack — the same coloring, applied to all side effects rather than only to time.
There is no async, and no Promise
Roc has no async keyword, no Promise and no event loop. Whether an effect is asynchronous underneath is the platform's business, and an application never sees it.
const fetchValue = async () => { console.log("(fetching)"); return 42; }; (async () => { const value = await fetchValue(); console.log(value); })();
# No async, no await, no Promise and no event # loop. An effectful function simply runs when it # is called, and returns when it is done. fetch_value! : {} => I64 fetch_value! = |{}| { echo!("(fetching)") 42 } main! = |_args| { echo!(fetch_value!({}).to_str()) Ok({}) }
This is a genuine limitation as much as a simplification: what concurrency a Roc program can do is entirely decided by the platform it is built against, and the set of mature platforms is small. There is no language-level answer to "run these two requests at once".
expect is part of the language
expect is a keyword rather than a library function, and it can appear at the top level of a file as well as inside one — which is how Roc writes unit tests without a testing framework.
const total = 2 + 2; console.assert(total === 4); console.log("the assertion held");
main! = |_args| { total : I64 total = 2 + 2 expect total == 4 echo!("the assertion held") Ok({}) }
A failing expect prints every value that fed the expression, not just the expression that was false. That is a deliberate goal of the compiler, and it is the idea most worth stealing whether or not you ever ship Roc.
No Runtime, No Event Loop, No GC
Reference counting, with no cycles to collect
JavaScript needs a tracing collector precisely because of the third line here: two objects holding each other would keep each other alive forever under simple reference counting. Roc needs no collector, and this row is why.
const first = { name: "first" }; const second = { name: "second", peer: first }; first.peer = second; // closing the cycle, which // only works because first // can still be changed console.log(second.peer.name); console.log(first.peer.name);
main! = |_args| { first = { name: "first" } second = { name: "second", peer: first } # first cannot be made to point back at second: # it was finished the moment it was defined, so # this program has no second line to print. echo!(second.peer.name) Ok({}) }
The JavaScript column closes the cycle and prints twice; the Roc column cannot, so it prints once. Nothing in Roc can be made to point back at a value that already exists, which is exactly the condition under which counting alone is sufficient — so the counts are inserted by the compiler and there is no collector, no generation, and no pause.
Functional updates that mutate when it is safe
Semantically set builds a new list. When the reference count of the old one is one — nobody else is holding it — the compiler mutates in place and copies nothing.
const numbers = [1, 2, 3]; const updated = [...numbers]; // an explicit copy, updated[1] = 99; // then mutate it console.log(updated); console.log(numbers);
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3] updated = numbers.set(1, 99) ?? numbers echo!(Str.inspect(updated)) echo!(Str.inspect(numbers)) Ok({}) }
This is why writing in an immutable style costs less in Roc than the spread-everything style costs in JavaScript. Here both lists are printed, so both must exist and a copy really is made; drop the second echo! and the copy disappears.
Top-level values are computed before the program starts
A top-level Roc definition is evaluated by the compiler, not at startup. By the time the program runs, squared is the constant 100 baked into the binary.
const limit = 10; const squared = limit * limit; // computed when the // module is loaded console.log(squared);
limit : I64 limit = 10 squared : I64 squared = limit * limit main! = |_args| { echo!(squared.to_str()) Ok({}) }
JavaScript does this work every time the module is loaded, and the cost is paid on each process start — which is a large part of why Node startup is measured in tens of milliseconds and a Roc binary starts in microseconds.
Prototypes vs Method Blocks
A class becomes a type with a method block
The .{ } after a type definition is a block of functions associated with that type. There is no this and no new — each function takes the value as an ordinary parameter and new is just a name someone chose.
class Counter { constructor(value = 0) { this.value = value; } increment() { return new Counter(this.value + 1); } describe() { return `count is ${this.value}`; } } console.log(new Counter().increment().increment().describe());
Counter := { value : I64 }.{ new : () -> Counter new = || { value: 0 } increment : Counter -> Counter increment = |{ value }| { value: value + 1 } describe : Counter -> Str describe = |counter| "count is ${counter.value.to_str()}" } main! = |_args| { counter = Counter.new().increment().increment() echo!(counter.describe()) Ok({}) }
Method syntax works because the compiler resolves counter.describe() from the type it already knows. There is no prototype to walk, no method resolution order, and no inheritance at all — a Roc type cannot be subclassed.
No prototype patching
A Roc type's method block is fixed where the type is defined. Nothing can add to it afterwards, and there is no prototype chain for a lookup to fall through.
// Extending a built-in at run time, from anywhere // in the program or any dependency: String.prototype.shout = function () { return `${this}!`; }; console.log("hello".shout());
# Str cannot be reopened, and neither can a type # you wrote. Write a function instead: shout : Str -> Str shout = |text| "${text}!" main! = |_args| { echo!(shout("hello")) Ok({}) }
Patching a built-in prototype is how two libraries end up defining incompatible versions of the same method, and how a method appears or disappears depending on import order. The replacement is an ordinary function — which is what shout already was before it was attached to anything.
A distinct type, not a renamed number
Defining a type with := rather than : makes it nominal — a genuinely distinct type that a plain number cannot stand in for, however similar the underlying data.
// The usual JavaScript answer is a comment. /** @typedef {number} UserId */ /** @param {UserId} userId */ function greet(userId) { return `user #${userId}`; } console.log(greet(42)); console.log(greet("42")); // "user #42" as well
UserId := { value : U64 } greet : UserId -> Str greet = |user_id| "user #${user_id.value.to_str()}" main! = |_args| { user_id = UserId.{ value: 42 } echo!(greet(user_id)) # greet(42) does not compile. Ok({}) }
This is the mechanism behind "parse, do not validate": once a value has been through UserId, every function downstream knows it was checked, and the compiler will not let an unchecked number take its place.
Gotchas for JavaScript Programmers
An untyped integer prints as a decimal
This is the first thing that will confuse you. An unconstrained number literal defaults to Dec, so a list that looks like integers prints as [1.0, 2.0, 3.0].
console.log([1, 2, 3]); console.log(1 + 2);
main! = |_args| { # No annotation: these literals become Dec, # and print with a decimal point. echo!(Str.inspect([1, 2, 3])) echo!(Str.inspect(1 + 2)) typed : List(I64) typed = [1, 2, 3] echo!(Str.inspect(typed)) Ok({}) }
The fix is an annotation or a suffix: typed : List(I64), or 42.I64 on the literal. Once anything in the expression pins the type down, the rest follows.
A bare True is a Bool, until it meets another tag
A bare True or False is a Bool, and !, and and or work on it with no annotation. It stops being one when it shares a value with a tag Bool does not have: if ready { True } else { Maybe } produces the tag union [Maybe, True, ..], and ! rejects that because it is not a Bool.
const ready = false; console.log(!ready);
main! = |_args| { # A bare False is a Bool; nothing needs annotating. ready = False echo!(Str.inspect(!ready)) # Mix True with a tag Bool does not have, and the # result is a union of tags rather than a Bool: # answer = if ready { True } else { Maybe } # !answer <- type mismatch: [Maybe, True, ..] # is not Bool Ok({}) }
Writing Bool.True and Bool.False in full is never wrong, and it is the spelling Roc's own documentation favors; the bare tags are the same values.
The standard library is still growing
Roc is pre-1.0 and its standard library is still growing, so some of what JavaScript programmers reach for is not there yet, and names can change from one release to the next. What is there often goes by a name of its own, like replace_each and with_ascii_uppercased here.
console.log("hello world".replace(" ", "_")); console.log("shout".toUpperCase());
main! = |_args| { # Replacement and case conversion go by names # of their own. echo!("hello world".replace_first(" ", "_")) echo!("shout".with_ascii_uppercased()) Ok({}) }
What is here is small, coherent and moving quickly.
No npm, and platforms are harder than packages
The thing a JavaScript programmer will miss first is not a language feature. It is npm, and the certainty that whatever you need has already been written by someone else.
// The JavaScript answer to "is there a library" // is almost always yes, and the answer to "how do // I try this" is a REPL that is already open. console.log(JSON.stringify({ packages: 3_000_000 }));
main! = |_args| { # Roc's package ecosystem is measured in dozens, # and a library that performs effects has to be # written against a PLATFORM interface — which # is a larger undertaking than publishing a # package. echo!("{\"packages\": \"dozens\"}") Ok({}) }
Roc's answer is that a young language has to earn that, and that the platform model makes it harder rather than easier: effects belong to the platform, so a library cannot simply reach for the network the way an npm package does. Read the language for its ideas; do not plan a production system around it yet.

Thank you — anything else?