PONY λ M2 Modula-2

TypeScript.CodeCompared.To/Roc

An interactive executable cheatsheet comparing TypeScript and Roc

TypeScript 6.0 Roc nightly (2026-07-02)
Hello World & The Platform Model
Hello, World
console.log("Hello, World!");
main! = |_args| { echo!("Hello, World!") Ok({}) }
The ! suffix on main! and echo! marks them as effectful functions — Roc tracks purity in the type system. main! receives the program arguments as a list and returns a Try: Ok({}) means success. There is no ambient console object; echo! is an effect provided by the platform hosting this program.
Where I/O comes from
// Node's runtime is ambient: any module can reach // the filesystem, the network, or the process. import { versions } from "node:process"; console.log(`running on Node ${versions.node}`);
main! = |_args| { echo!("every effect comes from the platform") Ok({}) }
This is the deepest difference between the two worlds. A TypeScript program assumes a runtime — Node, Deno, or a browser — whose entire API surface is ambiently available to every module. A Roc program cannot perform any I/O on its own: it is embedded by a platform — a host written in another language — and the platform decides exactly which effects exist. This page runs your code on the minimal "echo" platform, whose only effect is echo!. The upside is that a platform can make hard guarantees: a Roc plugin literally cannot touch the filesystem unless its host offers that effect — a sandbox npm install can only dream of.
Success and failure exit codes
console.log("all good"); process.exitCode = 0;
main! = |_args| { echo!("all good") Ok({}) }
In Node you signal failure by assigning process.exitCode or throwing from the top level. Roc's main! returns a Try: returning Err(SomeTag) makes the program exit with code 1, and Ok({}) exits cleanly. {} is Roc's empty record — the closest thing to void, except it is a real value you can pass around.
Types: Sound, Inferred, Never Erased
Type annotations
const count: number = 42; const label: string = "answer"; console.log(`${label}: ${count}`);
count : I64 count = 42 label : Str label = "answer" main! = |_args| { echo!("${label}: ${count.to_str()}") Ok({}) }
Roc annotations live on their own line above the definition — name : Type — rather than inline after a colon. Both languages make annotations optional thanks to inference, but Roc's are never erased: they are checked, drive code generation, and cannot be silenced with an as cast. Note the capitalized builtin names: I64, Str, Bool, List(a) — generics use parentheses, not angle brackets.
Inference covers whole programs
// TypeScript infers locals, but function parameters // are implicitly "any" unless annotated: function double(value: number): number { return value * 2; } console.log(double(21));
double = |value| value * 2 main! = |_args| { result : I64 result = double(21) echo!(result.to_str()) Ok({}) }
Roc uses full Hindley–Milner inference: parameter and return types are inferred from use, so double needs no annotation at all — the result : I64 annotation downstream pins the number type. TypeScript infers in one direction (from values to variables); Roc's inference flows both ways through the whole program, which is why idiomatic Roc code annotates top-level functions for documentation, not necessity.
No any, no as, no escape hatches
const mystery: any = "actually a string"; const forced = mystery as number; // Compiles fine, lies at runtime: console.log(forced);
main! = |_args| { # Roc has no `any` type and no cast operator. # Every conversion is an explicit function call: parsed = I64.from_str("42") ?? 0 echo!(parsed.to_str()) Ok({}) }
TypeScript's type system is deliberately unsound: any, as, and ! assertions let values escape checking, and the erased types can lie at runtime. Roc has none of these — there is no way to tell the compiler "trust me." The payoff is that a Roc type is a guarantee, not a suggestion: if a value claims to be I64, it is one.
Structural typing — you already think this way
type Point = { x: number; y: number }; function describe(point: Point): string { return `(${point.x}, ${point.y})`; } // Any object with the right shape is a Point: console.log(describe({ x: 1, y: 2 }));
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 })) Ok({}) }
Here TypeScript developers have a head start on everyone else: Roc records are structurally typed, exactly like TypeScript objects — any record with the right fields satisfies the type, and a type alias (single colon) just gives the shape a name. Coming from Java or Rust this is a big adjustment; coming from TypeScript it is home turf.
Values & Immutability
Everything is const — deeply
const greeting = "cannot reassign"; // greeting = "nope"; // TS error const items = [1, 2, 3]; items.push(4); // ...but mutation is fine! console.log(greeting, items.length);
main! = |_args| { greeting = "always immutable" echo!(greeting) Ok({}) }
TypeScript's const only freezes the binding — the array behind it mutates freely, and true immutability needs readonly, as const, or Object.freeze discipline. In Roc, a plain name = value binding is deeply immutable: there is no push, no property assignment, no way to mutate a shared value, period. Local mutation exists but is opt-in and visually loud (next row).
Opt-in mutation: let vs var $
let total = 0; total += 5; total += 10; console.log(total);
main! = |_args| { var $total = 0.I64 $total = $total + 5 $total = $total + 10 echo!($total.to_str()) Ok({}) }
Roc's var declares a reassignable local, and the $ sigil must appear on every use, so mutation is impossible to miss when reading code — imagine if every use of a let variable had to be spelled differently from a const one. A var is local-only: it cannot be captured by a closure or escape the function, so mutation never becomes shared state.
Destructuring assignment
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({}) }
Destructuring works the same way in both languages — note that Roc uses parentheses for tuples where TypeScript reuses array syntax. One Roc twist: the pattern must be exhaustive. You can destructure a tuple or a record directly, but you cannot write Ok(item) = list.first() as an assignment, because the Err case would be unhandled — use match for that.
Numbers
One number type vs a full menu
// TypeScript has number (an IEEE 754 double)... const byte = 255; // ...and bigint for arbitrary precision: const big = 170141183460469231731687303715884105727n; console.log(byte, big);
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({}) }
TypeScript inherits JavaScript's single number type — a 64-bit float where integers are only safe up to 253 − 1 — plus bigint. Roc offers the full menu: I8/U8 through I128/U128, floats F32/F64, and Dec. That I128 literal holds a value number could never represent exactly. Underscore digit separators work in both languages.
Dec: 0.1 + 0.2 is finally 0.3
console.log(0.1 + 0.2); // 0.30000000000000004 — the classic console.log(0.1 + 0.2 === 0.3); // false
main! = |_args| { precise : Dec precise = 0.1 + 0.2 echo!(precise.to_str()) lossy : F64 lossy = 0.1 + 0.2 echo!(lossy.to_str()) Ok({}) }
Roc's flagship number type Dec is a 128-bit fixed-point decimal — and it is the default for unconstrained fractional literals, so 0.1 + 0.2 is exactly 0.3 out of the box. IEEE 754 floats (F32/F64) are still available when you want hardware speed, and they reproduce the familiar 0.30000000000000004. JavaScript's long-promised decimal proposal has been stuck in committee for years; Roc just made it the default.
Integer division and remainder
const quotient = Math.trunc(17 / 5); const remainder = 17 % 5; console.log(quotient, remainder);
main! = |_args| { quotient : I64 quotient = 17 // 5 remainder : I64 remainder = 17 % 5 echo!("${quotient.to_str()} ${remainder.to_str()}") Ok({}) }
In TypeScript, 17 / 5 is 3.4 and integer division requires Math.trunc. Roc spells integer division // (like Python) because plain / is reserved for exact division on Dec and floats. The remainder operator % matches. Dividing an integer by zero with // is an error in Roc — not Infinity, and not NaN.
Conversions are explicit
const total = 3; const average = total / 2; // number-to-string is implicit in templates: console.log(`average: ${average}`);
main! = |_args| { total : I64 total = 3 average = total.to_f64() / 2.0 echo!("average: ${average.to_str()}") Ok({}) }
TypeScript numbers are all one type, so mixed arithmetic just works — and string templates call toString() implicitly. Roc never converts numbers implicitly: dividing an I64 by a float requires an explicit .to_f64(), and interpolation requires an explicit .to_str(). Noisier, but no "5" + 3 class of surprise can exist.
Overflow is a compile error (when computable)
const big = Number.MAX_SAFE_INTEGER; // Past 2^53 - 1, integers silently lose precision: console.log(big + 1 === big + 2); // true (!)
main! = |_args| { big : I64 big = 9_223_372_036_854_775_807 # echo!((big + 1).to_str()) # ^ COMPILE ERROR: "Integer addition overflowed!" echo!(big.to_str()) Ok({}) }
JavaScript numbers drift silently past MAX_SAFE_INTEGERbig + 1 === big + 2 is genuinely true. Roc integers never lose precision: overflow is a hard error, and because Roc evaluates compile-time-known expressions during compilation, the commented line is rejected before the program ever runs.
Strings
String interpolation — same syntax!
const name = "TypeScripter"; 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({}) }
Roc borrowed JavaScript's ${...} interpolation syntax — but it works inside ordinary double-quoted strings, with no backtick needed. The catch: interpolation only accepts Str values. There is no implicit toString(), so numbers need an explicit .to_str() call.
Concatenation
const start = "Fast "; const combined = start + "and friendly"; console.log(combined);
main! = |_args| { combined = "Fast ".concat("and friendly") echo!(combined) echo!(Str.concat("also ", "works")) Ok({}) }
Roc has no + for strings — concatenation is the concat method, callable in method style or as Str.concat. Losing string + also means losing its dark side: nothing in Roc can silently coerce a number into a string mid-expression.
Everyday string methods
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({}) }
The everyday methods map almost one-to-one, just in snake_case: trim, repeat, starts_with, ends_with, and contains (TypeScript's includes). Str.inspect is the debug formatter — the closest thing to JSON.stringify for quick output — used here because Bool has no .to_str() in the current build.
Splitting and joining
const parts = "red,green,blue".split(","); console.log(parts.length); const joined = parts.join(" | "); console.log(joined);
main! = |_args| { parts = "red,green,blue".split_on(",") echo!(parts.len().to_str()) joined = Str.join_with(parts, " | ") echo!(joined) Ok({}) }
The shape is identical: split_on returns a List(Str), just as split returns an array. Joining goes through Str.join_with(list, separator) — note it lives on Str rather than being a method on the list, the reverse of JavaScript's array.join.
Unicode escapes and UTF-8
console.log("rocket: \u{1F680}"); console.log("héllo".length); // 5 UTF-16 code units console.log([..."héllo"].length); // 5 code points
main! = |_args| { echo!("rocket: \u(1F680)") echo!("héllo".count_utf8_bytes().to_str()) Ok({}) }
The escape syntax nearly matches — JavaScript's \u{1F680} versus Roc's \u(1F680) — but the underlying encoding differs: JavaScript strings are UTF-16 (which is why "💎".length is 2), while Roc strings are UTF-8. Roc has no ambiguous .length at all: the method is named count_utf8_bytes() so nobody mistakes bytes for characters, and prints 6 here because é is two bytes.
Lists vs Arrays
List literals
const numbers: number[] = [3, 1, 4, 1, 5]; console.log(numbers.length); console.log(numbers);
main! = |_args| { numbers : List(I64) numbers = [3, 1, 4, 1, 5] echo!(numbers.len().to_str()) echo!(Str.inspect(numbers)) Ok({}) }
Roc's List is the one sequential collection — contiguous in memory, not a linked list, despite the functional-language name. The literal syntax is identical to an array literal. Str.inspect plays the role of console.log's object formatting for quick debugging output.
map and filter
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({}) }
The chained transformation style transfers directly — filter is named keep_if (with siblings drop_if and count_if), and closures use pipes instead of arrows. Unlike JavaScript, where each step allocates a fresh array, Roc's compiler mutates in place whenever the intermediate list is unshared, so the functional style has no hidden allocation tax.
reduce becomes fold
const numbers = [1, 2, 3, 4]; const total = numbers.reduce( (accumulator, number) => accumulator + number, 0, ); console.log(total);
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({}) }
Roc's fold is reduce with the arguments flipped: the initial accumulator comes first, then the closure. Roc also ships the sum() shortcut JavaScript never got, plus fold_rev, fold_with_index, and fold_until (which can stop early by returning Break(value)).
Indexing returns Try, not undefined
const numbers = [10, 20, 30]; const missing = numbers[9]; // undefined leaks in as the "value": console.log(missing); const fallback = numbers[9] ?? 0; console.log(fallback);
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({}) }
TypeScript's numbers[9] types as number but is undefined at runtime — the canonical unsoundness (noUncheckedIndexedAccess exists precisely to patch it). Roc has no subscript operator at all: .get() returns a Try, so the out-of-bounds case is a value you must handle. Roc's ?? works on Try the way TypeScript's works on undefined.
Sorting without the footguns
const numbers = [10, 9, 1]; // The classic trap: default sort is lexicographic! console.log([...numbers].sort()); // [1, 10, 9] console.log([...numbers].sort((left, right) => left - right)); console.log([...numbers].reverse());
main! = |_args| { numbers : List(I64) numbers = [10, 9, 1] ascending = numbers.sort_with(|left, right| { if left < right { LT } else if left > right { GT } else { EQ } }) echo!(Str.inspect(ascending)) echo!(Str.inspect(ascending.rev())) Ok({}) }
JavaScript's default sort() compares stringified elements — [10, 9, 1].sort() gives [1, 10, 9] — and mutates in place, which is why the spread copies are needed. Roc's sort_with takes a comparator returning LT/EQ/GT tags and returns a new list; when the original is unshared, the "copy" happens in place anyway, so immutability costs nothing.
slice becomes take/drop
const numbers = [1, 2, 3, 4, 5]; const head = numbers.slice(0, 2); const tail = numbers.slice(2); console.log(head, tail);
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3, 4, 5] head = numbers.take_first(2) tail = numbers.drop_first(2) echo!("${Str.inspect(head)} ${Str.inspect(tail)}") Ok({}) }
take_first and drop_first (plus take_last, drop_last, and sublist) cover what slice does, with names that say which end they work from. Conceptually they produce new lists, but reference counting lets them share structure, so slicing a large list is cheap.
Searching with predicates
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 ?? "none");
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({}) }
The predicate helpers map cleanly: someany, everyall, findfind_first, and findIndexfind_first_index. The one structural difference: find_first returns a Try instead of T | undefined, so "not found" is a tag you match on rather than a sentinel value that might collide with real data.
Records vs Objects
Records are objects without the baggage
const point = { x: 1.5, y: 2.5 }; console.log(`(${point.x}, ${point.y})`);
main! = |_args| { point = { x: 1.5, y: 2.5 } echo!("(${point.x.to_str()}, ${point.y.to_str()})") Ok({}) }
The literal syntax and dot access are identical. What is missing is the baggage: a Roc record has no prototype chain, no this, no methods-attached-to-data, and no reference identity — it is pure data, compared by value. Two records with the same fields are the same type, exactly as in TypeScript's structural system.
Spread update
const defaults = { verbose: false, retries: 3, timeoutSeconds: 30 }; const custom = { ...defaults, retries: 5 }; console.log(custom);
main! = |_args| { defaults = { verbose: Bool.False, retries: 3.I64, timeout_seconds: 30.I64 } custom = { ..defaults, retries: 5 } echo!(Str.inspect(custom)) Ok({}) }
The spread-update idiom translates almost keystroke-for-keystroke — Roc's spread is two dots instead of three. One semantic upgrade: TypeScript's spread is a shallow copy of a mutable object, while Roc's produces a genuinely immutable value, and the compiler reuses the original's memory when nothing else references it.
Real tuples, not dressed-up arrays
const pair: [number, string] = [1, "two"]; 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({}) }
TypeScript tuples are arrays wearing a type annotation — at runtime you can still push onto one. Roc tuples are a distinct value kind with parenthesis syntax, accessed as .0/.1 with a literal index the compiler verifies. Destructuring works the same way in both.
interface / type alias → type alias
interface Employee { name: string; department: string; } function describe(employee: Employee): string { return `${employee.name} works in ${employee.department}`; } const employee = { name: "Nia", department: "Compilers" }; console.log(describe(employee));
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({}) }
A Roc type alias (single colon) is TypeScript's type alias: transparent and structural, so any record with those fields satisfies it. Roc has no separate interface construct and no declaration merging — one way to name a shape. When you want a nominal type that will not unify with look-alikes (what TypeScript fakes with branded types), Roc has :=, covered under Tag Unions.
Tag Unions vs Discriminated Unions
Discriminated unions without the discriminant
type Shape = | { kind: "circle"; radius: number } | { kind: "rectangle"; width: number; height: number }; function area(shape: Shape): number { switch (shape.kind) { case "circle": return 3.14159 * shape.radius * shape.radius; case "rectangle": return shape.width * shape.height; } } console.log(area({ kind: "circle", radius: 2 })); console.log(area({ kind: "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({}) }
This is the feature TypeScript developers take to instantly. A tag union is a discriminated union where the language supplies the discriminant: no kind: field to invent, no string literals to keep in sync, and the payload is positional instead of a bag of named fields. match destructures and narrows in one step, with exhaustiveness checked by the compiler rather than by a never-typed default-case trick.
Tags without any declaration
// TypeScript needs the union type declared (or inferred // from string literals) before narrowing works: const hour = 14; const period: "morning" | "afternoon" = 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({}) }
Structural tags spring into existence at the point of use: the if gives period the inferred type [Morning, Afternoon] with no declaration anywhere, and the match is still checked for exhaustiveness. It is the ergonomics of TypeScript's string-literal unions with actual named constructors that can also carry payloads.
Open unions: extensibility in the type
type Signal = "go" | "stop" | (string & {}); function describe(signal: Signal): string { switch (signal) { case "go": return "go"; case "stop": return "stop"; default: return "something else"; } } console.log(describe("go")); console.log(describe("custom"));
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({}) }
The .. in [Go, Stop, ..] makes the union open: the function accepts those tags plus any others, provided a catch-all branch handles the rest. TypeScript approximates this with the notorious string & {} hack to keep autocomplete while accepting arbitrary strings; Roc expresses the idea directly in the type — and open tags can carry payloads, which the string hack never could.
Recursive types
type Tree = | { kind: "leaf"; value: number } | { kind: "node"; left: Tree; right: Tree }; function sumTree(tree: Tree): number { return tree.kind === "leaf" ? tree.value : sumTree(tree.left) + sumTree(tree.right); } const tree: Tree = { kind: "node", left: { kind: "leaf", value: 1 }, right: { kind: "node", left: { kind: "leaf", value: 2 }, right: { kind: "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 languages handle recursive types, but compare the construction sites: the TypeScript tree is a thicket of kind: fields and braces, while the Roc tree reads like the data it represents. Roc's recursive tag unions are heap-allocated and reference-counted automatically behind the scenes.
Pattern Matching
match is switch done right
const statusCode = 404; let message: string; 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({}) }
Everything switch gets wrong, match fixes: it is an expression (so the result feeds a binding directly, no mutable let dance), there is no fallthrough and no break, and missing a case is a compile error rather than a silent skip. TC39's long-discussed pattern-matching proposal is essentially this construct.
Guards
function describe(number: number): string { 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({}) }
A guard — pattern if condition => — attaches an arbitrary boolean test to a branch, replacing the early-return ladder TypeScript reaches for. A guarded branch does not count toward exhaustiveness, so the final catch-all is still required.
Or-patterns
function sizeClass(number: number): string { 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({}) }
Where switch expresses "any of these" through deliberate case fallthrough — the same mechanism that causes bugs when a break is forgotten — match uses an explicit | between alternatives in a single branch.
Matching on list shape
function describe(numbers: number[]): string { 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({}) }
TypeScript has rest destructuring but cannot branch on array shape — you check .length by hand and hope the destructuring matches the branch you are in. Roc's list patterns unify the test and the destructuring: [first, .. as rest] both matches "one or more elements" and binds the pieces, with the compiler verifying all shapes are covered.
No null, No undefined
T | undefined becomes a tag union
function findUser(id: number): string | undefined { return id === 1 ? "Ada" : undefined; } const user = findUser(1); if (user !== undefined) { console.log(`found ${user}`); } else { console.log("missing"); }
find_user : U32 -> [Found(Str), Missing] find_user = |id| { if id == 1 { Found("Ada") } else { Missing } } main! = |_args| { match find_user(1) { Found(name) => echo!("found ${name}") Missing => echo!("missing") } Ok({}) }
Roc has no null, no undefined — and no built-in Option either. Where TypeScript writes string | undefined, Roc returns either a Try (when absence is an error) or an ad-hoc structural union like [Found(Str), Missing], which needs no declaration and names the absent case something meaningful. There is no strictness flag to enable: unhandled absence is always a type error.
?? — same operator, sounder foundation
const numbers: number[] = []; const first = numbers[0] ?? 0; console.log(first);
main! = |_args| { numbers : List(I64) numbers = [] first = numbers.first() ?? 0 echo!(first.to_str()) Ok({}) }
Roc's ?? will feel like an old friend: it supplies a default, just like nullish coalescing. The difference is what it defaults over — TypeScript's ?? triggers on the values null and undefined, while Roc's unwraps a Try, substituting the default when it is Err. No value can ever be null in Roc, so the operator works on the result type instead.
Why there is no ?. operator
const settings: { theme?: { accent?: string } } = {}; const accent = settings.theme?.accent ?? "default"; console.log(accent);
main! = |_args| { settings = { theme: { accent: "midnight" } } # Every field is guaranteed present by the type, # so plain dot access can never fail: echo!(settings.theme.accent) Ok({}) }
Roc has no ?. because nothing needs it: a record type guarantees every field exists, so dot access cannot fail. Optionality is modeled in the data instead — a field whose value is a tag union like [Set(Str), Unset] — which forces the "what if it is missing?" decision to the one place that constructs the value, rather than sprinkling ?. at every use site.
Try vs Exceptions
Errors are values, not exceptions
function parseScore(text: string): number { const parsed = Number.parseInt(text.trim(), 10); if (Number.isNaN(parsed)) { throw new Error(`bad score: ${text}`); } return parsed; } try { console.log(`score: ${parseScore("95")}`); console.log(`score: ${parseScore("not a number")}`); } catch (problem) { console.log((problem as 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| { match parse_score("95") { Ok(score) => echo!("score: ${score.to_str()}") Err(BadScore(bad)) => echo!("bad score: ${bad}") } match parse_score("not a number") { Ok(score) => echo!("score: ${score.to_str()}") Err(BadScore(bad)) => echo!("bad score: ${bad}") } Ok({}) }
Roc has no exceptions, no throw, and no try/catch. A fallible function returns Try(ok, err)Ok or Err, handled by pattern matching. Note what the signature buys you: TypeScript's parseScore(text: string): number says nothing about the throw, and a caught problem is unknown. Roc's error type [BadScore(Str)] is right there in the signature, precisely typed, and the compiler will not let you ignore it.
The ? operator: early return on Err
function firstItem(items: number[]): number { const item = items[0]; if (item === undefined) { throw new Error("empty list"); } return item; } console.log(`first: ${firstItem([5, 6, 7])}`);
show_first! = |numbers| { first = numbers.first()? echo!("first: ${first.to_str()}") Ok({}) } main! = |_args| { numbers : List(I64) numbers = [5, 6, 7] show_first!(numbers) }
The postfix ? unwraps an Ok or early-returns the Err to the caller — exception-style propagation, but visible at every call site and tracked in the type. Here main! simply passes show_first!'s Try along as its own result. It is the ergonomic middle ground TypeScript lacks between "throw and lose the type" and "thread result objects by hand."
Error types compose automatically
// TypeScript error unions must be maintained by hand: type ConfigError = | { kind: "missingName" } | { kind: "badPort"; text: string }; function readPort(text: string): number | ConfigError { const port = Number.parseInt(text, 10); return Number.isNaN(port) ? { kind: "badPort", text } : port; } console.log(readPort("8080")); console.log(readPort("eighty"));
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| { echo!(Str.inspect(read_port("8080"))) echo!(Str.inspect(read_port("eighty"))) Ok({}) }
Because Roc error tags are structural and unions are open, errors from different functions merge automatically: a function calling two fallible helpers with ? infers the union of both error sets, with no hand-maintained ConfigError type to update when a new failure mode appears. It is the composability TypeScript's discriminated-union error style always wanted.
crash for unreachable states
const configurationFound = false; if (!configurationFound) { throw new Error("no configuration — cannot continue"); }
main! = |_args| { configuration_found : Bool configuration_found = False if !configuration_found { crash "no configuration — cannot continue" } Ok({}) }
Roc's crash is the one non-Try escape: an unrecoverable abort for states that should be impossible, with the platform deciding what happens next. Nothing can catch it — there is no catch — so it is reserved for genuine bugs, never expected failures. This row is display-only because the WASM host powering this page stops its instance on a crash (and the TypeScript side exits nonzero by throwing).
Functions & Closures
Arrow functions everywhere
function add(left: number, right: number): number { return left + right; } const alsoAdd = (left: number, right: number) => left + right; console.log(add(2, 3)); console.log(alsoAdd(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({}) }
Roc has exactly one function syntax — |args| body, pipes instead of the arrow — and a top-level function is just a named value bound to a lambda, the way const alsoAdd = ... is. There is no function keyword, no hoisting, no this, and no arrow-vs-function distinction to remember, because there is no this to bind in the first place.
Closures
const amount = 10; const addAmount = (number: number) => number + amount; console.log(addAmount(5)); const greeting = "hi "; const greet = (name: string) => `${greeting}${name}`; console.log(greet("Roc"));
main! = |_args| { amount : I64 amount = 10 add_amount = |number| number + amount echo!(add_amount(5).to_str()) greeting = "hi " greet = |name| "${greeting}${name}" echo!(greet("Roc")) Ok({}) }
Closures capture their environment in both languages, and the code reads nearly the same. The semantic difference is subtle but important: a TypeScript closure captures mutable bindings — the classic loop-variable bug — while a Roc closure captures immutable values, so what a closure sees can never change behind its back.
Generics without angle brackets
function identity<Value>(value: Value): Value { return 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({}) }
Lowercase names in a Roc type annotation are type variables — a -> a is <Value>(value: Value) => Value with no declaration site needed. Unlike TypeScript generics, which are erased and enforced only at compile time, Roc monomorphizes: each concrete use compiles to its own specialized machine code.
Constraints: extends becomes where
function firstToString<Item extends { toString(): string }>( items: Item[], ): string { return items.length > 0 ? items[0].toString() : "(empty)"; } console.log(firstToString([42, 7])); console.log(firstToString<number>([]));
first_to_str : List(a) -> Str where [a.to_str : a -> Str] first_to_str = |items| match items.first() { Ok(item) => item.to_str() Err(_) => "(empty)" } main! = |_args| { numbers : List(I64) numbers = [42, 7] echo!(first_to_str(numbers)) empty : List(I64) empty = [] echo!(first_to_str(empty)) Ok({}) }
Roc's where clause constrains a type variable by the methods it must provide — where [a.to_str : a -> Str] reads exactly like TypeScript's extends { toString(): string }: structural, no interface to implement. The difference is dispatch: TypeScript resolves the call through the object at runtime; Roc resolves it statically and generates specialized code.
Control Flow
if is an expression — goodbye nested ternaries
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({}) }
TypeScript needs the ternary operator because its if is a statement; chain two and readability dies. Roc's if is an expression, so else if chains produce a value directly — the ternary's job with if's syntax. Both branches must have the same type, and the condition must be an actual Bool: no truthiness, no if (items.length) idiom.
while loops and break
let count = 0; while (count < 5) { 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({}) }
Yes, the pure functional language has real while loops with break — mutation through var is local to the function, so it cannot leak or be shared, and the compiler is free to allow honest imperative iteration. You do not have to rewrite every loop as a fold to write Roc.
Iterating a collection
const words = ["alpha", "beta", "gamma"]; for (const word of words) { console.log(word); }
main! = |_args| { words = ["alpha", "beta", "gamma"] words.for_each!(|word| echo!(word)) Ok({}) }
The langref specifies for word in words { } loops, but the pinned nightly build this page runs on rejects them (the iterator protocol is still being wired up) — so the idiomatic working form today is .for_each!(...), whose ! marks that its closure performs effects, like forEach with an honest signature. Expect for to light up in a future nightly.
Early return
function clampPositive(number: number): 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({}) }
Roc has a genuine return statement for early exits — rare among functional languages — but the happy path needs no keyword: the final expression of the function body is its value. The familiar "guard clause returns early, main logic flows to the bottom" style translates unchanged.
Purity & Effects
Function coloring — for every effect
// async/await colors functions by *asynchrony* only. // Nothing marks that this "pure-looking" helper does I/O: function quietLookingHelper(name: string): string { console.log("(surprise: I/O happened here)"); return `hello ${name}`; } console.log(quietLookingHelper("TypeScript"));
# Pure: Str -> Str (arrow ->) describe : Str -> Str describe = |name| "hello ${name}" # Effectful: Str => {} (fat arrow =>, name ends in !) announce! : Str => {} announce! = |name| { echo!(describe(name)) } main! = |_args| { announce!("Roc") Ok({}) }
TypeScript developers already live with function coloring: async functions are marked, awaited, and infectious up the call stack. Roc applies the same discipline to all side effects — an effectful function carries a ! suffix and a => arrow in its type, and a pure function (->) cannot call one. Unlike async, the compiler enforces the color: no function can secretly log, fetch, or write.
Top-level values run at compile time
// Top-level code just runs, in import order, // with side effects welcome: const limit = 10; const squared = limit * limit; console.log(squared);
limit : I64 limit = 10 squared : I64 squared = limit * limit main! = |_args| { echo!(squared.to_str()) Ok({}) }
A TypeScript module's top level is a script: it executes at import time, side effects and all — the source of many an initialization-order bug. A Roc module's top level is pure by construction, so the compiler evaluates every top-level value at compile time: squared is baked into the binary as 100, and a top-level expression that would crash becomes a compile error instead.
Assertions: expect
import assert from "node:assert"; const total = 2 + 2; assert.strictEqual(total, 4); console.log("after the assertion");
main! = |_args| { total : I64 total = 2 + 2 expect total == 4 echo!("after the expect") Ok({}) }
An inline expect checks a condition mid-program, like node:assert; if it fails, the program halts with a nonzero exit code. The same keyword doubles as Roc's unit-testing construct: top-level expect blocks are collected and run by roc test — the built-in answer to reaching for Vitest or Jest.
Memory: No Garbage Collector
No GC pauses — compile-time reference counting
// V8's garbage collector finds unreachable values // at runtime, pausing execution to collect them: const shared = [1, 2, 3]; const another = shared; // same heap object console.log(shared, another);
main! = |_args| { shared : List(I64) shared = [1, 2, 3] another = shared echo!(Str.inspect(shared)) echo!(Str.inspect(another)) Ok({}) }
Roc has no garbage collector: the compiler inserts reference-count increments and decrements at compile time (the Perceus technique), so memory is freed the instant the last reference disappears — deterministically, with no collection pauses and no GC tuning. Because Roc statically rules out reference cycles, the classic weakness of reference counting cannot be expressed.
No shared mutable references
const original = { name: "Ada", scores: [1, 2] }; const copy = original; copy.scores.push(3); // Surprise: both changed — it was the same object. console.log(original.scores);
main! = |_args| { original = { name: "Ada", scores: [1, 2] } copy = original # No push, no property assignment — updates make new values: updated = { ..copy, scores: copy.scores.append(3) } echo!(Str.inspect(original.scores)) echo!(Str.inspect(updated.scores)) Ok({}) }
The aliasing bug on the left — mutating through one name and surprising every other holder of the reference — is impossible in Roc, because values cannot be mutated through references at all. Updates like append and record spread produce new values; whether memory is actually copied is the compiler's problem (it reuses the allocation when the value is unshared), not a correctness question.
Immutable APIs, mutable performance
const numbers = [1, 2, 3]; // The immutable idiom allocates a new array every time: const updated = numbers.map( (number, index) => (index === 1 ? 99 : number), ); console.log(updated);
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3] updated = numbers.set(1, 99) ?? numbers echo!(Str.inspect(updated)) Ok({}) }
In JavaScript, immutable-style updates (map, toSpliced, spread) always pay for a fresh allocation — the style has a runtime cost. Roc's set looks like it copies the list, but the compiler mutates in place whenever the reference count is 1, producing the machine code of the mutable version with the semantics of the immutable one. (set returns a Try because the index might be out of bounds, so ?? supplies the fallback.)
Gotchas for TypeScript Programmers
Untyped integers print as decimals
const numbers = [1, 2, 3]; console.log(numbers); // [ 1, 2, 3 ]
main! = |_args| { echo!(Str.inspect([1, 2, 3])) numbers : List(I64) numbers = [1, 2, 3] echo!(Str.inspect(numbers)) Ok({}) }
The number-one surprise: Roc's unconstrained numeric literals default to Dec, so the first line prints [1.0, 2.0, 3.0]. JavaScript's single number type at least prints integers as integers; Roc's decimal default changes the output. The habit to build: annotate any binding whose value you intend to display as an integer.
Interpolation will not auto-convert
const count = 5; // toString() is invoked automatically: console.log(`count is ${count}`);
main! = |_args| { count : I64 count = 5 # echo!("count is ${count}") # ^ TYPE MISMATCH: interpolation takes Str, found I64 echo!("count is ${count.to_str()}") Ok({}) }
Template literals quietly call toString() on anything — objects included, hence [object Object]. Roc's interpolation accepts only Str, so every number needs its .to_str(). It feels noisy for a day and then becomes automatic — and [object Object] can never happen.
Bare True is not Bool
const ready = false; // unambiguously boolean console.log(!ready);
main! = |_args| { ready : Bool ready = False echo!(Str.inspect(!ready)) Ok({}) }
Because tags are structural, a bare False is just the tag False in an anonymous union — not necessarily a Bool — so operators like ! may fail to resolve without context. Annotate ready : Bool (or write Bool.False) and everything works. The same structural-tags superpower that removes discriminant boilerplate creates this one sharp edge.
One ==, and it never coerces
const left = [1, 2]; const right = [1, 2]; // Reference equality: same contents, still false. console.log(left === right); // The everyday workaround: console.log(JSON.stringify(left) === JSON.stringify(right));
main! = |_args| { echo!(Str.inspect(1.I64 == 1)) left : List(I64) left = [1, 2] right : List(I64) right = [1, 2] echo!(Str.inspect(left == right)) Ok({}) }
Roc has exactly one equality operator, and it does what === never quite could: == compares values, structurally, at every depth — two lists with equal elements are equal. There is no coercing double-equals to ban in the style guide, and no "reference equality for objects" asterisk. Comparing values of different types is a compile error, not false.
The stdlib is still settling
// The JavaScript string API has been stable for decades: const replaced = "roc rocks".replace("rocks", "flies"); console.log(replaced);
main! = |_args| { # No Str.replace in this build — compose what exists: echo!("roc rocks".drop_suffix("rocks").concat("flies")) Ok({}) }
Roc is pre-1.0 and this page pins a specific nightly compiler, so some familiar operations are missing or renamed: there is no Str.replace yet, string length is count_utf8_bytes(), list reversal is rev(), the pipe operator |> was removed in favor of method chains, and triple-quoted multiline strings are not implemented. Expect this table to improve as Roc approaches its first stable release.