PONYλM2Modula-2

Dart.CodeCompared.To/Roc

An interactive executable cheatsheet comparing Dart and Roc

Dart 3.7 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 — the same idea as async, applied to every effect rather than only to waiting.
void main() { print('Hello, World!'); }
main! = |_args| { echo!("Hello, World!") Ok({}) }
Ok({}) is the return value, where {} is the empty record — Roc's void. The _args parameter has a leading underscore because the signature requires it and this program ignores it, which is the same convention Dart uses for an unused callback parameter.
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. The relationship is the one Dart has with its embedder, made explicit and checked while compiling.
void main() { // dart:io, dart:isolate and the collector exist // because the runtime was linked in before your // code ran — and dart:io is simply absent on web. print('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({}) }
Dart is closer to this than most languages already — dart:io exists on native and not on web, so a Flutter developer knows that what is available depends on where the code runs. Roc makes that a declaration rather than a fact to remember, so reaching for an effect the target does not have is a compile error instead of a broken build.
How a program reports failure
main! returns a TryOk 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.
import 'dart:io'; void main() { print('all good'); // Or exit(1) from anywhere, which stops the // process without unwinding. 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 deep inside a library. exit in Dart does exactly that, and it does not run finally blocks on the way out.
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.
void main() { // A single-line comment final count = 42; // an inline comment /* A block comment, which can span lines. */ /// A documentation comment. print(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 here, but it pins down which number type this is — and as the Numbers section shows, that decides what gets printed.
Null Safety, Taken Further
Sound null safety, and then no null at all
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.
void main() { // Dart 3's null safety is SOUND: a String cannot // hold null, and the compiler proves it rather // than warning about it. String? name; print(name?.length ?? 0); }
main! = |_args| { # There is no null literal to write, no ? suffix # to annotate with and no ?. to guard with. # 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({}) }
Dart deserves the credit here: its null safety is genuinely sound, which is more than TypeScript's annotations or Java's @Nullable can claim, and a Dart developer already thinks in terms of "can this be absent". What Roc removes is the remaining machinery — the ? suffix, !, ?. and ?? are all answers to a hole that is not there.
No late, and nothing to initialize later
A name is introduced by giving it a value, and a record literal must supply every field its type declares. There is no partially-built value and no deferred initialization.
class Config { // late is the escape hatch: the compiler stops // checking, and reading it too early throws // LateInitializationError at run time. late final String url; void load() { url = 'https://example.com'; } } void main() { final config = Config(); config.load(); print(config.url); }
main! = |_args| { # A binding IS its initializer. There is no # declare-now-fill-in-later form, so there is # no moment when a value is not yet there. config = { url: "https://example.com" } echo!(config.url) Ok({}) }
late is where Dart's soundness is traded back for convenience, and the trade is explicit: the type says non-nullable, the compiler stops proving it, and the check moves to run time. It exists because Flutter's initState shape genuinely needs it — which is a property of the framework's lifecycle rather than of the language.
A lookup that might find nothing
Dict.get returns a TryOk with the value or Err with a reason — and match forces both arms before the value can be used.
void main() { final ages = {'alice': 30}; // The index operator returns int? for a missing // key, so the null check is the compiler's doing // rather than a convention. final age = ages['bob']; print(age == null ? 'bob is not listed' : 'bob is $age'); }
main! = |_args| { ages = Dict.empty().insert("alice", 30.I64) # get returns a Try, so 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({}) }
This is another row where Dart is already most of the way there: the index operator is typed int?, so forgetting the check does not compile. The one thing it still cannot distinguish is a missing key from a key stored with a null value, which is why containsKey exists as a separate question.
No ! to silence the compiler
List.first returns a Try, so an empty list is part of the answer. Roc has no assertion operator — nothing lets you tell the compiler you know better.
void main() { final List<int> numbers = []; // firstOrNull is the safe form. The unsafe one // is numbers.first, which throws on empty. final first = numbers.isEmpty ? 0 : numbers.first; print(first); }
main! = |_args| { numbers : List(I64) numbers = [] # first returns a Try, and there is no operator # that asserts it succeeded. match numbers.first() { Ok(value) => echo!(value.to_str()) Err(_) => echo!("0") } Ok({}) }
Dart's ! is checked at run time rather than erased, so it fails loudly instead of corrupting anything — a real improvement over TypeScript's version of the same operator. It is still a place where the type says one thing and the programmer overrules it, and the absence of any such operator is the difference this row is about.
An exhaustive switch really is exhaustive
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 class Shape {} class Circle extends Shape {} class Square extends Shape {} String name(Shape shape) => switch (shape) { Circle() => 'circle', Square() => 'square', }; void main() { print(name(Circle())); }
Shape : [Circle, Square] name : Shape -> Str name = |shape| match shape { Circle => "circle" Square => "square" } main! = |_args| { echo!(name(Circle)) Ok({}) }
On a page written for a Java or C# reader this row is where null walks through the exhaustive switch and throws. It does not here: Shape is non-nullable, so Dart's switch is complete for the same reason Roc's is. This is the clearest single measure of how much closer Dart already is.
Records vs Records
Records are structural in both languages
A record literal is braces with field: value pairs, and fields are read with a dot. The type is the set of fields, so nothing is declared and nothing is constructed.
void main() { // Dart 3 records: no declaration, structural // type, and value equality — the same three // properties a Roc record has. final alice = (name: 'Alice', age: 30); print('${alice.name} is ${alice.age}'); }
main! = |_args| { alice = { name: "Alice", age: 30.I64 } echo!("${alice.name} is ${alice.age.to_str()}") Ok({}) }
This is the closest correspondence on the page. Dart 3 records are structural, need no declaration and compare by value, which is exactly the model Roc uses — a Dart developer who has moved to records is already writing in this style.
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.
void main() { final alice = (name: 'Alice', age: 30); // No with-expression: every field is written // again, and adding a field breaks this line. final older = (name: alice.name, age: 31); print('${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({}) }
Dart has no equivalent for records, which is why a class with a hand-written copyWith is still the Flutter idiom for state that changes one field at a time — and why copyWith is the thing code generators are most often pointed at.
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.
void main() { final team = (name: 'core', members: ['ada']); // The record's fields cannot be reassigned, and // the list inside it is fully mutable. team.members.add('grace'); print(team.members.length); }
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 difference: Dart changed the list the record was holding, and Roc built a new list and a new record around it. A Dart record's fields are final and what they point at is not, which is the one place its records and Roc's genuinely part company.
Positional records and tuples
A tuple is written in parentheses and taken apart by writing a tuple pattern on the left of =. // is integer division and % the remainder, where Dart spells the first ~/.
(int, int) divide(int numerator, int denominator) => (numerator ~/ denominator, numerator % denominator); void main() { final (quotient, remainder) = divide(17, 5); print('$quotient $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({}) }
These two are the same feature with the same syntax, arrived at independently. Dart's positional record fields are also reachable as .$1 and .$2; Roc offers only the destructuring form, which is the one worth using in both.
final, const & Deep Immutability
final protects the binding, not the value
Every value is immutable, so there is no add and no mutating method anywhere. append returns a new list one element longer.
void main() { // final stops reassignment of the name. The list // it points at is wide open. final items = ['apple']; items.add('pear'); print(items.length); }
main! = |_args| { items = ["apple"] # There is no add. Adding means building a new # list; the original is untouched. fuller = items.append("pear") echo!(fuller.len().to_str()) Ok({}) }
Both columns print 2, and the difference is what happened to items: Dart's grew, and Roc's did not. final in Dart is the same shape as const in JavaScript and val in Kotlin — it protects the name and says nothing about the object.
const is deep, and only for compile-time values
Roc has no const keyword because there is nothing for it to distinguish: a value computed at run time is exactly as immutable as a literal.
void main() { // const IS deep: this list cannot be changed at // all. The restriction is that everything in it // must be known at compile time. const items = ['apple']; print(items.length); // items.add('pear') throws UnsupportedError. }
main! = |_args| { # Every value is const in Dart's sense, whether # or not the compiler could have computed it. items = ["apple"] echo!(items.len().to_str()) Ok({}) }
Dart's const gives the deep guarantee Roc gives everywhere, and only for values the compiler can evaluate — so a list built from a function call cannot have it. That restriction is why final is what most Dart code actually uses, and why the shallow guarantee is the one in force most of the 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.
class Report { final List<int> scores; Report(this.scores); } void main() { final report = Report([1, 2, 3]); // The caller just changed the report's own list. report.scores.add(4); print(report.scores.length); }
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 Dart class above is written the ordinary way — a final field, a generative constructor — and it still leaks. The fixes are List.unmodifiable in the constructor or UnmodifiableListView on the way out, both of which are a wrapper the author has to remember.
Two names never share one value
Assignment never creates an alias, because there is nothing to alias. set returns a new list wrapped in a Try, since the index may be out of range, and the ? unwraps it.
void main() { final numbers = [1, 2, 3]; final alias = numbers; alias[0] = 99; // numbers changed, because there was only ever // one list and two ways to reach it. print(numbers[0]); }
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3] changed = numbers.set(0, 99)? # set returned a NEW list. numbers is untouched, # and nothing could have touched it. echo!(numbers.get(0)?.to_str()) echo!(changed.get(0)?.to_str()) Ok({}) }
Dart's behavior is correct and is the reason a widget rebuilt from a list someone else still holds can change underneath you. Removing the sharing is what makes a Roc value safe to pass anywhere without asking who else kept a reference.
Sealed Classes 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) needs no declaration beyond that line — writing a tag creates it.
sealed class Shape {} class Circle extends Shape { final double radius; Circle(this.radius); } class Rect extends Shape { final double width, height; Rect(this.width, this.height); } double area(Shape shape) => switch (shape) { Circle(:final radius) => 3.14159 * radius * radius, Rect(:final width, :final height) => width * height, }; void main() { print(area(Rect(3, 4))); }
Shape : [Circle(Dec), Rect(Dec, Dec)] area : Shape -> Dec area = |shape| match shape { Circle(radius) => 3.14159 * radius * radius Rect(width, height) => width * height } main! = |_args| { echo!(area(Rect(3, 4)).to_str()) Ok({}) }
Dart 3's sealed classes plus pattern matching are this idea arrived at from the object-oriented side, and the switch reads almost identically. The cost is a class per case, a constructor per class and a field declaration per payload — three declarations here for what one line says.
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 } String describe(Status status) => switch (status) { Status.ok => 'ok', Status.busy => 'busy', }; void main() { print(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 } String describe(Level level) => switch (level) { Level.warning => 'warning', // The set is fixed at the declaration. A caller // cannot add a case, and a sealed class only // moves where the set is fixed. _ => 'other', }; void main() { print(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({}) }
This is the part of Roc's tag system with no Dart counterpart. An enum is closed, a sealed class is closed by its subclasses being in the same library, and an ordinary class hierarchy is open but requires every case to be a declared class. Choosing openness per function, at the use site, cannot be written in Dart at all.
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() => '...'; } class Dog extends Animal { @override String speak() => 'Woof'; } void main() { Animal pet = Dog(); // Which speak runs is decided at run time by the // object's actual class. print(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. Dart 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). A Flutter widget tree is the case where inheritance earns its keep, and it is worth knowing which way your own code grows.
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 class Tree {} class Leaf extends Tree { final int value; Leaf(this.value); } class Node extends Tree { final Tree left, right; Node(this.left, this.right); } int total(Tree tree) => switch (tree) { Leaf(:final value) => value, Node(:final left, :final right) => total(left) + total(right), }; void main() { print(total(Node(Leaf(1), Node(Leaf(2), Leaf(3))))); }
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 Dart column needs a sealed class and two subclasses to say what one line says here.
Patterns & switch vs match
switch expression vs match
match is an expression, like Dart 3's switch expression: each arm is pattern => value, and the arms are separated by newlines rather than commas.
String name(int code) => switch (code) { 200 => 'OK', 404 => 'Not Found', _ => 'Unknown', }; void main() { print(name(404)); }
name : I64 -> Str name = |code| match code { 200 => "OK" 404 => "Not Found" _ => "Unknown" } main! = |_args| { echo!(name(404)) Ok({}) }
These are the same construct down to the arrow, which is a fair measure of where Dart 3's patterns came from. _ is the wildcard in both.
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.
String describe((int, int) point) => switch (point) { (0, 0) => 'origin', (0, _) => 'on the y axis', (_, 0) => 'on the x axis', _ => 'somewhere else', }; void main() { print(describe((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({}) }
Dart writes the same idea positionally on a tuple and by name on a named record, and it does not require every arm to mention every field. The version here names both fields in every arm, which is the shape Roc asks for.
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 Dart writes when.
String classify(int number) => switch (number) { 0 => 'zero', final n when n > 0 => 'positive', _ => 'negative', }; void main() { print(classify(0)); print(classify(42)); }
classify : I64 -> Str classify = |number| match number { 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.
No dynamic, and nothing to test for
There is no Object to accept anything, no dynamic and no is test, because a value's type is never in doubt. The parameter type says it is one of exactly two shapes.
String describe(Object value) => switch (value) { int number => 'a number: $number', String text => 'a string: $text', _ => 'something else', }; void main() { print(describe(42)); print(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 fallback arm, because its type says there are two cases. The Dart version needs a third for everything it was not designed for, and that arm is where a mistake lands quietly — describe(2.5) compiles and returns "something else".
Exceptions vs Try
What a signature says about failure
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.
// The signature is String -> int. Dart has no // checked exceptions, so nothing in it says that // this can throw FormatException. int parse(String text) => int.parse(text); void main() { print(parse('17')); }
# The signature says the failure out loud, and the # caller cannot reach the number without handling it. parse : Str -> Try(I64, [BadNumStr, ..]) parse = |text| I64.from_str(text) main! = |_args| { match parse("17") { Ok(number) => echo!(number.to_str()) Err(BadNumStr) => echo!("not a number") } Ok({}) }
Dart declined checked exceptions deliberately, as almost every language after Java did, so what a function can throw lives in documentation — and documentation is not checked against the code. Dart's int.tryParse is the alternative in the standard library, returning int?, and it is the shape this row is arguing should be the default rather than the opt-in.
try/catch vs match
The failure arrives as a value, so handling it is the same match used everywhere else. There is no separate construct, no exception type to name and no stack unwinding.
void main() { try { print(int.parse('oops')); } on FormatException { print('not a number'); } }
main! = |_args| { match I64.from_str("oops") { Ok(number) => echo!(number.to_str()) Err(_) => echo!("not a number") } Ok({}) }
The two read alike here because the failing call is right there. They diverge when it is not: a Dart try block can wrap a hundred lines and catch a FormatException thrown by any of them, while a Roc Try is tied to the one expression that produced it.
Propagating a failure
A ? after an expression unwraps the Ok and returns early on Err. The error type it returns must fit the function's own, which is why sum declares [BadNumStr, ..].
// An exception propagates by default: sum does // nothing to pass the failure along. int sum(String first, String second) => int.parse(first) + int.parse(second); void main() { print(sum('3', '4')); }
# ? returns early with the Err, so the happy path # reads straight down and the failure is explicit. 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({}) }
Dart propagates by doing nothing, which is convenient and is also why a failure can travel a long way from where it happened before anyone notices — in Flutter, often as far as an error widget with a stack trace the user sees.
A custom exception becomes a tag
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 and no interface to implement.
class WithdrawalException implements Exception { final int shortfall; WithdrawalException(this.shortfall); } int withdraw(int balance, int amount) { if (amount > balance) throw WithdrawalException(amount - balance); return balance - amount; } void main() { try { print(withdraw(100, 150)); } on WithdrawalException catch (error) { print('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 Dart errors are classes, catching is subtype matching, and a bare catch (e) catches everything — including the NoSuchMethodError 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. 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.
void main() { try { print('working'); } finally { // Runs however the block is left, which is only // necessary because there are invisible ways to // leave it. print('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 discards the exception that was propagating, and the compiler allows it. Removing invisible exits removes both the need and the hazard.
Classes & Mixins 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 { final int balance; Account(this.balance); Account withdraw(int amount) => Account(balance - amount); } void main() { print(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 on this build that changes what the type derives rather than who may build one: 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.
Extension methods, and the limit on this side
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 |>.
// An extension makes a function read as a method // on a type you do not own. extension Shouting on String { String shout() => '$this!'; } void main() { print('hi'.shout()); print('hi'.shout().shout()); }
shout : Str -> Str shout = |text| "${text}!" main! = |_args| { # "hi".shout() does NOT work: dot syntax reaches # the methods a type declares, and Str declares no # shout. You may declare methods on a type you # define, but never add one to Str. So a function # you write is called prefix, and |> chains it. echo!(shout("hi")) echo!("hi" |> shout |> shout) Ok({}) }
Dart wins this one outright. Extensions need a declaration and an import, and two of them defining the same member on the same type is an ambiguity to resolve by hand — but they exist, they are checked, and they let a call read the way the reader expects. |> recovers the chaining and not the discoverability.
Methods on a type you declare
A nominal type is declared with :=, and the trailing .{ … } block holds its methods. Account.new(100) calls one through the type name; .withdraw(30) and .describe() reach the same block through a value of that type, so the calls chain the way Dart's do.
class Account { final int balance; Account(this.balance); Account withdraw(int amount) => Account(balance - amount); String describe() => 'balance is $balance'; } void main() { print(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({}) }
The previous row is the limit — you cannot give Str a shout. This row is the other half of the rule: on a type you declare yourself, methods exist and chain exactly as Dart's do. What is absent is the rest of the object model — no this, no inheritance, no mixins, no virtual dispatch — and every call here is resolved at compile time.
No mixins
There is no mixin and no inheritance of any kind. Behavior shared between types is a function that takes each of them, and there is no declaration saying which types it applies to.
mixin Greets { String greet() => 'hello from $runtimeType'; } class Robot with Greets {} void main() { print(Robot().greet()); }
# A shared behavior is a shared function. Whether # a type "has" it is a question about what you # call, not about how the type was declared. greet : Str -> Str greet = |name| "hello from ${name}" main! = |_args| { echo!(greet("Robot")) Ok({}) }
Mixins solve a real problem — sharing an implementation without single inheritance getting in the way — and the problem is created by putting behavior on types in the first place. A function that takes a value has nothing to be mixed into.
Closures
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.
void main() { final numbers = [1, 2, 3]; final step = 10; for (final value in numbers.map((number) => number + step)) { print(value); } }
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3] step = 10 for value in numbers.map(|number| number + step) { echo!(value.to_str()) } Ok({}) }
Dart and Roc agree completely here, down to the for … in loop around the result. The one difference is the arrow: Dart's parameters go in parentheses before =>, and Roc's go between vertical bars.
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.
void main() { final numbers = [10, 20, 30]; print(numbers.length); print(numbers[1]); print(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({}) }
Dart's [] throws RangeError on a bad index, and nothing in the expression says so. The last line differs only in spelling: Dart 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.
void main() { final numbers = [1, 2]; numbers.add(3); // add mutated the list in place and returned // nothing. print(numbers.length); }
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.
void main() { final ages = {'alice': 30, 'bob': 25}; print(ages.length); print(ages['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.
Spreading one list into another
concat joins two lists and returns a new one. Roc has no spread syntax, so building a list out of pieces is a chain of concat calls.
void main() { final first = [1, 2]; final second = [0, ...first, 3]; print(second.length); }
main! = |_args| { first : List(I64) first = [1, 2] # There is no spread operator; concat joins two # lists and returns a new one. second = [0].concat(first).concat([3]) echo!(second.len().to_str()) Ok({}) }
Dart's spread — and its collection-if and collection-for, which have no counterpart here at all — are genuinely nicer for building a widget child list. This is one of the places Roc's smaller surface is felt as a cost rather than a simplification.
Iterables vs List Functions
map and where
keep_if is where and map is map, chained with the same dot syntax. Each returns a new list immediately.
void main() { final numbers = [1, 2, 3, 4, 5]; final result = numbers .where((number) => number % 2 == 1) .map((number) => number * 10) .join(', '); print(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({}) }
join in Dart converts each element for you by calling toString; Str.join_with takes a list of strings, so the numbers are converted first. That strictness is the same one string interpolation applies, and the Strings section comes back to it.
Eager, not lazy
Roc's list functions run where they are written and return a finished list. There is no lazy view, no deferred execution and no second evaluation.
void main() { final numbers = [1, 2, 3]; // where returns a lazy Iterable — nothing has // run yet, and it reads the list when consumed. final query = numbers.where((number) => number > 1); numbers.add(4); print(query.length); }
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 where(…).first stop early on a large list, and it is also why the Dart column prints 3 rather than 2 — the Iterable was built before the 4 existed and evaluated after. Consuming one twice runs the predicate twice, which surprises people when the predicate is expensive.
fold and reduce
fold takes a starting value and a function of the running total and the next element — the same two arguments, in the same order, with the same name.
void main() { final numbers = [1, 2, 3, 4]; // The type argument is needed: without it the // accumulator infers as Object? and + is gone. print(numbers.fold<int>(0, (a, b) => a + b)); print(numbers.fold<int>(1, (a, b) => a * b)); }
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3, 4] echo!(numbers.fold(0, |running, n| running + n).to_str()) echo!(numbers.fold(1, |running, n| running * n).to_str()) Ok({}) }
Dart also has reduce, which uses the first element as the seed and throws StateError on an empty iterable. Roc has no such overload, so the seed is always written and the empty case is always the seed.
Finding the first match
find_first returns a Try: Ok with the element or Err when the predicate matched nothing. There is no second function for callers who would rather not be thrown at.
void main() { final numbers = [1, 3, 8, 5]; // firstWhere throws unless orElse is supplied; // firstWhereOrNull needs package:collection. final found = numbers.firstWhere((number) => number % 2 == 0, orElse: () => -1); print(found == -1 ? 'nothing matched' : found); }
main! = |_args| { numbers : List(I64) numbers = [1, 3, 8, 5] # One function, and the answer says which of the # two things happened. match numbers.find_first(|number| number % 2 == 0) { Ok(value) => echo!(value.to_str()) Err(_) => echo!("nothing matched") } Ok({}) }
The orElse sentinel in the Dart column is the usual workaround and has the usual weakness — -1 is a plausible element, so a list that really contains one is indistinguishable from no match. firstWhereOrNull is the better answer and lives in a package rather than the core library.
Generics
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.
T firstOr<T>(List<T> items, T fallback) => items.isEmpty ? fallback : items.first; void main() { print(firstOr(<int>[], 7)); print(firstOr(<String>[], '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({}) }
Dart's generics are reified — the type argument survives to run time, so value is List<int> is a real question with a real answer. Roc erases the variable after compiling a specialized version per type, so nothing is lost and nothing is left to ask.
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.
T largest<T extends Comparable<T>>(List<T> items, T seed) { var best = seed; for (final item in items) { if (item.compareTo(best) > 0) best = item; } return best; } void main() { // num, not int: int implements Comparable<num>, // so it does not satisfy T extends Comparable<T>. print(largest<num>([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({}) }
Naming a method rather than a type is what removes the trap the Dart column walks into: that call has to be largest<num> rather than largest<int>, because int implements Comparable<num> and therefore does not satisfy T extends Comparable<T> — a constraint that reads as though it accepts every ordered type and does not. Roc asks only whether an is_gt exists, so there is no self-reference to fail to satisfy. The cost is that you cannot add one 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.
class Holder<T> { final T value; Holder(this.value); } void main() { print(Holder<int>(42).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
Dart's inference stops at the function boundary: parameters always need types, and so does any return type worth documenting. Roc infers across the whole program, so an annotation is a claim you choose to state.
// var and final infer a local. A parameter and a // return type are always written. int doubled(int number) => number * 2; void main() { final result = doubled(21); print(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. It is exactly Dart's typedef.
typedef UserId = int; void main() { UserId id = 7; int plain = id; // the same type, so this is fine print(plain); }
UserId : I64 main! = |_args| { id : UserId id = 7 plain : I64 plain = id # the same type, so this is fine echo!(plain.to_str()) Ok({}) }
An alias buys readability and nothing else — neither language will stop you passing a UserId where any integer is wanted. When you want that check, Dart reaches for an extension type or a wrapper class and Roc for :=.
Structural types, all the way
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.
class Point { final int x, y; Point(this.x, this.y); } class Coordinate { final int x, y; Coordinate(this.x, this.y); } String describe(Point point) => '(${point.x}, ${point.y})'; void main() { print(describe(Point(1, 2))); // describe(Coordinate(1, 2)) does not compile: // classes are nominal even when shaped alike. }
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. echo!(describe({ x: 1, y: 2 })) Ok({}) }
Dart is split on this: its classes are nominal and its records are structural, so the answer depends on which you reached for. Roc is structural throughout, and := is how you ask for the nominal behavior when you want it.
No dynamic
There is no way to turn type checking off for a value. Every expression has a type the compiler knows, and a call that does not fit is a compile error.
void main() { // dynamic switches type checking off for this // value; the call below compiles and fails at // run time. dynamic value = 'hi'; print(value.length); }
main! = |_args| { # There is no dynamic and no way to opt out of # type checking for a value. value = "hi" echo!(value.count_utf8_bytes().to_str()) Ok({}) }
dynamic is Dart's remaining hole, and it is a deliberate one — it is what makes JSON decoding, reflection and interop with untyped JavaScript possible. It also means value.length above compiles whatever value turns out to hold.
Strings
String interpolation
Interpolation is ${…} inside an ordinary string — Dart's ${…} with the braces always required — and it accepts only a Str, so anything that is not already text is converted explicitly.
void main() { final name = 'Ada'; final age = 36; // Dart converts by calling toString for you. print('$name is $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({}) }
Forgetting the conversion is a compile error naming the type that arrived, and it is the second most common thing to trip over here. The strictness has a point: Dart's interpolation calls toString on whatever it is given, and the default toString on a class prints Instance of 'Foo'.
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.
void main() { final text = 'café'; // length counts UTF-16 code units, which is why // some emoji count as two. print(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 why Dart's characters package exists.
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(" | ").
void main() { final line = 'red,green,blue'; final parts = line.split(','); print(parts.length); print(parts.join(' | ')); }
main! = |_args| { line = "red,green,blue" parts = line.split_on(",") echo!(parts.len().to_str()) echo!(Str.join_with(parts, " | ")) Ok({}) }
Dart's split accepts either a string or a Pattern, so the literal case behaves as expected without the regular-expression trap the same method carries in some other languages.
No case conversion
The pinned build of Roc has no to_uppercase and no to_lowercase. Case conversion is genuinely hard once more than ASCII is involved, and the standard library has not settled on an answer.
void main() { final text = 'Hello'; print(text.toUpperCase()); }
main! = |_args| { text = "Hello" # This build of Roc has no case conversion at # all, so an example that needs one has to say # what it wants explicitly. echo!(text) Ok({}) }
Dart's version is locale-independent by default, which avoids the Turkish dotless-i problem that catches culture-sensitive implementations — but also means it is not correct for Turkish. Nobody gets this entirely right; Roc has simply not shipped its attempt yet.
Numbers
Integer types say their width
Integer types name their width and signedness: I32, I64, U8. There is no type whose size depends on where the program runs.
void main() { // int is 64-bit on native and a double on the // web, so the same program has two behaviors. final small = 42; final big = 9000000000; print('$small $big'); }
main! = |_args| { small : I32 small = 42 big : I64 big = 9_000_000_000 echo!("${small.to_str()} ${big.to_str()}") Ok({}) }
Dart has one integer type whose behavior depends on the target — 64-bit two's complement when compiled natively, and an IEEE double when compiled to JavaScript, where integers above 2^53 lose precision. A Flutter developer shipping to both meets this as a bug that reproduces on only one platform.
Integer and fractional division
Roc has two division operators: // truncates and gives an integer, / gives a fraction. % is the remainder.
void main() { // ~/ truncates, / always produces a double. print(7 ~/ 2); print(7.0 / 2.0); print(7 % 2); }
main! = |_args| { whole : I64 whole = 7 echo!((whole // 2).to_str()) echo!((7.0 / 2.0).to_str()) echo!((whole % 2).to_str()) Ok({}) }
Dart made the same choice with different spelling — ~/ for truncating division — and it is one of the places Dart is ahead of most of its relatives: 7 / 2 is 3.5 rather than silently truncating because both operands happen to be integers.
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.
void main() { // double is binary floating point, so this is // the familiar answer. print(0.1 + 0.2); }
main! = |_args| { # An unsuffixed fractional literal is Dec, Roc's # exact decimal type, so this is exact. echo!((0.1 + 0.2).to_str()) approximate : F64 approximate = 0.1 echo!((approximate + 0.2).to_str()) Ok({}) }
Dart has only double in the core library, so exact decimal arithmetic means a package — and in a language used heavily for apps that display prices, that default is worth noticing. 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".
void main() { // tryParse returns int? — the good version. // parse throws FormatException. final value = int.tryParse('42'); print(value == null ? 'not a number' : value); }
main! = |_args| { # One function, and the failure is in the answer. match I64.from_str("42") { Ok(value) => echo!(value.to_str()) Err(_) => echo!("not a number") } Ok({}) }
Dart's tryParse is the same idea expressed with nullability, and because Dart's null safety is sound the check is enforced rather than advised. The difference left is that parse also exists beside it, so the careless spelling is still available.
async & Isolates vs the ! Marker
The ! marker colors a function
A name ending in ! performs effects, and only another ! function may call it. It is Dart's async rule applied to input and output rather than to waiting.
Future<void> announce(String message) async { await Future<void>.delayed(Duration.zero); print(message); } void main() async { // async marks a function for ONE effect — // waiting. Calling it without await gives you a // Future rather than a value. await announce('starting'); }
# ! marks a function for EVERY effect, and the rule # is the same: only a ! function may call one. announce! = |message| { echo!(message) Ok({}) } main! = |_args| { announce!("starting")? Ok({}) }
Dart developers know this rule as function coloring, usually as a complaint — async spreads up the call stack until it reaches main. Roc makes the same trade deliberately and gets more for it: a signature without ! is a promise that the function reads nothing, writes nothing and prints nothing.
A function with no ! cannot print
A function whose name has no ! cannot perform an effect. The body above could not print even if it wanted to — the compiler rejects the call, not the output.
// Nothing in this signature rules out printing, // reading a file or starting an isolate. int total(List<int> numbers) { print('(logging from inside)'); return numbers.fold(0, (a, b) => a + b); } void main() { print(total([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. In the Dart column nothing about int total(List<int>) rules out the logging line, which is why "is this safe to call twice" is a question about the body rather than the type.
Isolates, and why they agree with this design
Concurrency in Roc comes from the platform, and the echo platform this page runs on offers only echo!. What the language contributes is that there is no shared mutable state for any concurrency model to have to protect.
void main() { // An isolate has its own heap and shares NOTHING // — values are copied across the boundary, so no // two isolates can race on the same object. print('one isolate, no shared memory'); }
main! = |_args| { # Nothing is shared because nothing is mutable, # so the boundary an isolate draws around a heap # is drawn around every value instead. echo!("one isolate, no shared memory") Ok({}) }
Dart's isolates are the most Roc-like thing in the language: share-nothing, message-passing, no locks and no data races by construction. The costs are the ones that design implies — copying across the boundary, and no cheap way to hand over a large structure, which is why TransferableTypedData exists.
No Streams
Roc has no Stream type and no asynchronous iteration. A sequence that is already available is a list; a sequence arriving over time would be something a platform provides.
Future<void> main() async { final numbers = Stream.fromIterable([1, 2, 3]); await for (final value in numbers) { print(value); } }
main! = |_args| { # There is no Stream in this build and no async # iteration. A sequence of values is a list. numbers : List(I64) numbers = [1, 2, 3] for value in numbers { echo!(value.to_str()) } Ok({}) }
Streams are load-bearing in Flutter — every StreamBuilder, every BLoC, every websocket — and there is no counterpart here. This is a place where the comparison runs out rather than resolving, and it is worth saying so directly.
Runtime: AOT, and the Collector
Both compile ahead of time
A Roc program is compiled to a native binary and starts the way any binary does. There is no virtual machine to launch and nothing to warm up.
void main() { // A release Flutter build is AOT-compiled to a // native binary with no JIT — the same shape a // Roc program has. print('native, no virtual machine'); }
main! = |_args| { # Compiled to a native binary, with no runtime # to start and no bytecode to verify. echo!("native, no virtual machine") Ok({}) }
This is where the argument that works against Java does not work against Dart. A release Flutter build is already AOT-compiled native code with no JIT, so startup time and the absence of a VM are not differences between these two languages. What is left is the collector, which the next row is about.
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.
void main() { final numbers = List.filled(1000, 1); print(numbers.length); // The list becomes garbage here and is freed // whenever the collector next runs — which is // not a point in this program. print('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. echo!("no pause here") Ok({}) }
Dart's collector is generational and tuned hard for exactly Flutter's pattern — many short-lived widget objects per frame — which is the workload where a generational collector is at its best and reference counting at its worst. The honest summary is that Roc trades away that strength for predictability, and which one you want depends on what you are building.
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.
void main() { final numbers = [1, 2, 3]; numbers[0] = 99; print(numbers[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 Dart List whose reference any caller might have kept.
What Dart keeps: hot reload
Roc compiles and runs. There is no mechanism for replacing code in a running program and nothing that preserves state across an edit.
void main() { // The JIT that makes debug builds slower is also // what makes sub-second hot reload possible, with // the running app's state preserved. print('edit, save, see it'); }
main! = |_args| { # Compile and run. There is no incremental # reload of a running program. echo!("edit, save, see it") Ok({}) }
Hot reload is the single feature Flutter developers would miss most, and it is not an accident of tooling — it needs the JIT and the VM that the release build then discards. Dart carries a whole second execution mode so that development can work this way, which is a trade Roc does not currently offer at all.
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.
int doubled(int number) => number * 2; void main() { // A test needs package:test, a test/ directory // and a main of its own before it can run. print(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 package:test dependency and no separate test entry point.
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.
void main() { final count = 3; // assert is stripped from release builds, so the // invariant is checked in debug and not in // production. assert(count > 0); print(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({}) }
Dart's assert is compiled out of release builds by design, on the argument that a shipped app should be fast — which means the invariant you most want checked in production is the one that is not. Flutter's own framework uses it heavily for exactly that reason.
Gotchas for Dart 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.
void main() { // A bare integer literal is an int. final count = 6; print(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.
True is not the same thing as Bool.True
Writing True on its own creates a tag named True, which is not the same type as Bool. Annotating the binding : Bool and writing Bool.False gets the real boolean, which is what ! works on.
void main() { final flag = false; print(!flag); }
main! = |_args| { # A bare True or False is a structural TAG, not # a Bool, and ! does not apply to a tag. flag : Bool flag = Bool.False # Bool has no to_str, so inspect prints it. echo!(Str.inspect(!flag)) Ok({}) }
The two spellings look identical and the error message names a type that appears nowhere in the source, which is what makes this worth knowing before it happens. What Str.inspect prints is the tag's own name, True, where Dart prints the lowercase keyword.
Square brackets do not index a list
numbers[1] is accepted by the parser in this build but does not produce a usable value. get is how a list is indexed, and it returns a Try.
void main() { final numbers = [10, 20, 30]; print(numbers[1]); }
main! = |_args| { numbers : List(I64) numbers = [10, 20, 30] # numbers[1] parses, but does not type-check # into a value you can use. get is the way. echo!(numbers.get(1)?.to_str()) Ok({}) }
This one is worth knowing because the wrong spelling looks right and the complaint arrives from the type checker rather than the parser. Square brackets build a list; they do not read from one.
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.
void main() { var total = 0; for (final number in [1, 2, 3]) { total = total + number; } print(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.
A nominal type has no methods of its own
:= declares a nominal type. It is written and destructured with the UserId.{ … } form, and — measured on this build — that form is not compulsory: a plain record literal is still accepted where the type is expected. What := genuinely changes is that the type derives nothing, and that it may refer to itself. Construction and destructuring both use the UserId.{ … } form.
// An extension type wraps an int with no runtime // cost, and still gets its own identity. extension type UserId(int value) {} void main() { final id = UserId(7); print(id.value); }
UserId := { value : I64 } main! = |_args| { id = UserId.{ value: 7 } # Str.inspect(id) does NOT compile — a nominal # type derives nothing. Reach inside instead. UserId.{ value } = id echo!(value.to_str()) Ok({}) }
The catch is that a nominal type derives no behavior at all — no printing, and nothing a structural record would have had for free. Dart 3.3's extension types are the closest counterpart and the trade is the same: a distinct static identity, and you write what it can do.
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.
// Dart has no error type in the signature at all, // so there is no analogue of this to get wrong. int parse(String text) => int.parse(text); void main() { print(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 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?