PONYλM2Modula-2

Java.CodeCompared.To/Roc

An interactive executable cheatsheet comparing Java and Roc

Java 25 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 ! at the end of a name means "this performs effects", and the compiler enforces it.
class Main { public static void main(String[] args) { System.out.println("Hello, World!"); } }
main! = |_args| { echo!("Hello, World!") Ok({}) }
There is no class, because there are no classes — a top-level definition needs nothing wrapped around it. Ok({}) is the return value, where {} is the empty record, and _args has a leading underscore because the signature requires the parameter and this program ignores it.
Where the standard library comes from
A Roc application declares the platform it is compiled against, and the platform supplies the complete list of effects the program may perform. It is the relationship a Java program has with the JVM, made explicit and checked at compile time.
class Main { public static void main(String[] args) { // java.io, java.net, threads and the // collector all exist because the JVM // started before your code ran. System.out.println("the runtime came with the platform"); } }
main! = |_args| { # Nothing is ambient. This program was built # against a platform providing exactly echo!, # so echo! is the only effect it can perform. echo!("the runtime came with the platform") Ok({}) }
The difference is when the list closes. Any Java class can call Files.readString or open a socket, and whether that is permitted is a question for the container it eventually runs in; a Roc application compiled against a platform with no file access has no name for one.
How a program reports failure
main! returns a Try — Ok for success, Err for failure — and the platform turns that into whatever the operating system wants. The status is a value the compiler type-checks.
class Main { public static void main(String[] args) { System.out.println("all good"); // Or System.exit(1) from anywhere, which // skips every finally block on the way out. System.exit(0); } }
main! = |_args| { echo!("all good") # Ok is success, Err is failure; the platform # turns the return value into an exit status. Ok({}) }
Because it is a return value rather than a global action, there is no way to end the process from four frames down. System.exit does exactly that: it runs shutdown hooks but not finally blocks, which is why calling it from library code is considered rude.
Comments
Comments start with # and run to the end of the line. There is no block form and no documentation-comment syntax, so a multi-line comment is several # lines.
class Main { public static void main(String[] args) { // A single-line comment int count = 42; // an inline comment /* A block comment, which can span lines. */ /** A Javadoc comment. */ System.out.println(count); } }
main! = |_args| { # A single-line comment count : I64 count = 42 # an inline comment # Roc has no block comment and no documentation # comment — every comment line starts with its # own #. echo!(count.to_str()) Ok({}) }
The count : I64 line is a type annotation on its own line above the definition rather than before the name. Writing it is optional, but it pins down which number type this is — and as the Numbers section shows, that decides what gets printed.
No null, and No Optional Either
There is no null
Absence is spelled as a tag union — here Some(Str) or None — and the only way to reach the string inside is to handle both cases, because a match must cover every tag.
class Main { public static void main(String[] args) { // Every reference type has this extra // inhabitant, and nothing in the type says // whether this one is it. String name = null; System.out.println(name == null ? 0 : name.length()); } }
main! = |_args| { # There is no null literal to write and no type # that could hold one. Absence is a value you # construct on purpose. name : [Some(Str), None] name = None length = match name { Some(text) => text.count_utf8_bytes() None => 0 } echo!(length.to_str()) Ok({}) }
The sharp part is not that Java has null but that the type system cannot talk about it: String means "a string, or nothing", with no way to say which is meant. Every annotation scheme from @Nullable onward exists to add the distinction back, and none of them is enforced by the language.
Optional is a library type, and is itself nullable
The two-case tag union is built into the type system rather than into a class, so there is no wrapper object and no third state for the wrapper itself to be in.
import java.util.Optional; class Main { public static void main(String[] args) { Optional<String> found = Optional.of("hi"); System.out.println(found.map(String::length).orElse(0)); // Nothing stops this, and it is a real bug // found in real code. Optional<String> broken = null; System.out.println(broken == null ? "even the Optional was null" : ""); } }
main! = |_args| { found : [Some(Str), None] found = Some("hi") echo!(match found { Some(text) => text.count_utf8_bytes().to_str() None => "0" }) # There is no third state. A [Some(Str), None] # is one of exactly those two things. echo!("even the Optional was null") Ok({}) }
Optional was the right idea implemented with the only tool available — a class — and a class reference can be null. That is why it is documented as a return type only, why putting one in a field draws criticism, and why Optional.ofNullable has to exist at all.
A lookup that might find nothing
Dict.get returns a Try — Ok with the value or Err with a reason — and match forces both arms before the value can be used.
import java.util.Map; class Main { public static void main(String[] args) { Map<String, Integer> ages = Map.of("alice", 30); // get returns null for a missing key, which // is why getOrDefault and containsKey exist. Integer age = ages.get("bob"); System.out.println(age == null ? "bob is not listed" : "bob is " + age); } }
main! = |_args| { ages = Dict.empty().insert("alice", 30.I64) # get returns a Try, so there is one way to ask # and the answer carries its own failure case. match ages.get("bob") { Ok(age) => echo!("bob is ${age.to_str()}") Err(_) => echo!("bob is not listed") } Ok({}) }
Java's Map.get returns null for a missing key, which is indistinguishable from a key stored with a null value — the reason containsKey exists as a separate question. On an int-valued map the returned Integer also unboxes on use, so the missing key becomes a NullPointerException at the arithmetic rather than at the lookup.
null defeats an exhaustive switch
When the type lists two cases, two arms are the whole story. No extra value can arrive, so no extra arm is needed and none can be forgotten.
sealed interface Shape permits Circle, Square {} record Circle(double radius) implements Shape {} record Square(double side) implements Shape {} class Main { static String name(Shape shape) { // Exhaustive over the permitted types — // and null is none of them, so this throws // NullPointerException rather than matching. return switch (shape) { case Circle c -> "circle"; case Square s -> "square"; }; } public static void main(String[] args) { System.out.println(name(new Circle(1))); } }
Shape : [Circle(F64), Square(F64)] name : Shape -> Str name = |shape| match shape { Circle(_) => "circle" Square(_) => "square" } main! = |_args| { # Two cases is genuinely complete, because there # is no third value the type could hold. echo!(name(Circle(1.0))) Ok({}) }
This is the clearest example of the page's theme. Java 21's exhaustive switch over a sealed interface is genuinely good, and null walks straight through it — the compiler accepts the switch as complete while the runtime can still hand it something none of the cases describe.
Nothing is initialized to a default
A record literal must supply every field the type declares. There is no zero value, no default constructor and no moment when a value exists but is not yet filled in.
class Holder { int count; String label; } class Main { public static void main(String[] args) { Holder holder = new Holder(); // Fields you did not set: 0 and null, // silently, and both are valid values of // their types. System.out.println(holder.count); System.out.println(holder.label); } }
main! = |_args| { # Every field must be given a value. There is no # partially-built record and no default to fall # back on. holder = { count: 0.I64, label: "" } echo!(holder.count.to_str()) echo!(holder.label) Ok({}) }
Java's defaults are what make a half-constructed object reachable — a field set in one constructor but not another, or an object published from a constructor before it finishes. The label above prints null, which is a value the author never wrote anywhere.
Values, Not References
There is no object identity
== compares contents, for every type. Str.inspect turns the resulting Bool into text method.
class Main { public static void main(String[] args) { String first = new String("hi"); String second = new String("hi"); // Two questions with different answers: // are these the same object, and do they // hold the same characters? System.out.println(first == second); System.out.println(first.equals(second)); } }
main! = |_args| { first = "hi" second = "hi" # Only one question can be asked, and it is # about contents. echo!(Str.inspect(first == second)) Ok({}) }
Java has two comparisons because it has two things to compare, and choosing the wrong one is the most common beginner bug in the language and a recurring expert one. There is no identity here to ask about, so there is no wrong choice to make.
Two names never share one object
Every value is immutable, so a second name for one is only ever a second way to read it. append returns a new list rather than modifying one.
import java.util.ArrayList; import java.util.List; class Main { public static void main(String[] args) { List<Integer> numbers = new ArrayList<>(List.of(1, 2)); List<Integer> alias = numbers; alias.add(3); // numbers changed, because there was only // ever one list and two ways to reach it. System.out.println(numbers.size()); } }
main! = |_args| { numbers : List(I64) numbers = [1, 2] longer = numbers.append(3) # append returned a NEW list. numbers still has # its original two elements. echo!(numbers.len().to_str()) echo!(longer.len().to_str()) Ok({}) }
Java's behavior here is correct and is also the reason a getter that returns a collection leaks it, why Collections.unmodifiableList exists, and why the advice is to copy on the way in and on the way out. None of that applies to a value nobody can change.
final protects the field, not the object
Immutability reaches all the way down: a list inside a record inside another record cannot be changed by anyone, because nothing anywhere can change a value.
import java.util.ArrayList; import java.util.List; class Basket { // Cannot be reassigned; the list it points at // is wide open. final List<String> items = new ArrayList<>(); } class Main { public static void main(String[] args) { Basket basket = new Basket(); basket.items.add("apple"); System.out.println(basket.items.size()); } }
main! = |_args| { basket = { items: ["apple"] } # There is no add. Adding means building a new # list, and a new record to hold it. fuller = { ..basket, items: basket.items.append("pear") } echo!(fuller.items.len().to_str()) Ok({}) }
final protects only the binding, so a final List<String> is a field you cannot reassign holding a collection anyone can empty. It is the same shape as const in JavaScript and val in Kotlin, and it is what List.copyOf and the immutable collections exist to close, one type at a time.
Handing out a collection is safe
Reading a field out of a record hands over a value, not a handle. Whatever the caller does with it produces new values and leaves the record as it was.
import java.util.ArrayList; import java.util.List; class Report { private final List<Integer> scores; Report(List<Integer> scores) { this.scores = scores; } // Returns the object itself, so the caller can // change the report's own list. List<Integer> scores() { return scores; } } class Main { public static void main(String[] args) { Report report = new Report(new ArrayList<>(List.of(1, 2, 3))); report.scores().add(4); System.out.println(report.scores().size()); } }
main! = |_args| { report = { scores: [1, 2, 3] } scores = report.scores longer = scores.append(4) # longer is a new list; the report still has its # own three scores. echo!(report.scores.len().to_str()) Ok({}) }
The Java class above is written the way most are — private final field, a plain accessor — and it still leaks. The fix is List.copyOf(scores) in the constructor, which the author has to remember to write. The copy is immutable, so the accessor can return it as it is, and List.copyOf of a list that is already immutable returns that same list rather than copying it again.
Records vs Records
Declaring and building a record
A record literal is braces with field: value pairs, and fields are read with a dot rather than an accessor call. The type line above is an alias for the shape.
record Person(String name, int age) {} class Main { public static void main(String[] args) { Person alice = new Person("Alice", 30); System.out.println(alice.name() + " is " + alice.age()); } }
Person : { name : Str, age : I64 } main! = |_args| { alice : Person alice = { name: "Alice", age: 30 } echo!("${alice.name} is ${alice.age.to_str()}") Ok({}) }
Java's record generates the constructor, the accessors, equals, hashCode and toString. Roc generates none of them because it needs none: a record is structural, so equality and printing are defined on the shape rather than on a declaration.
Records are structural, not nominal
A Roc record type is its set of fields, so a literal with the right fields already is one. There is no constructor call and no type name at the use site.
record Point(int x, int y) {} record Coordinate(int x, int y) {} class Main { static String describe(Point point) { return "(" + point.x() + ", " + point.y() + ")"; } public static void main(String[] args) { System.out.println(describe(new Point(1, 2))); // describe(new Coordinate(1, 2)) does not // compile: same shape, different type. } }
Point : { x : I64, y : I64 } describe : Point -> Str describe = |point| "(${point.x.to_str()}, ${point.y.to_str()})" main! = |_args| { # Any record with these two fields IS a Point. # Nothing is constructed and no name is written. echo!(describe({ x: 1, y: 2 })) Ok({}) }
Structural typing cuts both ways, and Java's nominal records give something real in exchange: a Coordinate cannot be passed where a Point is wanted, even though they are the same shape. Roc's answer when you want that distinction is :=, which the Gotchas section shows.
Changing one field
{ ..alice, age: 31 } copies every field of alice and overrides the ones named after it, so changing one field mentions one field.
record Person(String name, int age) {} class Main { public static void main(String[] args) { Person alice = new Person("Alice", 30); // No with-expression: every field is named // again, and adding a field breaks this. Person older = new Person(alice.name(), 31); System.out.println(alice.age() + " then " + older.age()); } }
main! = |_args| { alice = { name: "Alice", age: 30.I64 } older = { ..alice, age: 31 } echo!("${alice.age.to_str()} then ${older.age.to_str()}") Ok({}) }
Java has no equivalent, which is why records with more than a few components collect hand-written withAge methods or a builder. Adding a component to the record then means visiting every place that rebuilt one — and since the constructor takes positional arguments, two components of the same type can be swapped without a compile error.
A record is immutable only at the surface
A record built from immutable values is immutable all the way through, because every value it holds is one. Producing a changed version means producing a new record.
import java.util.ArrayList; import java.util.List; record Team(String name, List<String> members) {} class Main { public static void main(String[] args) { Team team = new Team("core", new ArrayList<>(List.of("ada"))); // The record is final in every field, and // the list inside it is not. team.members().add("grace"); System.out.println(team.members().size()); } }
main! = |_args| { team = { name: "core", members: ["ada"] } # Nothing reachable from this record can be # changed, at any depth. bigger = { ..team, members: team.members.append("grace") } echo!(bigger.members.len().to_str()) Ok({}) }
Both columns print 2, and how they got there is the point: Java changed the list the record was holding, and Roc built a new list and a new record around it. A Java record guarantees its own fields are final and nothing about what they point at, so record Team(String, List<String>) is an immutable holder for a mutable list. The documentation is explicit that a record is shallowly immutable; the word "shallowly" carries the whole warning.
Sealed Interfaces vs Tag Unions
A sealed hierarchy becomes one line
A tag union lists its cases in one line, each with its own payload. Circle(2.0) needs no declaration beyond that line — writing a tag creates it.
sealed interface Shape permits Circle, Rect {} record Circle(double radius) implements Shape {} record Rect(double width, double height) implements Shape {} class Main { static double area(Shape shape) { return switch (shape) { case Circle c -> 3.14159 * c.radius() * c.radius(); case Rect r -> r.width() * r.height(); }; } public static void main(String[] args) { System.out.println(area(new Rect(3, 4))); } }
Shape : [Circle(F64), Rect(F64, F64)] area : Shape -> F64 area = |shape| match shape { Circle(radius) => 3.14159 * radius * radius Rect(width, height) => width * height } main! = |_args| { echo!(area(Rect(3.0, 4.0)).to_str()) Ok({}) }
This is the closest correspondence on the page: Java 17's sealed interfaces plus Java 21's pattern switch are this idea, arrived at from the object-oriented side. The cost is three declarations, three files in most codebases, and a permits clause that has to be kept in step. The two columns print 12.0 and 12 for the same number, which is worth noting once: Roc's F64 drops a trailing zero where Java's double keeps it.
A tag needs no declaration
Tags are structural: writing Fine creates one, and the type of describe is inferred as "something that is Fine or Busy". Nothing is declared and nothing is imported.
enum Status { OK, BUSY } class Main { static String describe(Status status) { return switch (status) { case OK -> "ok"; case BUSY -> "busy"; }; } public static void main(String[] args) { System.out.println(describe(Status.OK)); } }
# No declaration anywhere. Writing Fine or Busy # creates the tag, and the parameter's type is # inferred from the match below. describe = |status| match status { Fine => "ok" Busy => "busy" } main! = |_args| { echo!(describe(Fine)) Ok({}) }
The case is spelled Fine rather than Ok only to keep it clear of the Ok that Try uses. Nothing stops the reuse — tags are not registered anywhere — but a reader of this page would have to look twice.
A union that stays open
The .. in [Warning, ..] leaves the union open: this function handles Warning by name and anything else through the wildcard, and a caller may pass a tag the function has never heard of.
enum Level { WARNING, ERROR } class Main { static String describe(Level level) { // The set is fixed at the declaration. A // caller cannot add a case, and a sealed // interface only moves where it is fixed. return switch (level) { case WARNING -> "warning"; default -> "other"; }; } public static void main(String[] args) { System.out.println(describe(Level.WARNING)); } }
# The type says "at least Warning, possibly more", # and _ catches whatever a caller passes in. describe : [Warning, ..] -> Str describe = |level| match level { Warning => "warning" _ => "other" } main! = |_args| { echo!(describe(Warning)) echo!(describe(Fatal)) Ok({}) }
Java has no way to write that type. An enum is closed, a sealed interface is closed by its permits clause, and an unsealed interface is open but requires every case to be a declared class implementing it. Choosing openness per function, at the use site, has no Java counterpart.
No inheritance, and nothing virtual
There is no base type, no extends and no @Override. Dispatch is a match on a value whose cases the compiler knows completely, so it can check that every one is handled.
class Animal { String speak() { return "..."; } } class Dog extends Animal { @Override String speak() { return "Woof"; } } class Main { public static void main(String[] args) { Animal pet = new Dog(); // Which speak runs is decided at run time // by the object's actual class. System.out.println(pet.speak()); } }
Animal : [Dog, Cat] speak : Animal -> Str speak = |animal| match animal { Dog => "Woof" Cat => "Meow" } main! = |_args| { # Which branch runs is decided by the value, and # the compiler can see every branch there is. echo!(speak(Dog)) Ok({}) }
The two designs are open in opposite directions. Java makes it easy to add an animal (write a class) and hard to add an operation (edit every class); the union makes it easy to add an operation (write a function) and hard to add an animal (edit every match). Neither is better, and knowing which way your code grows is how you choose.
A recursive type
A type that refers to itself must be declared with := rather than : — a recursive alias is a compile error. Constructing a case qualifies it with the type name, as in Tree.Node(…), while matching does not.
sealed interface Tree permits Leaf, Node {} record Leaf(int value) implements Tree {} record Node(Tree left, Tree right) implements Tree {} class Main { static int total(Tree tree) { return switch (tree) { case Leaf leaf -> leaf.value(); case Node node -> total(node.left()) + total(node.right()); }; } public static void main(String[] args) { Tree tree = new Node(new Leaf(1), new Node(new Leaf(2), new Leaf(3))); System.out.println(total(tree)); } }
Tree := [Leaf(I64), Node(Tree, Tree)] total : Tree -> I64 total = |tree| match tree { Leaf(value) => value Node(left, right) => total(left) + total(right) } main! = |_args| { tree = Tree.Node(Tree.Leaf(1), Tree.Node(Tree.Leaf(2), Tree.Leaf(3))) echo!(total(tree).to_str()) Ok({}) }
The asymmetry between building and matching is the part to remember: Tree.Node(…) to construct, bare Node(left, right) in the match. The Java column needs a sealed interface and two records to say what one line says here.
switch Patterns vs match
switch expression vs match
match is an expression, like Java's arrow-form switch: each arm is pattern => value, and the arms are separated by newlines rather than semicolons.
class Main { static String name(int code) { return switch (code) { case 200 -> "OK"; case 404 -> "Not Found"; default -> "Unknown"; }; } public static void main(String[] args) { System.out.println(name(404)); } }
name : I64 -> Str name = |code| match code { 200 => "OK" 404 => "Not Found" _ => "Unknown" } main! = |_args| { echo!(name(404)) Ok({}) }
The shapes line up almost exactly, which is a fair measure of how much Java 14's switch expression took from this tradition — including the part that matters most, that arms do not fall through into one another.
Matching inside a structure
A record pattern matches field by field, and _ stands for a field this arm does not care about. Every arm must name the same fields — a pattern mentioning only x describes a different, open record type.
record Point(int x, int y) {} class Main { static String describe(Point point) { return switch (point) { case Point(int x, int y) when x == 0 && y == 0 -> "origin"; case Point(int x, int y) when x == 0 -> "on the y axis"; case Point(int x, int y) when y == 0 -> "on the x axis"; default -> "somewhere else"; }; } public static void main(String[] args) { System.out.println(describe(new Point(0, 5))); } }
describe : { x : I64, y : I64 } -> Str describe = |point| match point { { x: 0, y: 0 } => "origin" { x: 0, y: _ } => "on the y axis" { x: _, y: 0 } => "on the x axis" _ => "somewhere else" } main! = |_args| { echo!(describe({ x: 0, y: 5 })) Ok({}) }
Java's record pattern is positional, so it binds both components and then tests them in a when clause. Matching on the field name means the Roc version says what it wants directly, and it also means renaming or reordering the record's fields is a compile error at each site rather than a silent change in what gets bound.
Guards on an arm
An arm may carry an if guard: the pattern binds a name first, then the guard decides whether this arm runs. Roc writes if where Java writes when.
class Main { // Boxed, because a primitive type pattern is // still a preview feature. static String classify(Integer number) { return switch (number) { case Integer n when n == 0 -> "zero"; case Integer n when n > 0 -> "positive"; default -> "negative"; }; } public static void main(String[] args) { System.out.println(classify(0)); System.out.println(classify(42)); } }
classify : I64 -> Str classify = |number| match number { n if n == 0 => "zero" n if n > 0 => "positive" _ => "negative" } main! = |_args| { echo!(classify(0)) echo!(classify(42)) Ok({}) }
A guarded arm is invisible to the exhaustiveness check in both languages — neither compiler can know whether n > 0 and its absence cover everything — so both still need a final catch-all. The Java column takes an Integer rather than an int because matching a pattern against a primitive is a preview feature, which is the boxing tax arriving in yet another place.
No instanceof, and nothing to cast
There is no Object to accept anything, no instanceof and no cast, because a value's type is never in doubt. The parameter type says it is one of exactly two shapes.
class Main { static String describe(Object value) { return switch (value) { case Integer number -> "a number: " + number; case String text -> "a string: " + text; default -> "something else"; }; } public static void main(String[] args) { System.out.println(describe(42)); System.out.println(describe("hi")); } }
describe : [Number(I64), Text(Str)] -> Str describe = |value| match value { Number(number) => "a number: ${number.to_str()}" Text(text) => "a string: ${text}" } main! = |_args| { echo!(describe(Number(42))) echo!(describe(Text("hi"))) Ok({}) }
The Roc version needs no default arm, because its type says there are two cases. The Java version needs a third for everything it was not designed for, and that arm is where a mistake lands silently — describe(2.5) compiles and returns "something else".
Checked Exceptions vs Try
Failure in the signature — the idea Java had first
Try(I64, [BadNumStr, ..]) is a return type with two halves: the value on success, and a tag union of what can go wrong. Failure is data the function returns rather than an event that leaves it.
class Main { static class BadNumber extends Exception {} // Java is the one mainstream language that put // failure in the signature. throws is checked: // a caller must catch it or declare it too. static int parse(String text) throws BadNumber { try { return Integer.parseInt(text); } catch (NumberFormatException error) { throw new BadNumber(); } } public static void main(String[] args) { try { System.out.println(parse("17")); } catch (BadNumber error) { System.out.println("not a number"); } } }
# Same idea, in the return type rather than beside # it — so it is a value, not a second channel. parse : Str -> Try(I64, [BadNumStr, ..]) parse = |text| I64.from_str(text) main! = |_args| { match parse("17") { Ok(number) => echo!(number.to_str()) Err(_) => echo!("not a number") } Ok({}) }
Java and Roc agree on the principle and almost nobody else does — C#, Kotlin, Scala and C++ all declined checked exceptions on purpose. The next four rows are about the specific things that went wrong in Java's version, because that is where the comparison is actually useful.
The error type is inferred, not declared at every level
A function that passes a failure along does not have to mention it. The error type is inferred from the body and flows outward, so intermediate layers stay silent about errors they only forward.
class Main { static class BadNumber extends Exception {} static int first(String text) throws BadNumber { try { return Integer.parseInt(text); } catch (NumberFormatException error) { throw new BadNumber(); } } // Every level in between has to declare it // again, which is the friction that makes // people widen it to Exception. static int second(String text) throws BadNumber { return first(text); } public static void main(String[] args) { try { System.out.println(second("17")); } catch (BadNumber error) { System.out.println("not a number"); } } }
first = |text| I64.from_str(text) # No annotation here at all. The error type is # inferred and travels on its own. second = |text| first(text) main! = |_args| { match second("17") { Ok(number) => echo!(number.to_str()) Err(_) => echo!("not a number") } Ok({}) }
This is the single biggest difference from checked exceptions in practice. Java makes every layer restate a checked failure, so a change deep down edits a dozen signatures — and the pressure that creates is exactly why throws Exception and empty catch blocks became common enough to discredit the feature.
Propagating a failure
A ? after an expression unwraps the Ok and returns early on Err. The happy path reads straight down, and propagation costs one character per step.
class Main { static int sum(String first, String second) { try { return Integer.parseInt(first) + Integer.parseInt(second); } catch (NumberFormatException error) { return -1; } } public static void main(String[] args) { System.out.println(sum("3", "4")); } }
sum : Str, Str -> Try(I64, [BadNumStr, ..]) sum = |first_text, second_text| { first = I64.from_str(first_text)? second = I64.from_str(second_text)? Ok(first + second) } main! = |_args| { match sum("3", "4") { Ok(total) => echo!(total.to_str()) Err(_) => echo!("bad input") } Ok({}) }
Java propagates by doing nothing, which is convenient, and the convenience is why a failure can travel a long way from where it happened before anyone handles it. The -1 above is the other common outcome: a sentinel chosen because returning something was easier than declaring the failure.
An error is a tag, not a class hierarchy
An error case is a tag with a payload — Insufficient(I64) — written in the return type and nowhere else. There is no class to declare, no base type to extend and no constructor.
class WithdrawalException extends Exception { final int shortfall; WithdrawalException(int shortfall) { super("short by " + shortfall); this.shortfall = shortfall; } } class Main { static int withdraw(int balance, int amount) throws WithdrawalException { if (amount > balance) throw new WithdrawalException(amount - balance); return balance - amount; } public static void main(String[] args) { try { System.out.println(withdraw(100, 150)); } catch (WithdrawalException error) { System.out.println("short by " + error.shortfall); } } }
withdraw : I64, I64 -> Try(I64, [Insufficient(I64), ..]) withdraw = |balance, amount| if amount > balance { Err(Insufficient(amount - balance)) } else { Ok(balance - amount) } main! = |_args| { match withdraw(100, 150) { Ok(remaining) => echo!(remaining.to_str()) Err(Insufficient(shortfall)) => echo!("short by ${shortfall.to_str()}") } Ok({}) }
Because errors are a hierarchy in Java, catching is subtype matching: catch (Exception e) catches every Exception, including the NullPointerException that means your code is broken. Matching on a tag union catches exactly the cases the type lists, and adding a case breaks every match that now needs updating.
No finally, because nothing unwinds
Roc has no finally and no try-with-resources. No exception unwinds through a function, so the only way out of a block is the path written in it, and whatever a value held is released by the compiler on every path.
class Main { public static void main(String[] args) { try { System.out.println("working"); } finally { // Runs however the block is left, which // is only necessary because there are // invisible ways to leave it. System.out.println("cleaning up"); } } }
main! = |_args| { echo!("working") # Nothing can leave a block except the path # written in it, so there is no "however it was # left" for a finally to cover. echo!("cleaning up") Ok({}) }
finally is insurance against an invisible exit, and it is its own hazard: a return inside one silently discards the exception that was propagating, and the compiler allows it. Removing invisible exits removes both the need and the hazard.
Streams vs List Functions
map and filter
keep_if is filter and map is map, chained with the same dot syntax. Each returns a new list immediately, so there is no stream() to open and no collect to close.
import java.util.List; import java.util.stream.Collectors; class Main { public static void main(String[] args) { List<Integer> numbers = List.of(1, 2, 3, 4, 5); String result = numbers.stream() .filter(n -> n % 2 == 1) .map(n -> n * 10) .map(String::valueOf) .collect(Collectors.joining(", ")); System.out.println(result); } }
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3, 4, 5] result = numbers .keep_if(|number| number % 2 == 1) .map(|number| number * 10) .map(|number| number.to_str()) echo!(Str.join_with(result, ", ")) Ok({}) }
The bookends are the difference in daily use: a Java collection is not a stream, so every pipeline starts by converting and ends by collecting, and forgetting the terminal operation means nothing runs at all.
Eager, not lazy
Roc's list functions run where they are written and return a finished list. There is no pipeline object, no deferred execution and no second evaluation.
import java.util.ArrayList; import java.util.List; import java.util.stream.Stream; class Main { public static void main(String[] args) { List<Integer> numbers = new ArrayList<>(List.of(1, 2, 3)); // Nothing has run yet. Stream<Integer> query = numbers.stream().filter(n -> n > 1); numbers.add(4); // Running it NOW sees the 4 added after the // stream was built. System.out.println(query.count()); } }
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3] # kept is a list, computed here and finished. kept = numbers.keep_if(|number| number > 1) longer = numbers.append(4) echo!(kept.len().to_str()) echo!(longer.len().to_str()) Ok({}) }
Laziness is what lets filter(…).findFirst() stop early on a huge source, and it is also why the Java column prints 3 rather than 2. A stream is single-use as well, so consuming one twice throws IllegalStateException rather than recomputing.
reduce and sum
fold takes a starting value and a function of the running total and the next element — the same two arguments reduce takes, in the same order.
import java.util.List; class Main { public static void main(String[] args) { List<Integer> numbers = List.of(1, 2, 3, 4); System.out.println(numbers.stream().mapToInt(Integer::intValue).sum()); System.out.println(numbers.stream().reduce(1, (a, b) -> a * b)); } }
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3, 4] echo!(numbers.sum().to_str()) echo!(numbers.fold(1, |running, n| running * n).to_str()) Ok({}) }
The mapToInt in the Java column is the boxing tax: Stream<Integer> has no sum, because summing boxed objects is not something the API wants to encourage, so there is a parallel family of primitive streams to drop into.
Finding the first match
find_first returns a Try: Ok with the element or Err when the predicate matched nothing. One function, and the answer says which happened.
import java.util.List; class Main { public static void main(String[] args) { List<Integer> numbers = List.of(1, 3, 8, 5); System.out.println(numbers.stream() .filter(n -> n % 2 == 0) .findFirst() .map(String::valueOf) .orElse("nothing matched")); } }
main! = |_args| { numbers : List(I64) numbers = [1, 3, 8, 5] match numbers.find_first(|number| number % 2 == 0) { Ok(value) => echo!(value.to_str()) Err(_) => echo!("nothing matched") } Ok({}) }
Java's findFirst returns an Optional, which is the right answer, and then Optional is a class whose reference can be null — so the improvement stops one level short of the guarantee. The tag union has no level to stop short of.
Collections
Building and reading a list
A list literal is square brackets, len is the count, and get reads by index — returning a Try, which the ? unwraps while returning early if the index was out of range.
import java.util.List; class Main { public static void main(String[] args) { List<Integer> numbers = List.of(10, 20, 30); System.out.println(numbers.size()); System.out.println(numbers.get(1)); System.out.println(numbers.contains(30)); } }
main! = |_args| { numbers : List(I64) numbers = [10, 20, 30] echo!(numbers.len().to_str()) # get returns a Try, because an index can miss. echo!(numbers.get(1)?.to_str()) echo!(Str.inspect(numbers.contains(30))) Ok({}) }
List.get in Java throws IndexOutOfBoundsException, and nothing in its signature says so, because unchecked exceptions are exempt from the throws discipline. That exemption is why "failure in the signature" describes only part of Java even where it applies. The last line differs only in spelling — Java prints true and Str.inspect prints the tag's own name, True.
Adding returns a new list
append returns a list one element longer and leaves the original alone. Every list function works this way, so a list is never modified after it is built.
import java.util.ArrayList; import java.util.List; class Main { public static void main(String[] args) { List<Integer> numbers = new ArrayList<>(List.of(1, 2)); numbers.add(3); // add mutated the list and returned a // boolean nobody reads. System.out.println(numbers.size()); } }
main! = |_args| { numbers : List(I64) numbers = [1, 2] longer = numbers.append(3) # append returned a new list; numbers still has # its original two elements. echo!(longer.len().to_str()) echo!(numbers.len().to_str()) Ok({}) }
The obvious worry is a copy on every append, and usually there is not one: the compiler tracks how many references a list has and reuses the memory when the old one is about to go out of scope. The Runtime section shows that directly.
Map
A dictionary is built by starting from Dict.empty() and chaining insert, each of which returns a new dictionary. The .I64 suffix on the first value fixes the type of every value in it.
import java.util.Map; class Main { public static void main(String[] args) { Map<String, Integer> ages = Map.of("alice", 30, "bob", 25); System.out.println(ages.size()); System.out.println(ages.get("alice")); } }
main! = |_args| { ages = Dict.empty() .insert("alice", 30.I64) .insert("bob", 25) echo!(ages.len().to_str()) echo!(ages.get("alice")?.to_str()) Ok({}) }
Without that suffix the values would be Dec, Roc's decimal type, and 30 would print as 30.0 — the single most common surprise on this page, which the Gotchas section returns to.
No covariant arrays
A List(Str) is a list of strings and is not a list of anything else. There is no supertype it converts to, so a write into it cannot be of the wrong type.
class Main { public static void main(String[] args) { String[] names = {"ada"}; // Legal: String[] IS an Object[] in Java. Object[] objects = names; try { // And this is where it is found out. objects[0] = 42; } catch (ArrayStoreException error) { System.out.println("ArrayStoreException"); } } }
main! = |_args| { names : List(Str) names = ["ada"] # There is no supertype to assign this to, so # there is no store to check at run time. echo!("ArrayStoreException") Ok({}) }
Java's arrays are covariant — String[] is assignable to Object[] — which makes the type system unsound, so every array write carries a run-time check to catch it. Generics were deliberately made invariant to avoid repeating this, which is why List<String> is not a List<Object>.
Generics & Erasure
A function over any type
A lowercase name in a type signature is a type variable: a in List(a), a -> a stands for one type used consistently. There is no <T> to declare first.
import java.util.List; class Main { static <T> T firstOr(List<T> items, T fallback) { return items.isEmpty() ? fallback : items.get(0); } public static void main(String[] args) { System.out.println(firstOr(List.of(), 7)); System.out.println(firstOr(List.of(), "none")); } }
first_or : List(a), a -> a first_or = |items, fallback| match items.first() { Ok(value) => value Err(_) => fallback } main! = |_args| { empty_numbers : List(I64) empty_numbers = [] empty_words : List(Str) empty_words = [] echo!(first_or(empty_numbers, 7).to_str()) echo!(first_or(empty_words, "none")) Ok({}) }
The declaration site is the only cosmetic difference; the real one is at run time, and the next row is about that.
Erasure, and what it costs
Roc has no run-time type information at all: no class objects, no reflection and nothing to interrogate. A type variable is resolved while compiling and leaves nothing behind.
import java.util.ArrayList; import java.util.List; class Main { public static void main(String[] args) { List<String> words = new ArrayList<>(); List<Integer> numbers = new ArrayList<>(); // One class at run time: the type argument // is gone by then. System.out.println(words.getClass() == numbers.getClass()); } }
main! = |_args| { # There is no class, no getClass and no way to # ask a value what its type is — the question # belongs to the compiler, which has answered # it already. words : List(Str) words = [] numbers : List(I64) numbers = [] echo!("${words.len().to_str()}${numbers.len().to_str()}") Ok({}) }
That sounds like erasure and is the opposite of its problem. Java erases so that generic code compiles to one class, which is why new T[] is impossible, why an unchecked cast is only a warning, and why List<String> and List<Integer> cannot be overloads. Roc compiles a specialized version per type instead, so nothing is lost and nothing is left to query.
Constraining a type parameter
A where clause constrains a type variable by naming the methods it must have. > desugars to is_gt, so requiring that one method is what lets the body compare — and the error you get without it names the missing method by name.
import java.util.List; class Main { static <T extends Comparable<T>> T largest(List<T> items, T seed) { T best = seed; for (T item : items) if (item.compareTo(best) > 0) best = item; return best; } public static void main(String[] args) { System.out.println(largest(List.of(3, 9, 4), 0)); } }
# The where clause names the METHOD the type must # have, rather than an interface it must implement. largest : List(a), a -> a where [a.is_gt : a, a -> Bool] largest = |items, seed| items.fold(seed, |best, item| if item > best { item } else { best }) main! = |_args| { numbers : List(I64) numbers = [3, 9, 4] echo!(largest(numbers, 0).to_str()) Ok({}) }
Java's constraint is nominal: a type must declare implements Comparable<T>, which is why Comparator exists for types that do not and why the recursive <T extends Comparable<T>> is needed to say "comparable with itself". Roc names a method instead of a type, so the self-reference disappears — and so does retroactive implementation, since you cannot add is_gt to a type you did not write.
A generic container
A type alias takes parameters in parentheses: Holder(a) names the shape { value : a } for any a, and Holder(I64) fills it in.
record Holder<T>(T value) {} class Main { public static void main(String[] args) { Holder<Integer> holder = new Holder<>(42); System.out.println(holder.value()); } }
Holder(a) : { value : a } main! = |_args| { holder : Holder(I64) holder = { value: 42 } echo!(holder.value.to_str()) Ok({}) }
Because the alias is structural, { value: 42 } already is a Holder(I64) without being constructed as one. The name is not arbitrary: Roc's standard library has a Box, and an alias shadowing a builtin is reported as such.
Types & Inference
Inference covers whole programs
Java's var stops at the local variable. Roc infers across the whole program, so an annotation is a claim you choose to state rather than information the compiler needs.
class Main { // var infers a local. A parameter and a return // type are always written. static int doubled(int number) { return number * 2; } public static void main(String[] args) { var result = doubled(21); System.out.println(result); } }
# Roc infers the whole signature from the body and # the call sites, so the annotation is optional. doubled = |number| number * 2 main! = |_args| { result : I64 result = doubled(21) echo!(result.to_str()) Ok({}) }
Convention is to annotate top-level functions anyway, so a reader can see the shape without reading the body. The difference is that a Roc annotation is checked against the inferred type rather than being the definition of it.
Naming a type without wrapping it
A single colon defines an alias: UserId and I64 are two spellings of one type, and values move between them freely with no wrapper and no construction.
class Main { // Java has no type alias, so naming a type // means a wrapper class or a record — with a // construction step and a distinct identity. record UserId(int value) {} public static void main(String[] args) { UserId id = new UserId(7); System.out.println(id.value()); } }
UserId : I64 main! = |_args| { id : UserId id = 7 # The same type under two names, so this needs # no unwrapping. plain : I64 plain = id echo!(plain.to_str()) Ok({}) }
Java has nothing like this, which is why the pattern is a record and why the wrapper survives into every signature, every call and every hot loop. Roc's := gives the Java behavior when the distinction is wanted — the alias is for when only the name is.
No wildcards
Roc has no subtyping between types, so a list of one type is never usable as a list of another, and there is no variance to declare or work around.
import java.util.List; class Main { // The wildcard exists because List<Integer> is // not a List<Number>, and sometimes it should // be usable as one. static double total(List<? extends Number> numbers) { double sum = 0; for (Number number : numbers) sum += number.doubleValue(); return sum; } public static void main(String[] args) { System.out.println(total(List.of(1, 2, 3))); } }
total : List(Dec) -> Dec total = |numbers| numbers.fold(0, |running, n| running + n) main! = |_args| { # There is no subtyping, so there is no variance # and nothing to annotate. echo!(total([1, 2, 3]).to_str()) Ok({}) }
Java's wildcards exist entirely to recover flexibility that invariant generics removed, and the guidance for when to use which — produce with extends, consume with super — is a rule people memorize. No subtyping means no such rule.
Returning two things
A tuple is written in parentheses and taken apart by writing a tuple pattern on the left of =. A function returning several values needs nothing declared.
class Main { record Pair(int quotient, int remainder) {} static Pair divide(int numerator, int denominator) { return new Pair(numerator / denominator, numerator % denominator); } public static void main(String[] args) { Pair result = divide(17, 5); System.out.println(result.quotient() + " " + result.remainder()); } }
divide : I64, I64 -> (I64, I64) divide = |numerator, denominator| (numerator // denominator, numerator % denominator) main! = |_args| { (quotient, remainder) = divide(17, 5) echo!("${quotient.to_str()} ${remainder.to_str()}") Ok({}) }
Java has no tuple, so returning two things means declaring a type for the pair — which is better documentation and worse when the pair is incidental. The record above exists for one call and one line.
Strings
Building a message
Interpolation is ${…} inside an ordinary string, and it accepts only a Str — anything that is not already text is converted explicitly.
class Main { public static void main(String[] args) { String name = "Ada"; int age = 36; // Java has no string interpolation, so it // is concatenation or formatted(). System.out.println("%s is %d".formatted(name, age)); } }
main! = |_args| { name = "Ada" age : I64 age = 36 # Interpolation takes a Str and converts nothing, # so the number is converted first. echo!("${name} is ${age.to_str()}") Ok({}) }
Java still has no interpolation, so the choice is + concatenation, formatted with a format string whose placeholders are checked only by a linter, or a StringBuilder. Roc's strictness is the opposite extreme, and it is the second most common thing to trip over here.
How long is a string
There is no len on a string. count_utf8_bytes is named for what it counts, and a Roc string is UTF-8, so an accented letter is two bytes and an emoji is four.
class Main { public static void main(String[] args) { String text = "café"; // length() counts UTF-16 code units, which // is why some emoji count as two. System.out.println(text.length()); } }
main! = |_args| { text = "café" # Roc strings are UTF-8 and the method says so. echo!(text.count_utf8_bytes().to_str()) Ok({}) }
Both columns are correct and print different numbers — é is one UTF-16 code unit and two UTF-8 bytes. The naming is the real difference: length() sounds like a count of characters and is not, which is how string bugs survive review.
Splitting and joining
split_on takes a literal separator string, and Str.join_with puts a list back together — list first, separator second, which reads naturally in dot form as parts.join_with(" | ").
import java.util.List; class Main { public static void main(String[] args) { String line = "red,green,blue"; // split takes a REGULAR EXPRESSION, not a // separator, so "." would match everything. String[] parts = line.split(","); System.out.println(parts.length); System.out.println(String.join(" | ", parts)); } }
main! = |_args| { line = "red,green,blue" parts = line.split_on(",") echo!(parts.len().to_str()) echo!(Str.join_with(parts, " | ")) Ok({}) }
Java's String.split takes a regular expression, which is a genuine trap: splitting on a literal dot needs "\\.", and splitting on a user-supplied string needs Pattern.quote. The name gives no hint that a regex is involved.
Building up a string
concat joins two strings and returns a new one, and folding it over a list accumulates the whole thing. There is no builder type.
import java.util.List; class Main { public static void main(String[] args) { StringBuilder builder = new StringBuilder(); for (String word : List.of("a", "b", "c")) builder.append(word); System.out.println(builder.toString()); } }
main! = |_args| { built = ["a", "b", "c"].fold("", |running, word| running.concat(word)) echo!(built) Ok({}) }
Java needs StringBuilder because String is immutable and += in a loop allocates on every pass. Roc's strings are immutable too, and the compiler sees that the accumulated string has one reference and appends into it — so the naive version is already the efficient one.
Numbers & Boxing
Integer types
Integer types are named for their width and signedness: I32 is Java's int, I64 its long, I8 its byte. Underscores group digits in both languages.
class Main { public static void main(String[] args) { int small = 42; long big = 9_000_000_000L; byte tiny = 127; System.out.println(small + " " + big + " " + tiny); } }
main! = |_args| { small : I32 small = 42 big : I64 big = 9_000_000_000 tiny : I8 tiny = 127 echo!("${small.to_str()} ${big.to_str()} ${tiny.to_str()}") Ok({}) }
Roc also has unsigned types such as U8. Java's byte, short, int and long are all signed, so a byte read from a stream arrives as a number from −128 to 127 and has to be masked with & 0xFF to be read as the 0–255 value it was.
No boxing, and no Integer cache
A number is a value with no object around it, so comparing two of them compares the numbers. There is no boxed form and no identity to compare instead.
class Main { public static void main(String[] args) { Integer cachedFirst = 127, cachedSecond = 127; Integer beyondFirst = 128, beyondSecond = 128; // Both pairs hold equal numbers. The cache // for -128..127 means only one pair is ==. System.out.println(cachedFirst == cachedSecond); System.out.println(beyondFirst == beyondSecond); } }
main! = |_args| { cached_first : I64 cached_first = 127 cached_second : I64 cached_second = 127 beyond_first : I64 beyond_first = 128 beyond_second : I64 beyond_second = 128 # Numbers are numbers. There is no object to be # the same object as. echo!(Str.inspect(cached_first == cached_second)) echo!(Str.inspect(beyond_first == beyond_second)) Ok({}) }
Java's output here is true then false, and both are correct behavior: Integer.valueOf caches −128 to 127, so == on boxed integers is reference comparison that happens to work for small values. Code tested with small numbers and shipped with large ones is a real bug, and a famous one.
Overflow stops the program, it does not wrap
Arithmetic that leaves the range of its type aborts the program. A literal that does not fit its annotated type does not get that far — it is a compile error naming the type and the value.
class Main { public static void main(String[] args) { // Silently wraps to Long.MIN_VALUE. Defined // behavior, and almost never what was meant. System.out.println(Long.MAX_VALUE + 1); System.out.println(Long.MAX_VALUE); } }
main! = |_args| { limit : I64 limit = 9223372036854775807 # limit + 1 would ABORT here rather than wrap, # and a literal too large for the type does not # compile at all. echo!(limit.to_str()) Ok({}) }
Java's wrapping is at least defined, which puts it ahead of C's signed overflow, and it is still a wrong answer that keeps running. Math.addExact exists and throws instead, which is the behavior Roc makes the default.
Decimal arithmetic
Roc's Dec is a fixed-point decimal and is what a fractional literal becomes unless something says otherwise. Asking for binary floating point means annotating F64.
import java.math.BigDecimal; class Main { public static void main(String[] args) { // double gets this wrong; BigDecimal is a // class with methods instead of operators. System.out.println(0.1 + 0.2); System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2"))); } }
main! = |_args| { # An unsuffixed fractional literal is already # Dec, Roc's exact decimal type, with ordinary # operators. echo!((0.1 + 0.2).to_str()) approximate : F64 approximate = 0.1 echo!((approximate + 0.2).to_str()) Ok({}) }
The defaults are opposite, and Roc's is the safer one: Java's bare 0.1 is a double, so 0.1 + 0.2 prints 0.30000000000000004, and the exact alternative is a class whose arithmetic is method calls. Roc makes you ask for the fast, lossy type by name.
Parsing a number
I64.from_str is named for the type it produces and returns a Try. The type on the left decides the result, so U8.from_str rejects "300".
class Main { public static void main(String[] args) { try { System.out.println(Integer.parseInt("42")); } catch (NumberFormatException error) { System.out.println("not a number"); } } }
main! = |_args| { # The failure is in the answer, not in a second # channel. match I64.from_str("42") { Ok(value) => echo!(value.to_str()) Err(_) => echo!("not a number") } Ok({}) }
NumberFormatException is unchecked, so nothing obliges the try block above — the version without it compiles, runs, and dies on the first piece of bad input. That exemption for unchecked exceptions is where most of Java's actual failures live.
Virtual Threads vs the ! Marker
The ! marker colors a function
A name ending in ! performs effects, and only another ! function may call one. A function without it cannot read, write or print — the compiler rejects the call rather than the output.
class Main { // Nothing in this signature rules out printing, // opening a file or starting a thread. static int total(int[] numbers) { int sum = 0; for (int number : numbers) sum += number; return sum; } public static void main(String[] args) { System.out.println(total(new int[]{1, 2, 3})); } }
# No ! in the name, so the body cannot call echo! # — the compiler rejects the call. All this can do # is compute. total : List(I64) -> I64 total = |numbers| numbers.sum() main! = |_args| { echo!(total([1, 2, 3]).to_str()) Ok({}) }
That makes the signature a guarantee rather than a description. int total(int[]) in Java rules out nothing, which is why "is this method safe to call twice" is a question about the body rather than about the type.
Virtual threads and the coloring problem
Roc marks a function for effects, and only an effectful function can call one — the coloring rule that async/await imposes in other languages, applied to all effects rather than to waiting.
class Main { public static void main(String[] args) throws Exception { // Java 21's answer to async: a thread that // is cheap enough to block. Ordinary code, // no async keyword, no colored functions. Thread worker = Thread.ofVirtual() .start(() -> System.out.println("from a virtual thread")); worker.join(); } }
main! = |_args| { # The ! marker colors functions for EVERY # effect, which is the trade Java's virtual # threads were designed to avoid. echo!("from the only thread there is") Ok({}) }
Java went the other way on purpose. Virtual threads make blocking cheap so that ordinary synchronous code scales, and no function needs a marker at all. The two languages made opposite bets: Java bought back uncolored code at the cost of a signature saying nothing, and Roc kept the coloring and got a signature that means something.
No shared mutable state to synchronize
Values are immutable, so there is nothing two pieces of code could be changing at once. Mutation exists only as a local var, whose name carries a $ and which nothing outside the function can reach.
class Counter { private int count; // synchronized, because two threads // incrementing at once is a lost update. synchronized void increment() { count++; } synchronized int value() { return count; } } class Main { public static void main(String[] args) { Counter counter = new Counter(); counter.increment(); counter.increment(); System.out.println(counter.value()); } }
main! = |_args| { # Nothing is shared, because nothing is mutable. # There is no lock, no volatile and no memory # model to reason about. var $count = 0.I64 $count = $count + 1 $count = $count + 1 echo!($count.to_str()) Ok({}) }
This is the part of the argument that gets stronger the larger a program grows: synchronized, volatile, AtomicInteger, the happens-before relation and the whole Java memory model exist to make shared mutable state safe. Removing the sharing removes the subject.
The JVM: Collector, JIT, Startup
Reference counting, decided at compile time
Roc frees a value by decrementing a count the compiler inserted while compiling, at the point the value stops being used. Nothing scans the heap and nothing pauses the program.
import java.util.stream.IntStream; class Main { public static void main(String[] args) { var numbers = IntStream.rangeClosed(1, 1000).boxed().toList(); System.out.println(numbers.size()); // The list becomes garbage here and is freed // whenever the collector next runs — which // is not a point in this program. System.out.println("no pause here"); } }
main! = |_args| { numbers = List.repeat(1.I64, 1000) echo!(numbers.len().to_str()) # The compiler inserted the decrement that frees # this list, at the last line that uses it. # There is no collector and no pause. echo!("no pause here") Ok({}) }
The costs are real: a little arithmetic on every copy, and no way to free a reference cycle. Roc gets away without a cycle collector because nothing in the language can build one — a value can only refer to values that already existed when it was constructed, which is not true of any Java object graph.
Immutable in the source, mutated in the binary
Every list function returns a new list, and set wraps it in a Try because the index may be out of range. When the old list has one reference and is about to go out of scope, the compiler reuses its memory instead of copying.
import java.util.ArrayList; import java.util.List; class Main { public static void main(String[] args) { List<Integer> numbers = new ArrayList<>(List.of(1, 2, 3)); numbers.set(0, 99); System.out.println(numbers.get(0)); } }
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3] # set returns a new list — but numbers is never # used again, so its count is 1 and the compiler # writes into the same memory rather than copying. changed = numbers.set(0, 99)? echo!(changed.get(0)?.to_str()) Ok({}) }
This is the answer to the objection that immutability must cost a copy, and it holds because the value is immutable: the compiler can prove nobody else is looking. It is exactly the proof that cannot be made about a Java ArrayList whose reference any caller might have kept.
No runtime to start
A Roc program is compiled to a native binary and starts the way any binary does. There is no virtual machine to launch, no class loading and no bytecode verification.
class Main { public static void main(String[] args) { // Before this line ran: a JVM started, the // class loader resolved and verified every // class, and the interpreter began. The JIT // has not warmed up yet. System.out.println("running"); } }
main! = |_args| { # A compiled binary with no runtime to start, # no classes to load and no warm-up. echo!("running") Ok({}) }
That matters for a command-line tool, a serverless function or anything invoked often and briefly — the case Java has spent years attacking with class-data sharing, AppCDS and native images. It matters much less for a server that starts once and runs for a month.
What the JVM is better at
Roc compiles ahead of time. There is no profiling, no speculative inlining and no deoptimization, so what the compiler decided is what runs.
class Main { public static void main(String[] args) { // A generational collector makes allocation // a pointer bump and collection of young // objects nearly free — and the JIT // specializes this loop on real data. long total = 0; for (int index = 0; index < 1000; index++) total += 1; System.out.println(total); } }
main! = |_args| { # No JIT. What is compiled is what runs, with no # profile from the actual workload to guide it. var $total = 0.I64 for number in List.repeat(1.I64, 1000) { $total = $total + number } echo!($total.to_str()) Ok({}) }
This is where Java's runtime earns its cost, and saying so is the fair version of this section. A generational collector turns allocation into a pointer bump and makes short-lived garbage nearly free, which beats reference counting on allocation-heavy work; and a JIT with real profile data can inline through a megamorphic call site in a way no ahead-of-time compiler can. A long-running server is precisely the shape that wins on both.
Interfaces & Methods vs Functions
Behavior lives in functions, not on types
There is no class here, and withdraw is an ordinary function whose first parameter is the account. Roc can attach methods to a nominal type — := followed by .{ … } — but a plain record alias like this one has none, and either way there is no inheritance and no virtual dispatch.
class Account { private final int balance; Account(int balance) { this.balance = balance; } int balance() { return balance; } Account withdraw(int amount) { return new Account(balance - amount); } } class Main { public static void main(String[] args) { System.out.println(new Account(100).withdraw(30).balance()); } }
Account : { balance : I64 } withdraw : Account, I64 -> Account withdraw = |account, amount| { ..account, balance: account.balance - amount } main! = |_args| { account = { balance: 100 } echo!(withdraw(account, 30).balance.to_str()) Ok({}) }
Nothing is encapsulated here: any code holding this record can read balance directly. There is nothing to reach for either — := makes a type nominal, but a record literal with the right fields is still accepted where the nominal type is expected, with or without an annotation. Privacy in Roc is a convention, not a boundary.
Method syntax, and the limit on both sides
Dot syntax reaches the methods a type declares. The standard library's types declare theirs, and you can declare your own on a nominal type — Account := { … }.{ … }, shown below under Methods on a type you declare. What you cannot do is add a method to a type you did not declare, so a free function is called prefix, or piped with |>.
import java.util.List; class Main { static int total(List<Integer> values) { return values.stream().mapToInt(Integer::intValue).sum(); } public static void main(String[] args) { List<Integer> numbers = List.of(1, 2, 3); // Reads as a method because it is one. System.out.println(numbers.size()); // A helper you wrote is called the other // way round — Java has no extension methods. System.out.println(total(numbers)); } }
total : List(I64) -> I64 total = |numbers| numbers.sum() main! = |_args| { numbers : List(I64) numbers = [1, 2, 3] # numbers.len() works because List declares len. # numbers.total() does NOT — you may declare # methods on a type you define, but never add one # to a type you did not. So your own function is # called prefix, or piped with |>. echo!(numbers.len().to_str()) echo!((numbers |> total).to_str()) Ok({}) }
Java is in the same position for the same reason: a helper you write for a type you do not own is Helpers.describe(value) forever, and reads inside-out when chained. Neither language lets you extend a type you did not declare, and |> is the thing Roc has that Java does not.
Methods on a type you declare
A nominal type is declared with :=, and the trailing .{ … } block holds the functions that belong to it. Account.new(100) reaches one through the type name, the way a static factory does; .withdraw(30) and .describe() reach the same block through a value of that type, which is what lets the calls chain.
class Account { private final int balance; Account(int balance) { this.balance = balance; } Account withdraw(int amount) { return new Account(balance - amount); } String describe() { return "balance is " + balance; } } class Main { public static void main(String[] args) { System.out.println(new Account(100).withdraw(30).describe()); } }
Account := { balance : I64 }.{ new : I64 -> Account new = |starting| { balance: starting } withdraw : Account, I64 -> Account withdraw = |account, amount| { balance: account.balance - amount } describe : Account -> Str describe = |account| "balance is ${account.balance.to_str()}" } main! = |_args| { echo!(Account.new(100).withdraw(30).describe()) Ok({}) }
This is as close as Roc comes to a class, and the previous row understates it. What it still is not: withdraw takes the account as an ordinary first parameter, so there is no this; the block cannot be subclassed, implemented or extended; and every call is resolved at compile time, so none of the dispatch Java spends an interface on happens here. Roc gets its polymorphism from tag unions and from where constraints instead.
Closures capture without being final
A function written inline with |parameters| captures whatever it names from the surrounding scope, and is an ordinary value that can be stored, passed and returned.
import java.util.List; class Main { public static void main(String[] args) { List<Integer> numbers = List.of(1, 2, 3); int step = 10; // step must be final or effectively final; // assigning to it below would break this. numbers.stream().map(n -> n + step) .forEach(System.out::println); } }
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3] step = 10 for value in numbers.map(|number| number + step) { echo!(value.to_str()) } Ok({}) }
Java's effectively-final rule exists because a lambda captures by value and a mutable local would make the copy misleading. Every binding here is already immutable, so the rule has nothing to restrict and never needs explaining.
Sorting with a comparison
sort_by takes a key function and orders by what it returns, which is what Comparator.comparingInt builds for you. When a key is not enough, sort_with takes a comparison returning one of three tags — Before, After or Same — and the compiler checks all three are accounted for.
import java.util.Comparator; import java.util.List; class Main { public static void main(String[] args) { List<String> words = List.of("pear", "fig", "apple"); words.stream() .sorted(Comparator.comparingInt(String::length)) .forEach(System.out::println); } }
main! = |_args| { words = ["pear", "fig", "apple"] by_length = words.sort_by(|candidate| candidate.count_utf8_bytes()) for word in by_length { echo!(word) } Ok({}) }
Comparator.comparingInt exists because the underlying interface is still "return an int whose sign means something", and it is a library making that safe for the common case. Roc's general form is three named tags rather than a sign, so a comparison cannot be written backwards: one that returns the wrong sign in Java compiles and sorts incorrectly, and a tag by the wrong name does not compile.
Assertions & Tests
Tests are a language keyword
expect is a top-level statement holding an expression that must be true. It sits next to the function it is about, and the test runner collects every one in the program.
class Main { static int doubled(int number) { return number * 2; } public static void main(String[] args) { // A test needs a build file, a dependency // and an annotation before it can run. System.out.println(doubled(21)); } }
doubled : I64 -> I64 doubled = |number| number * 2 # expect is part of the language. It runs when the # tests run and is compiled out otherwise. expect doubled(21) == 42 expect doubled(0) == 0 main! = |_args| { echo!(doubled(21).to_str()) Ok({}) }
A failing expect at the top level is silent on the platform this page runs on — it is collected by the test runner rather than evaluated here, so nothing about it reaches the output pane. Being in the language means no test source set, no JUnit dependency and no @Test.
Asserting inside a running program
expect also works inside a function body, where it checks its condition as the program runs. It does not abort and it prints nothing — a failed check shows up as a non-zero exit status, which is how the run is marked as having failed.
class Main { public static void main(String[] args) { int count = 3; // assert is disabled unless the JVM is // started with -ea, so in production this // statement does nothing at all. assert count > 0; System.out.println(count); } }
main! = |_args| { count : I64 count = 3 # An expect inside a function body checks at run # time, in every build. expect count > 0 echo!(count.to_str()) Ok({}) }
Java's assert is off by default — it needs -ea on the command line — which is why most codebases avoid it entirely and throw IllegalArgumentException instead. An invariant that is only checked when someone remembered a flag is not really checked.
Gotchas for Java Programmers
An unannotated integer prints with a decimal point
A number literal with nothing to constrain it defaults to Dec, Roc's exact decimal type, and Dec always prints a decimal point. Annotating the binding or suffixing the literal with .I64 fixes it.
class Main { public static void main(String[] args) { // A bare integer literal is an int. var count = 6; System.out.println(count); } }
main! = |_args| { # A bare literal with nothing to constrain it # becomes Dec, and Dec prints a decimal point. loose = 6 echo!(loose.to_str()) # Annotate or suffix to fix the type. exact : I64 exact = 6 echo!(exact.to_str()) Ok({}) }
This is the first thing that surprises nearly everyone, and it surprises quietly — the program is correct and the arithmetic is exact, but 6 prints as 6.0. Any example whose printed form matters should say which number type it means.
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.
class Main { public static void main(String[] args) { boolean flag = false; System.out.println(!flag); } }
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({}) }
The error names a type, [Maybe, True, ..], that appears nowhere in the source, which is what makes this worth knowing before it happens. A Bool is printed with Str.inspect, which writes the tag's own name, True, where Java prints the lowercase keyword.
A name cannot be reassigned
A plain binding is fixed once, so accumulating in a loop needs var and a name beginning with $. The sigil is part of the name and is written at every use.
class Main { public static void main(String[] args) { int total = 0; for (int number : new int[]{1, 2, 3}) total = total + number; System.out.println(total); } }
main! = |_args| { # total = total + number would be a compile # error: a plain binding cannot be reassigned, # and the compiler reads it as self-reference. var $total = 0.I64 for number in [1, 2, 3] { $total = $total + number } echo!($total.to_str()) Ok({}) }
The error for getting this wrong names self-reference rather than mutation, which reads oddly until you see why: total = total + number is a new definition of total whose body mentions total. The idiomatic version is usually fold.
An annotated error type has to stay open
When you annotate a function's error type, end the tag union with .. to leave it open. A closed error type cannot unify with the one the platform's main! declares, and the ? operator stops compiling.
class Main { // A throws clause names exactly what it names, // and a caller may narrow it freely. static int parse(String text) throws NumberFormatException { return Integer.parseInt(text); } public static void main(String[] args) { System.out.println(parse("17")); } }
# The .. matters: Try(I64, [BadNumStr]) — closed — # will not unify with the error type main! returns, # and the ? below stops compiling. parse : Str -> Try(I64, [BadNumStr, ..]) parse = |text| I64.from_str(text) main! = |_args| { echo!(parse("17")?.to_str()) Ok({}) }
Leaving the annotation off entirely also works, since an inferred error type is open already. The failure mode is worth recognizing: the error message talks about a payload type rather than about the annotation, so the line it points at is the ? and not the signature that caused it.

Thank you — anything else?