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 — stricter than C#'s async, which only requires that await appear in an async method, not that callers of an async method be async.Console.WriteLine("Hello, World!");main! = |_args| {
echo!("Hello, World!")
Ok({})
}The
_args parameter is named with a leading underscore because the signature requires it and this program ignores it. Ok({}) is the return value, where {} is the empty record — Roc's void.Where the standard library comes from
A C# program runs on a runtime that someone else started, and the base class library is available whether the program uses it or not. Roc turns that into a declaration: an application is compiled against a named platform, and the platform supplies the complete list of effects the program may perform.
// The base class library is simply there. File,
// HttpClient, Thread and the collector all exist
// because the runtime started before your code did.
Console.WriteLine("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 is fixed. A C# assembly can reach for
File.ReadAllText at any point and fail at run time if the host forbids it; a Roc application compiled against a platform with no file access cannot be made to name one.How a program reports failure
C# reports failure by throwing, by returning an exit code from
Main (or from top-level statements), or by calling Environment.Exit from wherever the program happens to be. Roc's main! returns a Try — Ok for success, Err for failure — and the platform turns that into whatever the operating system wants.// Environment.Exit or an uncaught exception ends
// the process; the exit code is set on the way out.
Console.WriteLine("all good");
Environment.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 the exit status is a return value, the compiler type-checks it, and there is no way to end the process from deep inside a library while skipping every cleanup step on the way out.
Environment.Exit does exactly that: it skips the finally blocks on the stack (though ProcessExit handlers still run).Comments
Comments start with
#. There is no block form and no documentation-comment syntax, so a multi-line comment is several # lines.// A single-line comment
var count = 42; // an inline comment
/* A block comment,
which can span lines. */
/// <summary>An XML documentation comment.</summary>
Console.WriteLine(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 as in C#. 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.Types & Inference
Inference covers whole programs
C#'s
var stops at the local variable: a method's parameter types and return type are always spelled out (only a lambda infers them). Roc infers across the whole program, so an annotation is a claim you choose to state rather than information the compiler needs.// var infers a local from its initializer, but a method's
// parameter and return types are always written.
int Double(int number) => number * 2;
var result = Double(21);
Console.WriteLine(result);# Roc infers the whole signature from the body and
# the call sites, so the annotation is optional.
double = |number| number * 2
main! = |_args| {
result : I64
result = double(21)
echo!(result.to_str())
Ok({})
}The trade is readability at a distance, and Roc convention answers it the way careful C# does — annotate the top-level functions. The difference is that a Roc annotation is checked against the inferred type rather than being the definition of it.
Records are structural, not nominal
A Roc record type is its set of fields, so a literal with the right fields already is one — nothing is constructed and no type name appears at the call site. C#'s
record is nominal: two records with identical members are different types.string Describe(Point point) =>
$"({point.X}, {point.Y})";
Console.WriteLine(Describe(new Point(1, 2)));
// An identically-shaped record of another name
// would NOT be accepted here.
record Point(int X, int Y);Point : { x : I64, y : I64 }
describe : Point -> Str
describe = |point| "(${point.x.to_str()}, ${point.y.to_str()})"
main! = |_args| {
echo!(describe({ x: 1, y: 2 }))
# Any record with these two fields IS a Point.
Ok({})
}Point here is an alias for a shape, not a declaration of a new type. When you want C#'s nominal behavior — a type that only its own constructor can produce — := gives it to you; the Gotchas section has the worked example.No object, no dynamic, no casts
The square brackets in
[Number(I64), Text(Str)] are a tag union: the parameter is one of exactly two shapes, each carrying its own payload. There is no object to accept anything, no dynamic, no cast and no type test, because a value's type is never in doubt.string Describe(object value) => value switch
{
int number => $"a number: {number}",
string text => $"a string: {text}",
_ => "something else",
};
Console.WriteLine(Describe(42));
Console.WriteLine(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, because its type says there are exactly two cases. The C# version needs a third arm for everything it was not designed for, and that arm is where a mistake lands silently — Describe(2.5m) compiles and returns "something else".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 the same idea as a C# using alias.// using-alias names an existing type; it creates
// no new type and no runtime representation.
using UserId = System.Int32;
UserId id = 7;
int plain = id; // the same type, so this is fine
Console.WriteLine(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, C# reaches for a wrapper type and Roc for :=; both cost a construction step.No Null, So No Nullable Types
There is no null
Roc has 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 match must cover every tag.#nullable enable
// The ? says this one may be null. Without it the
// compiler WARNS on null — it does not refuse.
string? maybeName = null;
// Forgetting the check is a warning, not an error.
Console.WriteLine(maybeName?.Length ?? 0);main! = |_args| {
# There is no null literal to write, no ? to
# annotate with, and no ?. to guard with.
# Absence is a value you construct on purpose.
maybe_name : [Some(Str), None]
maybe_name = None
length = match maybe_name {
Some(name) => name.count_utf8_bytes()
None => 0
}
echo!(length.to_str())
Ok({})
}C# 8 added nullable reference types to describe a hole that was already in every reference slot, and the retrofit shows: the compiler warns rather than refusing,
! silences the warning, and the annotations are recorded in metadata but never enforced at run time, so a value arriving from a library compiled without them is neither checked nor trusted — it is simply unknown.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. There is no second method for the careful caller, because there is no careless one.var ages = new Dictionary<string, int>
{
["alice"] = 30,
};
// TryGetValue exists because the indexer throws
// and GetValueOrDefault would return a silent 0.
if (ages.TryGetValue("bob", out var age))
Console.WriteLine($"bob is {age}");
else
Console.WriteLine("bob is not listed");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({})
}C# offers three ways to read a dictionary and they fail differently: the indexer throws
KeyNotFoundException, TryGetValue returns a bool and an out parameter, and GetValueOrDefault hands back 0 for a missing int — indistinguishable from a stored zero.Nullable value types
I64.from_str returns a Try, the same shape Dict.get returned on the previous row. Roc needs no separate mechanism for numbers, because a number was never nullable and a string never was either.// int? is Nullable<int>, a real struct with
// HasValue and Value — a different mechanism from
// the reference-type ? on the previous row.
int? parsed = int.TryParse("17", out var number) ? number : null;
Console.WriteLine(parsed.HasValue
? $"parsed {parsed.Value}"
: "no number");main! = |_args| {
# One mechanism covers both: I64 and Str use the
# same tags, because neither has a hole in it to
# start with.
parsed = I64.from_str("17")
match parsed {
Ok(number) => echo!("parsed ${number.to_str()}")
Err(_) => echo!("no number")
}
Ok({})
}C# has two:
string? is a compile-time annotation on a reference, recorded in metadata but never enforced at run time, while int? is Nullable<int>, a genuine struct with a boolean field. They share a spelling and almost nothing else, which is why T? in a generic method needs the where T : struct or where T : class constraint to mean anything definite.There is no default(T)
Every Roc value must be constructed, so there is no
default(T) to fall back on and no zero-initialized type. List.first answers with a Try, which distinguishes an empty list from a list whose first element happens to be zero.var empty = new List<int>();
// FirstOrDefault hands back 0 for an empty list of
// int — the same 0 a stored zero would give.
var first = empty.FirstOrDefault();
Console.WriteLine($"first: {first}");main! = |_args| {
empty : List(I64)
empty = []
# first returns a Try, so "there was none" and
# "the first one is zero" are different answers.
match empty.first() {
Ok(value) => echo!("first: ${value.to_str()}")
Err(_) => echo!("first: the list is empty")
}
Ok({})
}This row is the one place the C# and Roc columns print different text, and that is the lesson: a C# program using
FirstOrDefault() on an int list cannot print the second sentence, because by the time it has a value it can no longer tell the two cases apart (asking Any() first, or passing a default such as FirstOrDefault(-1), is how C# code does). FirstOrDefault is where a great many silent zeroes come from.Values, Not References
Every value is a value
Roc has no reference types, so assignment never creates an alias.
{ ..original, count: 99 } builds a new record from an old one; the original is untouched, and there is no syntax that would touch it.var original = new Counter { Count = 1 };
var copy = original;
copy.Count = 99;
// Counter is a class, so both names point at one
// object and the first one sees the change.
Console.WriteLine(original.Count);
class Counter { public int Count; }main! = |_args| {
original = { count: 1.I64 }
copy = { ..original, count: 99 }
# copy is a new record. Nothing in Roc can make
# two names refer to one mutable value.
echo!(original.count.to_str())
Ok({})
}C# makes you choose. A
class gives reference semantics and the aliasing above; a struct gives value semantics but copies on every assignment and every argument pass, which is why the guidance is to keep them small and make them readonly. Roc removes the choice, and copy-on-write in the compiler removes the cost it would otherwise imply.readonly protects the field, not the object
Roc 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.
append returns a new list rather than modifying one.var basket = new Basket();
// The field cannot be reassigned…
// basket.Items = new List<string>(); // error
// …but the list it points at is wide open.
basket.Items.Add("apple");
Console.WriteLine(basket.Items.Count);
class Basket
{
public readonly List<string> Items = new();
}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({})
}readonly in C# protects only the binding, so a readonly List<string> is a field you cannot reassign holding a collection anyone can empty. This is the same shape as const in JavaScript and val in Kotlin, and it is the gap ImmutableList<T> exists to close, one type at a time (ReadOnlyCollection<T> only narrows it: it is a read-only view over a list its owner can still change).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, so there is nothing to defend against.
var report = new Report(new List<int> { 1, 2, 3 });
var scores = report.Scores;
scores.Add(4);
// The caller just changed the report's own list.
Console.WriteLine(report.Scores.Count);
class Report(List<int> scores)
{
public List<int> Scores { get; } = scores;
}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 C# property here is
get-only and it still leaks: the getter returns the same List<int> the object holds. Returning scores.ToList() (a copy on every read) or AsReadOnly() (a read-only view) is the standard fix, and one the author has to remember to write.Records vs Records
Declaring and building a record
A record literal is written with braces and
field: value pairs, and fields are read with a dot. The type line above is optional documentation — the literal would have the same type without it.var alice = new Person("Alice", 30);
Console.WriteLine($"{alice.Name} is {alice.Age}");
record Person(string Name, int Age);Person : { name : Str, age : I64 }
main! = |_args| {
alice : Person
alice = { name: "Alice", age: 30 }
echo!("${alice.name} is ${alice.age.to_str()}")
Ok({})
}C# 9's positional record generates the constructor, the properties,
Equals, GetHashCode, ToString and a deconstructor. Roc generates none of those because it needs none: a record is structural, so equality and printing are defined on the shape rather than on a declaration.Non-destructive update
{ ..alice, age: 31 } copies every field of alice and overrides the ones named after it. C# spells the same operation alice with { Age = 31 }.var alice = new Person("Alice", 30);
var older = alice with { Age = 31 };
Console.WriteLine($"{alice.Age} then {older.Age}");
record Person(string Name, int Age);main! = |_args| {
alice = { name: "Alice", age: 30.I64 }
older = { ..alice, age: 31 }
echo!("${alice.age.to_str()} then ${older.age.to_str()}")
Ok({})
}The two are the same idea and they arrived from opposite directions: C# added
with in version 9 for the case where copying is what you want, while Roc has no other kind of update, so the shorthand is the only way to change a field at all.Equality compares contents
== compares contents, always, for every type. Str.inspect turns the resulting Bool into text method.var first = new Person("Alice", 30);
var second = new Person("Alice", 30);
// True for a record; False if Person were a class.
Console.WriteLine(first == second);
record Person(string Name, int Age);main! = |_args| {
first = { name: "Alice", age: 30.I64 }
second = { name: "Alice", age: 30 }
# Always contents — there is no identity to
# compare instead.
echo!(Str.inspect(first == second))
Ok({})
}In C# it depends on the declaration: a
record compares by value, a class by reference, and a struct by value through a reflection-based default that is slow enough that the guidance is to override it. Roc has one answer because it has no object identity for the other answer to be about.Taking a record apart
Writing a record pattern on the left of
= pulls fields out and binds each to a name of its own. The names come from the fields, so the order in the pattern does not matter.var alice = new Person("Alice", 30);
var (name, age) = alice;
Console.WriteLine($"{name}/{age}");
record Person(string Name, int Age);main! = |_args| {
alice = { name: "Alice", age: 30.I64 }
{ name, age } = alice
echo!("${name}/${age.to_str()}")
Ok({})
}C#'s positional deconstruction is by position, which means reordering the record's parameters (renaming does not matter) silently changes what every
var (a, b) = … site binds. Roc matches on the field name, so the same edit is a compile error at each site instead.Classes & Interfaces vs Tags
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.var account = new Account(100);
Console.WriteLine(account.Withdraw(30).Balance);
class Account(int balance)
{
public int Balance { get; } = balance;
public Account Withdraw(int amount) =>
new Account(Balance - amount);
}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.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 |>.var numbers = new List<int> { 1, 2, 3 };
// An extension method makes a static function
// read as though it belonged to the type: Sum is
// Enumerable.Sum(this IEnumerable<int>).
Console.WriteLine(numbers.Sum());
// A plain static method is called the other way
// round, whatever it does.
Console.WriteLine(Total(numbers));
static int Total(List<int> values) => values.Sum();total : List(I64) -> I64
total = |numbers| numbers.sum()
main! = |_args| {
numbers : List(I64)
numbers = [1, 2, 3]
# numbers.sum() works because List declares sum.
# 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.sum().to_str())
echo!((numbers |> total).to_str())
Ok({})
}C# wins this one. An extension method needs a static class, a
this modifier and a using for the right namespace — and in exchange the call site reads the way the reader expects, and the method appears in completion on the type. |> recovers the chaining and not the discoverability.Methods on a type you declare
A nominal type is declared with
:=, and a trailing .{ … } block holds the functions that belong to it. Account.new(100) reaches one through the type name, the way a static factory method does; .withdraw(30) and .describe() reach the same block through a value of that type, which is what lets the calls chain left to right.Console.WriteLine(new Account(100).Withdraw(30).Describe());
class Account
{
private readonly int balance;
public Account(int balance) => this.balance = balance;
public Account Withdraw(int amount) => new Account(balance - amount);
public string Describe() => $"balance is {balance}";
}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 chain reads like the C# one, and the resemblance stops there. Every function in the block is an ordinary function whose first parameter happens to be an
Account — there is no this to write, nothing is virtual, and nothing inherits. The block decides where a function is found, not how the call is dispatched; each of these resolves at compile time exactly as a free function would.An interface hierarchy becomes a tag union
A tag union lists the cases in one line, and one
match handles them all. Circle(2.0) needs no declaration beyond the union — a tag is created by writing it.Shape[] shapes = [new Circle(2), new Rect(3, 4)];
foreach (var shape in shapes)
Console.WriteLine(shape.Area());
interface Shape { double Area(); }
record Circle(double R) : Shape
{
public double Area() => 3.14159 * R * R;
}
record Rect(double W, double H) : Shape
{
public double Area() => W * H;
}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| {
for shape in [Circle(2.0), Rect(3.0, 4.0)] {
echo!(area(shape).to_str())
}
Ok({})
}The two designs are open in opposite directions, which is the real trade. C# makes it easy to add a shape (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 a shape (edit every match). Neither is better; they fail in different places — and C# cannot make the union choice checked, since the compiler never treats a class hierarchy as closed.
No inheritance, and nothing virtual
There is no base type, no
virtual 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.Animal pet = new Dog();
// Which Speak runs is decided at run time by the
// object's actual type.
Console.WriteLine(pet.Speak());
class Animal { public virtual string Speak() => "..."; }
class Dog : Animal
{
public override string Speak() => "Woof";
}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 thing C# has that this does not is an open set: a fourth animal can arrive from another assembly at run time and the existing code will call its
Speak. Roc trades that for exhaustiveness — nobody can add a case behind your back, and nobody can forget one either.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.// A C# hierarchy can be closed (a private constructor)
// or left open, but the compiler checks neither, and
// there is no way to say "these two, plus whatever
// the caller adds".
Console.WriteLine(Describe(Level.Warning));
string Describe(Level level) => level switch
{
Level.Warning => "warning",
_ => "other",
};
enum Level { Warning, Error }# 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({})
}C# has no way to write that type. An
enum is closed, an interface is open but requires every case to be a declared type implementing it, and neither lets a caller invent a case at the call site. Being able to choose per function is the part of Roc's tag system with no C# counterpart at all.switch Expressions vs match
switch expression vs match
match is an expression, like C#'s switch expression: each arm is pattern => value, and the arms are separated by newlines rather than commas.string Name(int code) => code switch
{
200 => "OK",
404 => "Not Found",
_ => "Unknown",
};
Console.WriteLine(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 C# 8's switch expression took from this tradition. The difference is what happens when a case is missing, and the next row is about that.
A missing case is an error, not a warning
A
match must cover every case the type allows. Leaving one out is a compile error with the missing pattern named, not a diagnostic you can build through.// Handling all three named members still warns
// (CS8524), because (Color)99 is a legal value;
// handling only two warns CS8509. The program
// builds either way, and throws
// SwitchExpressionException if an unhandled value arrives.
Console.WriteLine(Describe(Color.Red));
string Describe(Color color) => color switch
{
Color.Red => "red",
Color.Green => "green",
Color.Blue => "blue",
};
enum Color { Red, Green, Blue }Color : [Red, Green, Blue]
describe : Color -> Str
describe = |color| match color {
Red => "red"
Green => "green"
Blue => "blue"
}
main! = |_args| {
# Delete any arm above and this does not build.
echo!(describe(Red))
Ok({})
}C#'s check is a warning by default, and an
enum makes it weaker still: (Color)99 is a legal value of the type, so no switch over an enum can ever be complete. Adding a fourth color to a Roc union breaks the build at every match that needs updating, which is the refactoring tool this comparison is really about.Matching inside a structure
A record pattern matches field by field, and
_ stands for a field this arm does not care about. Every arm has to name the same fields — a pattern that mentions only x describes a different, open record type and will not sit alongside one that mentions both.Console.WriteLine(Describe(new Point(0, 5)));
string Describe(Point point) => point switch
{
{ X: 0, Y: 0 } => "origin",
{ X: 0 } => "on the y axis",
{ Y: 0 } => "on the x axis",
_ => "somewhere else",
};
record Point(int X, int Y);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({})
}That is the one place C# is more forgiving: its property pattern
{ X: 0 } simply ignores the rest, so the arms need not agree on which members they name. Otherwise this is among the closest correspondences on the page, because C# 8 and 9 took property, positional and relational patterns from exactly this tradition.Guards on an arm
An arm may carry an
if guard: the pattern binds first, then the guard decides whether this arm runs. Roc has no relational pattern, so a comparison is written as a guard on a bound name.Console.WriteLine(Classify(0));
Console.WriteLine(Classify(42));
string Classify(int number) => number switch
{
0 => "zero",
> 0 => "positive",
_ => "negative",
};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 — the compiler cannot know whether
n > 0 and its absence cover everything — so a guard needs the final _. C#'s relational patterns (> 0, < 0) are the shorter spelling for exactly this case, and are checked: arms that cover every integer need no _.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 everything that can go wrong. Failure is data returned from the function, not a control-flow event that leaves it.// The signature is string -> int. It says nothing
// about FormatException, and C# has no checked
// exceptions, so nothing makes the caller care.
Console.WriteLine(Parse("17"));
int Parse(string text) => int.Parse(text);# 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({})
}C# dropped checked exceptions on purpose, and the reasoning was sound — Java's version pushed people toward empty
catch blocks and throws Exception. The cost is that no C# signature tells you what it throws, so the knowledge lives in documentation, and documentation is not checked against the code.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.try
{
var number = int.Parse("oops");
Console.WriteLine(number);
}
catch (FormatException)
{
Console.WriteLine("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 C#
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 instead of rethrowing
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.
Console.WriteLine(Sum("3", "4"));
int Sum(string left, string right) =>
int.Parse(left) + int.Parse(right);# ? returns early with the Err, so the happy path
# reads straight down and the failure is explicit.
sum : Str, Str -> Try(I64, [BadNumStr])
sum = |left, right| {
first = I64.from_str(left)?
second = I64.from_str(right)?
Ok(first + second)
}
main! = |_args| {
match sum("3", "4") {
Ok(total) => echo!(total.to_str())
Err(_) => echo!("bad input")
}
Ok({})
}C# 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. Roc propagates by writing one character, which is short enough not to be a burden and visible enough to read.
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, no base type to inherit and no constructor.try
{
throw new WithdrawalException(50);
}
catch (WithdrawalException error)
{
Console.WriteLine($"short by {error.Shortfall}");
}
class WithdrawalException(int shortfall) : Exception
{
public int Shortfall { get; } = 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({})
}The payload is matched out by position, so the shortfall is a plain
I64 by the time it is used. A C# exception carries a stack trace along with it, which Roc's tag does not; that is a genuine loss for debugging and a genuine saving in the cost of returning one.When there is genuinely no recovery
crash ends the program with a message. It is not an exception: nothing catches it, and it has no type, so it can stand wherever any value is expected.// The .NET equivalent of "stop now": no catch
// handler runs and the process ends.
Console.WriteLine("about to fail");
Environment.FailFast("the configuration is impossible");main! = |_args| {
echo!("about to fail")
crash "the configuration is impossible"
}Both languages keep a way out for the case that really is unrecoverable, and both make it deliberately unlike ordinary error handling so it is not reached for by habit. A
crash in Roc is a statement that this state was supposed to be impossible.IDisposable vs Reference Counting
Why there is no using statement
Roc frees a value when the last reference to it goes away, at a point the compiler decides while compiling. There is no
using statement, no IDisposable and no finalizer, because release is not on a separate schedule from the value.// using exists because the collector frees memory
// on its own schedule and releases a file handle
// only when a finalizer eventually runs.
using (var writer = new StringWriter())
{
writer.Write("buffered");
Console.WriteLine(writer.ToString());
}main! = |_args| {
# Every value is freed the moment its count
# reaches zero, so there is no scope to open and
# nothing to remember to close.
buffer = Str.join_with(["buffered"], "")
echo!(buffer)
Ok({})
}The C# pattern exists for one reason: the collector manages memory and nothing else, so a socket or a file handle would sit open until a finalizer eventually ran. Deterministic release makes the whole apparatus — the interface, the statement, the analyzer that warns when you forget — unnecessary rather than automatic.
No finally, because nothing unwinds
Roc has no
finally, and the reason is that it has nothing to protect against: no exception can unwind through a function, so the only way out of a block is the path written in it.try
{
Console.WriteLine("working");
}
finally
{
// Runs however the block is left — return,
// exception, or falling off the end (not when the
// process is ended by Environment.Exit or FailFast).
Console.WriteLine("cleaning up");
}main! = |_args| {
echo!("working")
# Nothing can leave a block early except a
# return you wrote, so there is no "however it
# was left" for a finally to cover.
echo!("cleaning up")
Ok({})
}A
finally block is insurance against an invisible exit. It is also its own hazard — a throw inside finally replaces the exception that was propagating (C# at least forbids return there). Removing invisible exits removes both the need and the hazard.LINQ vs List Functions
Select and Where
keep_if is Where and map is Select, chained with the same dot syntax. Each one returns a new list immediately, so there is no ToList to finish with.var numbers = new List<int> { 1, 2, 3, 4, 5 };
var result = numbers
.Where(n => n % 2 == 1)
.Select(n => n * 10)
.ToList();
Console.WriteLine(string.Join(", ", result));main! = |_args| {
numbers : List(I64)
numbers = [1, 2, 3, 4, 5]
result = numbers
.keep_if(|number| number % 2 == 1)
.map(|number| number * 10)
echo!(Str.join_with(result.map(|number| number.to_str()), ", "))
Ok({})
}The names differ because Roc names the effect rather than the SQL clause — a C# programmer reading
keep_if for the first time needs no gloss, which is more than Select can claim. string.Join converts each element for you; Str.join_with takes a list of strings, so the numbers are converted first.Eager, not deferred
Roc's list functions run when they are written and return a finished list. There is no query object, no deferred execution and no second evaluation.
var numbers = new List<int> { 1, 2, 3 };
// Nothing has run yet — Where returns a query.
var query = numbers.Where(n => n > 1);
numbers.Add(4);
// Running it NOW sees the 4 that was added after
// the query was written.
Console.WriteLine(string.Join(", ", query));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!(Str.join_with(kept.map(|number| number.to_str()), ", "))
echo!(longer.len().to_str())
Ok({})
}LINQ's laziness is what makes
Where(…).First() stop early on a million-row sequence, and it is also why the C# column prints 2, 3, 4 — the query was written before the 4 existed and ran after. Enumerating twice runs the query twice, and enumerating a query built from a database connection that has since closed throws at the foreach rather than where the query was built.Aggregate and Sum
fold takes a starting value and a function of the running total and the next element — the same two arguments Aggregate takes, in the same order.var numbers = new List<int> { 1, 2, 3, 4 };
Console.WriteLine(numbers.Sum());
Console.WriteLine(numbers.Aggregate(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 one-argument
Aggregate overload, which uses the first element as the seed, throws on an empty sequence. Roc has no such overload, so the seed is always written and the empty case is always the seed.Sorting with a comparison
sort_by takes a key function and orders by what it returns — the same shape as OrderBy. When a key is not enough, sort_with takes a comparison returning one of three tags, Before, After or Same, rather than a negative, zero or positive number.var words = new List<string> { "pear", "fig", "apple" };
var byLength = words
.OrderBy(word => word.Length)
.ToList();
Console.WriteLine(string.Join(", ", byLength));main! = |_args| {
words = ["pear", "fig", "apple"]
by_length = words.sort_by(|candidate| candidate.count_utf8_bytes())
echo!(Str.join_with(by_length, ", "))
Ok({})
}The two columns line up almost exactly here. Where they part is the general form underneath: C#'s
IComparer returns an int whose sign carries the meaning, so one that returns it backwards compiles and sorts incorrectly, while Roc's three tags cannot be written the wrong way round and the compiler checks that all three are handled.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.var numbers = new List<int> { 1, 3, 8, 5 };
// First throws when nothing matches;
// FirstOrDefault returns a silent 0.
var found = numbers.FirstOrDefault(n => n % 2 == 0);
Console.WriteLine(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({})
}C# offers the pair, and choosing between them is choosing which failure mode you prefer:
First throws InvalidOperationException with a message that names nothing about the query, and FirstOrDefault hands back a value that is indistinguishable from a real match on a list of integers.Collections
Building and reading a list
A list literal is square brackets,
len is the count, and get reads by index. get returns a Try rather than the element, because the index might be past the end.var numbers = new List<int> { 10, 20, 30 };
Console.WriteLine(numbers.Count);
Console.WriteLine(numbers[1]);
Console.WriteLine(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.
match numbers.get(1) {
Ok(value) => echo!(value.to_str())
Err(_) => echo!("out of range")
}
echo!(Str.inspect(numbers.contains(30)))
Ok({})
}This is the row where Roc asks for the most typing in exchange for the least drama:
numbers[1] in C# is either the element or an ArgumentOutOfRangeException that ends the program, and the difference does not appear in the source. Str.inspect prints the Bool, which has no to_str of its own.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.var numbers = new List<int> { 1, 2 };
numbers.Add(3);
// Add mutated the list in place and returned void.
Console.WriteLine(numbers.Count);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 that this copies on every append, and it usually does not: the compiler tracks how many references a list has, and when the old one is about to go out of scope it reuses the same memory. The Memory section shows that directly.
Dictionary
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.var ages = new Dictionary<string, int>
{
["alice"] = 30,
["bob"] = 25,
};
Console.WriteLine(ages.Count);
Console.WriteLine(ages["alice"]);main! = |_args| {
ages =
Dict.empty()
.insert("alice", 30.I64)
.insert("bob", 25)
echo!(ages.len().to_str())
match ages.get("alice") {
Ok(age) => echo!(age.to_str())
Err(_) => echo!("missing")
}
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, and the Gotchas section returns to it.Tuples
A tuple is written with parentheses and taken apart by writing a tuple pattern on the left of
=. Elements are reached by destructuring rather than by a positional name.var pair = (Name: "Alice", Age: 30);
Console.WriteLine($"{pair.Name} is {pair.Age}");
var (name, age) = pair;
Console.WriteLine($"{name}/{age}");main! = |_args| {
pair = ("Alice", 30.I64)
(name, age) = pair
echo!("${name} is ${age.to_str()}")
echo!("${name}/${age.to_str()}")
Ok({})
}C#'s named tuple elements have no counterpart, and that is deliberate rather than missing: a tuple whose parts deserve names is a record in Roc, where the names are part of the type and travel with the value instead of existing only as metadata for callers and vanishing from the run-time value the way C#'s do.
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 any one type, used consistently. There is no <T> list to declare — a lowercase name in a type position simply is one.T FirstOr<T>(List<T> items, T fallback) =>
items.Count > 0 ? items[0] : fallback;
Console.WriteLine(FirstOr(new List<int>(), 7));
Console.WriteLine(FirstOr(new List<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({})
}Both versions are checked once and work for every type. The difference is at run time: C# generics are reified, so
List<int> is a distinct type the runtime knows about and can reflect over, while Roc erases the variable entirely after monomorphizing, which is why there is no typeof(T) 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.// where says what T must support before the body
// may use it.
T Largest<T>(List<T> items, T seed) where T : IComparable<T>
{
var best = seed;
foreach (var item in items)
if (item.CompareTo(best) > 0) best = item;
return best;
}
Console.WriteLine(Largest(new List<int> { 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({})
}The two do the same job and differ in what they name. C#'s constraint is nominal: a type must declare that it implements
IComparable<T>, which is why retrofitting one onto a type you do not own is impossible and why IComparer exists as the escape hatch. Roc's is structural: anything with an is_gt method qualifies without declaring anything — and equally, 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.var holder = new Holder<int>(42);
Console.WriteLine(holder.Value);
record Holder<T>(T 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 } is already a Holder(I64) without being constructed as one — the annotation names the shape rather than selecting a class. C#'s Holder<int> is a distinct runtime type produced only by its constructor or a with copy. The name matters more than it looks: Roc's standard library already has a Box, and an alias that shadows a builtin is reported as one.Strings
String interpolation
Interpolation is
${…} inside an ordinary string. Unlike C#'s $"…", it accepts only a Str — there is no implicit conversion, so anything that is not already text is converted explicitly.var name = "Alice";
var age = 30;
// Every part is converted to text (ToString, unless the
// type is IFormattable or ISpanFormattable).
Console.WriteLine($"{name} is {age}");main! = |_args| {
name = "Alice"
age = 30.I64
# 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, which is the second most common thing to trip over on this page. The strictness has a point: C#'s interpolation converts whatever it is given, by
ToString unless the type formats itself, and the default ToString on a class prints its type name.Splitting and joining
split_on takes a string separator, not a character — Roc has no character type distinct from a one-character string — and Str.join_with puts a list back together.var line = "red,green,blue";
var parts = line.Split(',');
Console.WriteLine(parts.Length);
Console.WriteLine(string.Join(" | ", parts));main! = |_args| {
line = "red,green,blue"
parts = line.split_on(",")
echo!(parts.len().to_str())
echo!(Str.join_with(parts, " | "))
Ok({})
}The argument order is worth noting:
Str.join_with(parts, " | ") has the list first and the separator second, the opposite of string.Join. It reads naturally in dot form as parts.join_with(" | ").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.var text = "café";
// Length counts UTF-16 code units, which is why
// some emoji count as two.
Console.WriteLine(text.Length);main! = |_args| {
text = "café"
# Roc strings are UTF-8 and the method says so.
echo!(text.count_utf8_bytes().to_str())
Ok({})
}The two columns print different numbers and both are correct —
é is one UTF-16 code unit and two UTF-8 bytes. The naming is the real difference: Length sounds like the number of characters and is not, which is how string-handling bugs survive review.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.var builder = new StringBuilder();
foreach (var word in new[] { "a", "b", "c" })
builder.Append(word);
Console.WriteLine(builder.ToString());main! = |_args| {
built = ["a", "b", "c"].fold("", |running, word|
running.concat(word))
echo!(built)
Ok({})
}The reason C# needs
StringBuilder is that string is immutable and += in a loop allocates on every pass. Roc's strings are immutable too, but the compiler sees that the accumulated string has one reference and appends into it, so the naive loop is already the efficient one.Converting case
Roc's case conversion is
with_ascii_uppercased and with_ascii_lowercased, and the names are the whole story: they change the 26 ASCII letters and pass every other character through untouched.var text = "Hello";
// ToUpper without a culture uses the current one,
// which is why the invariant overload exists.
Console.WriteLine(text.ToUpperInvariant());main! = |_args| {
text = "Hello"
# Only the 26 ASCII letters change; "héllo"
# comes back as "HéLLO".
echo!(text.with_ascii_uppercased())
Ok({})
}C# has the full answer and also the trap it creates:
ToUpper() uses the current culture, so under Turkish casing rules the dotted "i" uppercases to "İ", not "I", and breaks case-insensitive comparisons of identifiers. ToUpperInvariant exists entirely because of that. with_ascii_uppercased cannot fall into it: it converts ASCII letters and nothing else, which is exactly right for identifiers.Numbers
Integer types
Integer types are named for their width and signedness:
I32 is C#'s int, I64 its long, U8 its byte. Underscores group digits in both languages.int small = 42;
long big = 9_000_000_000L;
byte tiny = 255;
Console.WriteLine($"{small} {big} {tiny}");main! = |_args| {
small : I32
small = 42
big : I64
big = 9_000_000_000
tiny : U8
tiny = 255
echo!("${small.to_str()} ${big.to_str()} ${tiny.to_str()}")
Ok({})
}The naming is the improvement:
int, long and byte have to be memorized, and long means something different in C. A literal too large for its annotated type is a compile error in both languages; the silent wrap C# gives you outside a checked block applies to arithmetic at run time.Integer and fractional division
Roc has two division operators, though not the split C# readers might hope for:
// always truncates toward zero, and / follows the operand type — on integers it discards the remainder exactly as C# does, and on Dec and floats it gives a fraction. % is the remainder.// / is integer division between two ints and
// fractional between two doubles — the same
// operator, decided by the operand types.
Console.WriteLine(7 / 2);
Console.WriteLine(7.0 / 2.0);
Console.WriteLine(7 % 2);main! = |_args| {
whole : I64
whole = 7
# // always truncates. / follows the operand
# type: it truncates on integers, as in C#,
# and gives a fraction on Dec and floats.
echo!((whole // 2).to_str())
echo!((7.0 / 2.0).to_str())
echo!((whole % 2).to_str())
Ok({})
}C# decides from the operand types, so
total / count silently truncates when both happen to be int — a bug that survives review because the source looks like the fractional division the author meant. Roc's / on two I64 values truncates the same way. What protects a Roc programmer more often is the default: a number literal with no annotation is a Dec, so 7 / 2 written with bare literals is 3.5.Decimal arithmetic
Roc's
Dec is a fixed-point decimal, and it is what a fractional literal becomes unless something says otherwise. Asking for binary floating point means annotating F64.// double gets this wrong; decimal stores 0.1 and 0.2 exactly, and
// getting money right means remembering to say so.
Console.WriteLine(0.1 + 0.2);
Console.WriteLine(0.1m + 0.2m);main! = |_args| {
# An unsuffixed fractional literal is already
# Dec, Roc's exact decimal type.
echo!((0.1 + 0.2).to_str())
approximate : F64
approximate = 0.1
echo!((approximate + 0.2).to_str())
Ok({})
}The defaults are the opposite way round, and Roc's is the safer one: C#'s bare
0.1 is a double, so 0.1 + 0.2 prints 0.30000000000000004 unless the author remembered the m suffix. 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 of .from_str is what decides the result type, so U8.from_str rejects "300".// Two ways to parse, with three things to remember:
// Parse throws, TryParse returns a bool and an out
// parameter, and the result of a failed TryParse
// is a zero you must not read.
if (int.TryParse("42", out var value))
Console.WriteLine(value);
else
Console.WriteLine("not a number");main! = |_args| {
# One way, and the failure is in the answer.
match I64.from_str("42") {
Ok(value) => echo!(value.to_str())
Err(_) => echo!("not a number")
}
Ok({})
}The
out parameter in the C# version is the shape of a language that had no way to return two things: TryParse predates tuples, nullable value types were awkward, and the pattern stuck. A Try return needs none of that machinery.async/await vs the ! Marker
The ! marker colors a function
A name ending in
! performs effects, and only another ! function may call it. It is like C#'s async applied to input and output rather than to waiting, but enforced up the whole call chain.// async marks a function for ONE effect — waiting.
// A non-async method may still call it: return the
// task, block on .Result (a deadlock risk under a
// synchronization context), or discard it — only
// await requires the caller to be async.
await Announce("starting");
async Task Announce(string message)
{
await Task.Yield();
Console.WriteLine(message);
}# ! 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({})
}C# programmers 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 call echo! even if it wanted to: the compiler rejects the call rather than the output.// Nothing in the signature stops this method from
// writing to the console, opening a file or
// starting a thread.
Console.WriteLine(Total(new List<int> { 1, 2, 3 }));
int Total(List<int> numbers)
{
Console.WriteLine("(logging from inside)");
return numbers.Sum();
}# No ! in the name, so the body cannot call echo!
# — the compiler rejects it. 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 C# column nothing about
int Total(List<int>) rules out the logging line, which is why "is this method safe to call twice" is a question about the body rather than the signature.No Task, and no concurrency in this platform
Concurrency in Roc comes from the platform, and the echo platform this page runs on offers only
echo!. A platform built for a web server would supply its own effects for doing several things at once.var first = Task.Run(() => "from a thread");
Console.WriteLine(await first);main! = |_args| {
# The echo platform provides exactly one effect,
# so there is no Task, no thread and no way to
# start one. Concurrency is something a platform
# offers, not something the language has.
echo!("from the only thread there is")
Ok({})
}The division is the interesting part. C# puts
Task, the thread pool and the synchronization context in the base class library, so every program has them whether it wants them or not; Roc puts them in the platform, so a program compiled against a single-threaded one cannot name a thread at all.The Collector vs Reference Counting
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.
var numbers = Enumerable.Repeat(1, 1000).ToList();
Console.WriteLine(numbers.Count);
// The list becomes garbage here and is freed
// whenever the collector next runs — which is not
// a point in this program.
Console.WriteLine(true);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!(Str.inspect(Bool.True))
Ok({})
}The cost is real and worth naming: reference counting pays a little on every copy and cannot free a cycle. Roc gets away without a cycle collector because nothing in the language can construct one — a value can only refer to values that already existed when it was built.
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. The ? unwraps it and returns early on failure. When the old list has exactly one reference and is about to go out of scope, the compiler reuses its memory instead of copying, so the generated code is the in-place write C# spells out.var numbers = new List<int> { 1, 2, 3 };
// Writing through the indexer changes the list.
numbers[0] = 99;
Console.WriteLine(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.
updated = numbers.set(0, 99)?
first = updated.get(0)?
echo!(first.to_str())
Ok({})
}This is the answer to the obvious objection that immutability must be slow. It holds precisely because the value is immutable: the compiler can prove nobody else is looking at that list, which is a proof it cannot make about a C#
List<int> that any caller might have kept a reference to.No boxing, because there is nothing to box into
Boxing is the cost of a universal base type, and Roc has none: no
object, no System.ValueType and no reference type for a value to be wrapped in.// Assigning an int to object boxes it: a heap copy
// of the value (the JIT may skip the allocation
// when the box never escapes).
object boxed = 42;
int unboxed = (int)boxed;
Console.WriteLine(unboxed);main! = |_args| {
# There is no object, no common base type and
# no reference type — so nothing to box into.
value : I64
value = 42
echo!(value.to_str())
Ok({})
}C# has spent releases mitigating this — generics to avoid boxing in collections,
Span<T>, ref structs, generic math — and each one is an escape from a heap allocation that a universal base type made possible. The mitigations are good; not needing them is simpler.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.// A test needs a project, a package reference and
// an attribute before it can run at all.
Console.WriteLine(Double(21));
int Double(int number) => number * 2;double : I64 -> I64
double = |number| number * 2
# expect is part of the language. It runs when the
# tests run and is compiled out otherwise.
expect double(21) == 42
expect double(0) == 0
main! = |_args| {
echo!(double(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 project, no [Fact] and nothing to reference.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.var count = 3;
// Debug.Assert is compiled out of a release build,
// so an invariant checked in development is not
// checked in production.
System.Diagnostics.Debug.Assert(count > 0);
Console.WriteLine(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({})
}The difference from
Debug.Assert is which builds it survives. C#'s is conditional on the DEBUG symbol by design, on the argument that a release build should be fast — which means the invariant you most want checked in production is the one that is not.Gotchas for C# 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.// A bare integer literal that fits in 32 bits is an int.
var count = 6;
Console.WriteLine(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())
echo!(6.I64.to_str())
Ok({})
}This is the first thing that surprises nearly everyone, and it surprises in a quiet way — 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.var flag = false;
// ! works on any bool, however it was produced.
Console.WriteLine(!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.Narrowing says what it does with the overflow
Converting between number types is an ordinary method named for the destination, and there is no cast syntax. Narrowing comes in two spellings that say what happens to a value that does not fit:
to_u8_wrap keeps the low bits, and to_u8_try returns a Try that is Err(OutOfRange) for 300.long big = 300;
// An explicit cast narrows, and silently wraps:
// 300 does not fit in a byte, so this prints 44.
byte narrowed = (byte)big;
Console.WriteLine(narrowed);main! = |_args| {
big : I64
big = 300
# There is no cast syntax. Narrowing is a method,
# and its name says what happens to a value that
# does not fit: to_u8_wrap keeps the low bits,
# and to_u8_try returns Err(OutOfRange) instead.
echo!(big.to_u8_wrap().to_str())
Ok({})
}Both print
44. C#'s (byte) cast gets there silently: it compiles, it runs, and outside a checked block it keeps the low eight bits without a word. Roc does the same arithmetic only when the call site says wrap, and the try spelling puts the overflow in the type instead.A recursive type must be nominal
A type that refers to itself has to be declared with
:=. Constructing one qualifies the tag with the type name — Tree.Leaf(1) — while matching on it does not, because the type is already known there.var tree = new Node(new Leaf(1), new Node(new Leaf(2), new Leaf(3)));
Console.WriteLine(Sum(tree));
int Sum(Tree tree) => tree switch
{
Leaf leaf => leaf.Value,
Node node => Sum(node.Left) + Sum(node.Right),
_ => 0,
};
abstract record Tree;
record Leaf(int Value) : Tree;
record Node(Tree Left, Tree Right) : Tree;# A recursive ALIAS (:) is a compile error. The
# nominal form (:=) is what allows the self
# reference.
Tree := [Leaf(I64), Node(Tree, Tree)]
sum_tree : Tree -> I64
sum_tree = |tree| match tree {
Leaf(value) => value
Node(left, right) => sum_tree(left) + sum_tree(right)
}
main! = |_args| {
tree = Tree.Node(Tree.Leaf(1), Tree.Node(Tree.Leaf(2), Tree.Leaf(3)))
echo!(sum_tree(tree).to_str())
Ok({})
}The asymmetry between building and matching is the part to remember:
Tree.Node(…) to construct, bare Node(left, right) in the match. The C# column needs a base type and three declarations to say what one Roc line says.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.var total = 0;
foreach (var number in new[] { 1, 2, 3 })
total = total + number;
Console.WriteLine(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, and the mutable form is there for when a loop genuinely reads better.