PONYλM2Modula-2

C.CodeCompared.To/Roc

An interactive executable cheatsheet comparing C and Roc

C17 (GCC) 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: a function without one cannot print.
#include <stdio.h> int main(void) { printf("Hello, World!\n"); return 0; }
main! = |_args| { echo!("Hello, World!") Ok({}) }
There is no #include and no header, because echo! is not declared anywhere in this file — it comes from the platform this program is compiled against. Ok({}) is the return value, where {} is the empty record, and it is what return 0 is doing.
Where the standard library comes from
A Roc application declares the platform it is compiled against, and the platform supplies the complete list of effects the program may perform. It is the same relationship a C program has with libc, made explicit and checked.
#include <stdio.h> int main(void) { /* stdio, stdlib and the C runtime are linked in by the toolchain. Nothing in the source says which libc, and any of them will link. */ printf("the toolchain chose the library\n"); return 0; }
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 toolchain chose the library") Ok({}) }
The practical difference is that the list is closed at compile time. A C program can call system() from anywhere a header is included, and whether that is allowed is a question for the sandbox it eventually runs in; a Roc application compiled against a platform with no process control has no name for one.
How a program reports failure
main! returns a Try — Ok for success, Err for failure — and the platform turns that into whatever the operating system wants. It is return 0 with a type attached.
#include <stdio.h> #include <stdlib.h> int main(void) { printf("all good\n"); /* Or exit(EXIT_FAILURE) from anywhere at all. */ return EXIT_SUCCESS; }
main! = |_args| { echo!("all good") # Ok is success, Err is failure; the platform # turns the return value into an exit status. Ok({}) }
Because the status is a return value rather than a global action, there is no exit() to call from four frames down and no atexit handler to register against it. Whatever the program has open is released on the way out, in the order the compiler worked out while compiling.
Comments
Comments start with # and run to the end of the line. There is no block form, so a multi-line comment is several # lines.
#include <stdio.h> int main(void) { /* A block comment, which can span lines. */ int count = 42; // a line comment printf("%d\n", count); return 0; }
main! = |_args| { # Roc has no block comment — every comment line # starts with its own #. count : I64 count = 42 # a line comment echo!(count.to_str()) Ok({}) }
The count : I64 line is a type annotation on its own line above the definition, rather than a declaration specifier before the name. Writing it is optional, but it pins down which number type this is — and as the Numbers section shows, that decides what gets printed.
No Pointers
There is no pointer type
Roc has no pointer type, no address-of operator and no dereference. A binding names a value, and passing it somewhere passes the value — there is no second thing that refers to it.
#include <stdio.h> int main(void) { int value = 42; int *pointer = &value; *pointer = 99; printf("%d\n", value); return 0; }
main! = |_args| { value : I64 value = 42 # There is no &, no *, and no type that could # hold an address. A name IS its value. changed : I64 changed = 99 echo!(changed.to_str()) Ok({}) }
That one absence is what takes the whole familiar list with it: no dangling pointer, no double free, no use-after-free, no aliasing question, no null dereference. The rest of this section and the next two are that sentence worked out in detail.
No out parameters
A function returns as many values as it likes by returning a tuple, written in parentheses, and the caller takes it apart with a tuple pattern on the left of =. The out parameter has nothing to do.
#include <stdio.h> static void divide(int numerator, int denominator, int *quotient, int *remainder) { *quotient = numerator / denominator; *remainder = numerator % denominator; } int main(void) { int quotient, remainder; divide(17, 5, &quotient, &remainder); printf("%d %d\n", quotient, remainder); return 0; }
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({}) }
The C version has to declare quotient and remainder uninitialized and trust that divide writes both. If it writes only one, the other is read as whatever was on the stack, and -Wall will not always catch it.
There is no NULL
Absence is a value with a type. find_first returns a Try, and match obliges you to say what happens in each case before the contents can be used.
#include <stdio.h> #include <string.h> int main(void) { const char *haystack = "abc"; const char *found = strchr(haystack, 'z'); /* Forgetting this check is a segfault. */ if (found == NULL) printf("not found\n"); else printf("found\n"); return 0; }
main! = |_args| { haystack = "abc" # find_first hands back a Try. There is no null # to return and no check to forget — the value # cannot be reached without handling both cases. match haystack.to_utf8().find_first(|byte| byte == 'z') { Ok(_) => echo!("found") Err(_) => echo!("not found") } Ok({}) }
In C the same information is smuggled back as a pointer that happens to be zero, so the type says "address of a character" while the value says "nothing". Every caller has to remember the convention, and the one that forgets crashes at a place that has nothing to do with the mistake.
Two names never share one value
Every value is immutable, so a second name for one is only ever a second way to read it. set returns a new list; the ? after it unwraps the Try it comes in, since an index can be out of range.
#include <stdio.h> int main(void) { int numbers[3] = {1, 2, 3}; int *alias = numbers; alias[0] = 99; /* numbers changed, because there was only ever one array and two ways to reach it. */ printf("%d\n", numbers[0]); return 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({}) }
The C column is not a bug — it is the intended behavior of arrays and pointers, and it is also why restrict exists, why strict aliasing is a rule the optimizer relies on, and why passing a buffer to a function you did not write requires reading its documentation carefully.
No pointer arithmetic
Iteration is a method on the collection rather than a pointer walked to a limit. sum here, and map, keep_if and fold when the work is not a total.
#include <stdio.h> int main(void) { int numbers[4] = {10, 20, 30, 40}; int *cursor = numbers; int total = 0; while (cursor < numbers + 4) { total += *cursor; cursor++; } printf("%d\n", total); return 0; }
main! = |_args| { numbers : List(I64) numbers = [10, 20, 30, 40] # Walking a list is the list walking itself. # There is no cursor and no end to compare to. echo!(numbers.sum().to_str()) Ok({}) }
Pointer arithmetic is what makes C's loop fast and what makes an off-by-one in it a memory-safety bug rather than a wrong answer. Handing the walk to the collection removes both — and removes the choice about how to walk it, which the What C Still Has section comes back to.
Memory Without malloc or free
No malloc, no free — and no collector
Roc is reference counted, and the counts are inserted by the compiler while compiling. There is no garbage collector, no heap scan, no pause and no runtime to start — which is the part worth knowing before reading anything else on this page.
#include <stdio.h> #include <stdlib.h> int main(void) { int *numbers = malloc(3 * sizeof(int)); if (numbers == NULL) return 1; numbers[0] = 1; numbers[1] = 2; numbers[2] = 3; printf("%d\n", numbers[0] + numbers[1] + numbers[2]); free(numbers); return 0; }
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3] # The compiler inserted the allocation and the # matching free, at the last line that uses this # list. Nothing scans the heap; nothing pauses. echo!(numbers.sum().to_str()) Ok({}) }
The costs are real and worth naming: a little arithmetic on every copy, and no way to free a reference cycle. Roc gets away without a cycle collector because nothing in the language can build a cycle — a value can only refer to values that already existed when it was constructed.
Immutable in the source, mutated in the binary
Every list function returns a new list. When the old one has exactly one reference and is about to go out of scope, the compiler reuses its memory instead of copying, so what is generated is the in-place write the C column spells out.
#include <stdio.h> int main(void) { int numbers[3] = {1, 2, 3}; /* Writing through the subscript changes the array where it sits. */ numbers[0] = 99; printf("%d\n", numbers[0]); return 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 obvious objection that immutability must cost a copy, and it holds because the value is immutable: the compiler can prove nobody else is looking at that list. It is exactly the proof an optimizer cannot make about a C array whose address has been passed to a function in another translation unit.
Nothing outlives what it points at
Returning a value from a function returns the value. Nothing has an address, so nothing can be returned that outlives the thing it referred to.
#include <stdio.h> static const char *label(void) { /* Returning the address of a local: the array is gone before the caller reads it. */ char text[] = "local"; static char kept[8]; for (int index = 0; index < 6; index++) kept[index] = text[index]; return kept; } int main(void) { printf("%s\n", label()); return 0; }
label : Str label = { text = "local" text } main! = |_args| { # A value returned from a function is a value. # There is no stack frame it could have been # living in, and nothing to keep alive by hand. echo!(label) Ok({}) }
The C column shows the workaround rather than the bug, because the bug will not compile cleanly: returning text directly is a dangling pointer, and the static buffer is the usual repair — which is not reentrant and not thread-safe, and is why strtok and asctime have the reputations they do.
Who frees this is not a question
A function that produces a value returns it, and that is the whole protocol. There is no convention about who releases it, because the compiler already knows where the last use is.
#include <stdio.h> #include <stdlib.h> #include <string.h> /* The caller must free the result. That sentence lives in a comment, and nothing checks it. */ static char *joined(const char *a, const char *b) { char *out = malloc(strlen(a) + strlen(b) + 1); if (out == NULL) return NULL; strcpy(out, a); strcat(out, b); return out; } int main(void) { char *text = joined("ab", "cd"); if (text == NULL) return 1; printf("%s\n", text); free(text); return 0; }
joined : Str, Str -> Str joined = |first, second| first.concat(second) main! = |_args| { # Nothing is documented about ownership, because # there is no ownership to document. echo!(joined("ab", "cd")) Ok({}) }
Every non-trivial C library has to answer this question in prose — does the caller free it, is it static storage, must it be freed with a matching function from the same library — and every caller has to read the answer. The commonest memory bugs in C are not exotic; they are two people disagreeing about that sentence.
No Undefined Behavior
Nothing is uninitialized
A name is introduced by giving it a value, so the declaration and the initialization are one thing. There is no declaration-only form and no default-initialized storage.
#include <stdio.h> int main(void) { /* An initialized local, because reading an uninitialized one is undefined behavior and may print anything at all. */ int count = 0; printf("%d\n", count); return 0; }
main! = |_args| { # A binding is its initializer. There is no way # to declare a name and fill it in later, so # there is nothing to read too early. count : I64 count = 0 echo!(count.to_str()) Ok({}) }
In C the two are separate, and the gap between them is undefined behavior that usually prints a plausible number. The C column above is written the careful way, which is the point — the careless way compiles just as happily.
Overflow stops the program, it does not wrap
Arithmetic that leaves the range of its type aborts the program. A literal that does not fit its annotated type does not get that far — it is a compile error naming the type and the value.
#include <stdio.h> int main(void) { unsigned char limit = 255; limit++; /* Unsigned wraps silently and prints 0. Signed overflow would be undefined behavior, which the optimizer is allowed to assume away. */ printf("%u\n", limit); return 0; }
main! = |_args| { limit : U8 limit = 255 # limit + 1 would ABORT here rather than wrap. # Writing 256 in the first place is a compile # error, because it does not fit a U8. echo!(limit.to_str()) Ok({}) }
C gives you two different wrong answers instead: unsigned arithmetic wraps silently, which is defined and almost never what was meant, and signed overflow is undefined, which lets the optimizer delete the bounds check written just after it. Aborting is a worse outcome than the right answer and a much better one than a plausible wrong number.
No strict-aliasing rule to break
A Roc value has no exposed representation: there is no cast, no memcpy, and no way to view one type's storage as another. The question strict aliasing answers cannot be asked.
#include <stdio.h> #include <string.h> int main(void) { float number = 1.0f; unsigned int bits; /* memcpy, because reading a float through an unsigned int * breaks strict aliasing and the optimizer may reorder around it. */ memcpy(&bits, &number, sizeof bits); printf("%u\n", bits); return 0; }
main! = |_args| { # There is no way to ask what a value's bytes # are, so there is no rule about reading them # through the wrong type. number : F64 number = 1.0 echo!(number.to_str()) Ok({}) }
That is a real loss as well as a real safety gain — bit-level reinterpretation is how you write a fast inverse square root, a tagged pointer or a serializer, and none of them can be written here. What C Still Has picks this up.
Evaluation order is not a trap
An expression in Roc computes a value and changes nothing, so the order in which its parts are evaluated cannot be observed. The question of sequence points does not arise.
#include <stdio.h> int main(void) { int index = 0; int numbers[2] = {10, 20}; /* i = i++ + numbers[i] would be unsequenced and undefined; each step is written out instead. */ int value = numbers[index]; index = index + 1; printf("%d %d\n", value, index); return 0; }
main! = |_args| { numbers : List(I64) numbers = [10, 20] # Nothing has a side effect, so there is no # order for two of them to happen in. value = numbers.get(0)? index : I64 index = 1 echo!("${value.to_str()} ${index.to_str()}") Ok({}) }
C's unsequenced-modification rules are a genuine hazard precisely because the offending expressions look reasonable and compile with at most a -Wsequence-point warning from -Wall — and two compilers, or two optimization levels, can disagree about what they mean.
Division by zero
Dividing by zero with // aborts the program with a message, the same way arithmetic overflow does. div_try is the checked form: it returns a Try, so the zero case is an Err to handle like any other.
#include <stdio.h> int main(void) { int denominator = 0; /* 1 / denominator is undefined behavior, and on most machines a SIGFPE. The check is yours. */ if (denominator == 0) printf("cannot divide\n"); else printf("%d\n", 1 / denominator); return 0; }
main! = |_args| { denominator : I64 denominator = 0 # div_try puts the zero case in the type: it # returns a Try instead of aborting, the way # plain // does. match 1.I64.div_try(denominator) { Ok(quotient) => echo!(quotient.to_str()) Err(_) => echo!("cannot divide") } Ok({}) }
In C the check is yours to remember, and forgetting it is undefined behavior that the optimizer is entitled to assume cannot occur and to reason backwards from. In Roc, forgetting it with // is a defined abort naming what happened, and div_try makes forgetting it impossible.
Arrays vs Lists
Building and reading a list
A list literal is square brackets, len is the count and get reads by index. get returns a Try, and the ? unwraps it and returns early if the index was out of range.
#include <stdio.h> int main(void) { int numbers[3] = {10, 20, 30}; size_t count = sizeof numbers / sizeof numbers[0]; printf("%zu\n", count); printf("%d\n", numbers[1]); return 0; }
main! = |_args| { numbers : List(I64) numbers = [10, 20, 30] # The length travels with the value. echo!(numbers.len().to_str()) echo!(numbers.get(1)?.to_str()) Ok({}) }
The sizeof division in the C column works only where the array's declaration is visible. Pass it to a function and it decays to a pointer, the length is gone, and it has to be handed over separately — which is why every C function taking a buffer also takes a count, and why the two can disagree.
An index past the end
Indexing returns a Try, so the out-of-range case is part of the answer. There is no second, faster accessor that skips the check.
#include <stdio.h> int main(void) { int numbers[3] = {10, 20, 30}; /* numbers[7] reads whatever is there. No check, no error, and often no crash. */ int index = 7; if (index < 3) printf("%d\n", numbers[index]); else printf("out of range\n"); return 0; }
main! = |_args| { numbers : List(I64) numbers = [10, 20, 30] # There is no unchecked way to ask. match numbers.get(7) { Ok(value) => echo!(value.to_str()) Err(_) => echo!("out of range") } Ok({}) }
The C column has to write the bound itself and keep it correct as the array changes, and the version without the if compiles identically — it reads adjacent memory, which is a security bug on a good day and a mysterious wrong answer on a bad one.
A list passed to a function is still a list
A list is one value carrying its own length, so a function that takes one takes a single parameter and knows how long it is.
#include <stdio.h> /* The array decayed to a pointer, so the length has to come along separately. */ static int total(const int *numbers, size_t count) { int sum = 0; for (size_t i = 0; i < count; i++) sum += numbers[i]; return sum; } int main(void) { int numbers[4] = {1, 2, 3, 4}; printf("%d\n", total(numbers, 4)); return 0; }
total : List(I64) -> I64 total = |numbers| numbers.sum() main! = |_args| { numbers : List(I64) numbers = [1, 2, 3, 4] echo!(total(numbers).to_str()) Ok({}) }
The second parameter in the C version is the bug surface. Nothing connects it to the first, so passing the wrong count compiles cleanly, and a caller who later shortens the array has to remember to change the number at every call site.
Growing a list
append returns a list one element longer. Reallocation, copying and the free of the old block are the compiler's problem, and when the original has one reference it is grown in place.
#include <stdio.h> #include <stdlib.h> int main(void) { size_t count = 2; int *numbers = malloc(count * sizeof(int)); if (numbers == NULL) return 1; numbers[0] = 1; numbers[1] = 2; int *grown = realloc(numbers, 3 * sizeof(int)); if (grown == NULL) { free(numbers); return 1; } numbers = grown; numbers[2] = 3; printf("%d\n", numbers[2]); free(numbers); return 0; }
main! = |_args| { numbers : List(I64) numbers = [1, 2] grown = numbers.append(3) echo!(grown.get(2)?.to_str()) Ok({}) }
The realloc dance in the C column is the correct form of it, and most of its length is the failure path: assigning the result straight back to numbers leaks the original when it returns NULL, which is a mistake common enough to have its own static-analysis check.
Strings Are Not char*
A string is a value, not a pointer
Str is a value that carries its own length, and the method is named for what it counts: bytes of UTF-8. There is no terminator to search for, so asking the length is a read rather than a scan.
#include <stdio.h> #include <string.h> int main(void) { const char *text = "hello"; /* strlen walks to the NUL byte every time. */ printf("%zu\n", strlen(text)); return 0; }
main! = |_args| { text = "hello" # The length is stored, not searched for. echo!(text.count_utf8_bytes().to_str()) Ok({}) }
C's convention costs an O(n) walk per call and, more importantly, makes a string with a zero byte in it unrepresentable. It is also where the classic overruns come from: a buffer whose NUL got overwritten has no end, and every function that walks it keeps going.
Joining two strings
concat returns a new string of exactly the right length. There is no destination buffer to size and no possibility of writing past one.
#include <stdio.h> #include <string.h> int main(void) { char buffer[16]; /* The size is yours to get right; strcat with a short buffer is a silent overrun. */ snprintf(buffer, sizeof buffer, "%s%s", "ab", "cd"); printf("%s\n", buffer); return 0; }
main! = |_args| { # No buffer, no size, no truncation. echo!("ab".concat("cd")) Ok({}) }
snprintf is the safe member of a family whose other members are not, and even it truncates silently when the buffer is short — the return value says how much it wanted to write, and almost nobody checks it.
Building a message
Interpolation is ${…} inside an ordinary string, and it accepts only a Str. Anything that is not already text is converted first, which is what to_str is doing.
#include <stdio.h> int main(void) { const char *name = "Ada"; int age = 36; /* The format string and the arguments must agree; -Wformat checks the literal case. */ printf("%s is %d\n", name, age); return 0; }
main! = |_args| { name = "Ada" age : I64 age = 36 # Interpolation takes a Str, so the number is # converted explicitly. echo!("${name} is ${age.to_str()}") Ok({}) }
printf has no way to know the types of its arguments, so the agreement between %d and what follows is checked only when the format is a literal the compiler can see. Pass a format string through a variable and the check is gone, which is the whole format-string vulnerability class.
Splitting a string
split_on takes a separator string and returns a list of pieces, which for walks. The original string is untouched, because nothing in Roc can touch it.
#include <stdio.h> #include <string.h> int main(void) { /* strtok writes NUL bytes into its argument, so it needs a writable copy and cannot be used on a literal. */ char line[] = "red,green,blue"; for (char *part = strtok(line, ","); part != NULL; part = strtok(NULL, ",")) { printf("%s\n", part); } return 0; }
main! = |_args| { line = "red,green,blue" for part in line.split_on(",") { echo!(part) } Ok({}) }
strtok is the sharpest illustration of C's string model: it modifies the input, it keeps its position in a static variable so two loops cannot interleave and no two threads can use it at once, and the pieces it returns point into the buffer you gave it.
Getting at the bytes
to_utf8 turns a string into a List(U8), and Str.from_utf8 goes back — returning a Try, because not every byte sequence is valid UTF-8.
#include <stdio.h> int main(void) { const char *text = "hi"; /* A char* IS the bytes; indexing gives one. */ printf("%d %d\n", text[0], text[1]); return 0; }
main! = |_args| { text = "hi" # A Str is not a byte array, but it converts to # one on request. bytes = text.to_utf8() echo!("${bytes.get(0)?.to_str()} ${bytes.get(1)?.to_str()}") Ok({}) }
The conversion is explicit in both directions, which is the difference worth noticing: in C a string and its bytes are the same object, so code that means to handle text and code that means to handle bytes are written identically and only differ in what the author was thinking.
Structs vs Records
Declaring and building a struct
A record literal is braces with field: value pairs, and fields are read with a dot. The type line above is an alias for the shape — it declares nothing new.
#include <stdio.h> struct Point { int x; int y; }; int main(void) { struct Point origin = {1, 2}; printf("(%d, %d)\n", origin.x, origin.y); return 0; }
Point : { x : I64, y : I64 } main! = |_args| { origin : Point origin = { x: 1, y: 2 } echo!("(${origin.x.to_str()}, ${origin.y.to_str()})") Ok({}) }
Because the alias names a shape rather than a type, any record with an x and a y of the right types already is a Point. There is no struct keyword at the use site and no typedef to avoid writing one.
Changing one field
{ ..origin, y: 3 } copies every field of origin and overrides the ones named after it. The original is unchanged, which is also what the C column gets — a struct assignment copies.
#include <stdio.h> struct Point { int x; int y; }; int main(void) { struct Point origin = {1, 2}; struct Point moved = origin; moved.y = 3; printf("%d %d\n", origin.y, moved.y); return 0; }
main! = |_args| { origin = { x: 1.I64, y: 2.I64 } moved = { ..origin, y: 3 } echo!("${origin.y.to_str()} ${moved.y.to_str()}") Ok({}) }
This row is where C and Roc agree more than the rest of the page suggests. A C struct is a value: assigning it copies, passing it to a function copies, and returning it copies. It is only when its address is taken that C's behavior diverges from what this page describes.
There is no layout to inspect
A Roc record has no exposed layout: no sizeof, no offsetof, no alignment control and no packed attribute. The compiler chooses a representation and nothing in the language can observe it.
#include <stdio.h> struct Padded { char flag; int value; }; int main(void) { /* Usually 8: three padding bytes after flag. The exact number is the ABI's business. */ printf("%zu\n", sizeof(struct Padded)); return 0; }
main! = |_args| { padded = { flag: Bool.True, value: 1.I64 } # There is no sizeof, no offsetof, no alignment # and no padding to ask about — a record is not # a region of memory you can address. echo!(padded.value.to_str()) Ok({}) }
For a C programmer this is the most uncomfortable absence on the page, and rightly — parsing a wire format, mapping a hardware register or matching a file header all need exactly this control. What C Still Has returns to it.
Comparing two structs
== compares contents, always. Str.inspect turns the resulting Bool into text method.
#include <stdio.h> #include <string.h> struct Point { int x; int y; }; int main(void) { struct Point a = {1, 2}; struct Point b = {1, 2}; /* == does not work on structs, and memcmp compares padding bytes too. */ printf("%d\n", a.x == b.x && a.y == b.y); return 0; }
main! = |_args| { first = { x: 1.I64, y: 2 } second = { x: 1.I64, y: 2 } # == compares contents, for every type. echo!(Str.inspect(first == second)) Ok({}) }
C has no structural equality at all, so the choice is a field-by-field comparison you maintain by hand — which quietly stops being right when a field is added — or memcmp, which also compares the padding bytes and can report two identical structs as different.
union vs Tag Union
A union that knows which member is live
A tag union is one declaration: each case names itself and carries its own payload. Constructing one is writing the tag with its value, and match gets the payload out already typed.
#include <stdio.h> enum Kind { KIND_NUMBER, KIND_TEXT }; struct Value { enum Kind kind; /* the tag, by hand */ union { int number; const char *text; } as; }; static void show(struct Value v) { /* Nothing checks that kind matches what was last written into the union. */ switch (v.kind) { case KIND_NUMBER: printf("%d\n", v.as.number); break; case KIND_TEXT: printf("%s\n", v.as.text); break; } } int main(void) { struct Value one = {KIND_NUMBER, {.number = 42}}; show(one); return 0; }
Value : [Number(I64), Text(Str)] show : Value -> Str show = |value| match value { Number(number) => number.to_str() Text(text) => text } main! = |_args| { echo!(show(Number(42))) Ok({}) }
This is the row that best repays a C programmer's attention. The C version is three declarations for one idea, and the connection between the tag and the union is maintained entirely by the programmer — set kind to KIND_TEXT and write number, and show prints an integer as a pointer.
A missing case is an error
A match must cover every case the type allows, and leaving one out is a compile error naming the missing pattern. Because the type lists exactly three tags, three arms are complete and no fallback is needed.
#include <stdio.h> enum Color { RED, GREEN, BLUE }; static const char *name(enum Color color) { switch (color) { case RED: return "red"; case GREEN: return "green"; case BLUE: return "blue"; } /* Needed anyway: color could hold (enum Color)99, which is a legal value of the type. */ return "unknown"; } int main(void) { printf("%s\n", name(RED)); return 0; }
Color : [Red, Green, Blue] name : Color -> Str name = |color| match color { Red => "red" Green => "green" Blue => "blue" } main! = |_args| { # Delete any arm above and this does not build. echo!(name(Red)) Ok({}) }
The C function needs its final return because an enum is an integer type: any value that fits is legal, so no switch over one can ever be exhaustive. -Wswitch catches a forgotten named constant, but it is a warning, and the unreachable fallback stays.
A union cannot be used to reinterpret bytes
A tag union holds exactly one of its cases and knows which, so there is no second member to read and no reinterpretation to perform. It is a sum type, not overlapping storage.
#include <stdio.h> union Bits { float number; unsigned int raw; }; int main(void) { /* Reading a different member than was written is implementation-defined in C, and undefined in C++. It is a common idiom regardless. */ union Bits value; value.number = 1.0f; printf("%u\n", value.raw); return 0; }
main! = |_args| { # A tag union stores ONE case and remembers # which, so there is no other member to read. value = Number(1.0) echo!(match value { Number(number) => number.to_str() }) Ok({}) }
That overlap is what C's union actually is, and type punning through one is used constantly — for bit manipulation, for network headers, for variant records. None of it is expressible here, and the next section is the honest accounting of that.
A tree, without pointers
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.
#include <stdio.h> #include <stdlib.h> struct Node { int value; struct Node *left, *right; }; static int total(const struct Node *node) { if (node == NULL) return 0; return node->value + total(node->left) + total(node->right); } int main(void) { struct Node leaf_a = {2, NULL, NULL}; struct Node leaf_b = {3, NULL, NULL}; struct Node root = {1, &leaf_a, &leaf_b}; printf("%d\n", total(&root)); return 0; }
Tree := [Empty, Node(Tree, I64, Tree)] total : Tree -> I64 total = |tree| match tree { Empty => 0 Node(left, value, right) => value + total(left) + total(right) } main! = |_args| { tree = Tree.Node( Tree.Node(Tree.Empty, 2, Tree.Empty), 1, Tree.Node(Tree.Empty, 3, Tree.Empty)) echo!(total(tree).to_str()) Ok({}) }
Empty is a case of the type rather than a null pointer, so the recursion terminates on a value the compiler knows about instead of on a check the author remembered. The C version allocates nothing here only because the tree is three known nodes; a real one needs malloc and a matching walk to free it.
enum vs Tags
A tag is not an integer
A tag is its own thing, not a name for a number. There is no arithmetic on one, no implicit conversion to an integer and no way to construct one from a value that was not a tag.
#include <stdio.h> enum Color { RED, GREEN, BLUE }; int main(void) { enum Color color = RED; /* An enum IS an int: this compiles, and prints a number rather than a color name. */ printf("%d\n", color + 1); return 0; }
Color : [Red, Green, Blue] main! = |_args| { color : Color color = Red # color + 1 does not type-check. A tag has no # numeric value to add to. echo!(match color { Red => "red" Green => "green" Blue => "blue" }) Ok({}) }
C's enum is a set of integer constants with a type name loosely attached, which is why color + 1 compiles, why any integer fits in an enum Color, and why the switch in the previous section needed its unreachable fallback.
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". No declaration is written and none is needed.
#include <stdio.h> /* Every named constant has to exist before use, in a header if more than one file needs it. */ enum Status { STATUS_OK, STATUS_BUSY }; static const char *describe(enum Status status) { return status == STATUS_OK ? "ok" : "busy"; } int main(void) { printf("%s\n", describe(STATUS_OK)); return 0; }
# No declaration anywhere. Writing Ok 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 you reusing the name, since tags are not registered anywhere, but a reader of the 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.
#include <stdio.h> enum Level { WARNING, ERROR }; static const char *describe(enum Level level) { /* The set is fixed at the declaration. A caller cannot add a case, and the default arm exists only because any int fits. */ switch (level) { case WARNING: return "warning"; default: return "other"; } } int main(void) { printf("%s\n", describe(WARNING)); return 0; }
# 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 express that. An enum is closed at its declaration, and the only open alternative is an integer, which is open to everything including values that are not levels at all. Choosing openness per function has no C counterpart.
Types & Inference
Inference covers whole programs
Roc infers types across the whole program, so an annotation is a claim you choose to state rather than information the compiler needs. Writing one is checked against what was inferred.
#include <stdio.h> /* Every type is written: parameters, return, and the local. C23 has auto for the last one. */ static int doubled(int number) { return number * 2; } int main(void) { int result = doubled(21); printf("%d\n", result); return 0; }
# 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, for the same reason a C header is useful — so a reader can see the shape without reading the body. The difference is that the annotation cannot drift out of step with the definition, the way a prototype in a header can.
Types read left to right
A type is written left to right, with -> between what goes in and what comes out, and parentheses grouping a function type that is itself an argument or a result.
#include <stdio.h> /* A function taking an int and returning a pointer to a function taking a char and returning int. C's declaration syntax is read outward-in. */ static int helper(char c) { return c; } static int (*chooser(int unused))(char) { (void)unused; return helper; } int main(void) { printf("%d\n", chooser(0)('A')); return 0; }
helper : U8 -> I64 helper = |byte| byte.to_i64() # Read left to right: takes an I64, returns a # function from U8 to I64. chooser : I64 -> (U8 -> I64) chooser = |_unused| helper main! = |_args| { echo!(chooser(0)('A').to_str()) Ok({}) }
C's declarator syntax follows the shape of a use of the thing rather than its type, which is why int (*f(int))(char) has to be unwound from the identifier outwards, and why cdecl exists as a program.
No implicit conversion
Arithmetic requires both operands to be the same type, and converting between types is an ordinary method named for the destination — to_f64, to_i64. There is no cast syntax and no promotion.
#include <stdio.h> int main(void) { int whole = 7; double fraction = 2.0; /* whole is promoted to double silently. The usual arithmetic conversions are a table. */ printf("%.1f\n", whole / fraction); return 0; }
main! = |_args| { whole : I64 whole = 7 # Mixing an I64 with an F64 is a type error. # The conversion is a method call, named for # the type it produces. fraction : F64 fraction = 2.0 echo!((whole.to_f64() / fraction).to_str()) Ok({}) }
C's usual arithmetic conversions are convenient and are also where a surprising number of bugs live: comparing a signed int with an unsigned size_t converts the int, so a negative length becomes an enormous positive one and the bounds check passes.
No void*, and nothing to cast
A lowercase name in a type signature is a type variable: a stands for one type, used consistently. The function works for any element type and the compiler still knows which one at each call.
#include <stdio.h> /* void* accepts anything and remembers nothing. The cast back is unchecked. */ static int first_int(const void *data) { return *(const int *)data; } int main(void) { int numbers[2] = {7, 8}; printf("%d\n", first_int(numbers)); return 0; }
first : List(a) -> Try(a, [ListWasEmpty, ..]) first = |items| items.first() main! = |_args| { numbers : List(I64) numbers = [7, 8] echo!(first(numbers)?.to_str()) Ok({}) }
That is the difference from void*, which also accepts any type but forgets it — the cast on the way out is a promise the compiler cannot check, and getting it wrong reads the right bytes as the wrong type.
Numbers & Conversion
Integer types say their width
Every integer type names its width and signedness: I32, I64, U8. There is no type whose size depends on the platform, so there is no equivalent of int or long.
#include <stdio.h> #include <stdint.h> int main(void) { /* int, long and size_t vary by platform, so stdint.h exists to say what you meant. */ int32_t small = 42; int64_t big = 9000000000; uint8_t tiny = 255; printf("%d %lld %u\n", small, (long long)big, tiny); return 0; }
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({}) }
This is stdint.h as the only option rather than as the careful option, and it removes the format-specifier problem with it — printing an I64 is to_str, not a choice between %ld, %lld and PRId64.
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.
#include <stdio.h> int main(void) { /* / is integer division between two ints and fractional between two doubles — the same operator, decided by the operand types. */ printf("%d\n", 7 / 2); printf("%.1f\n", 7.0 / 2.0); printf("%d\n", 7 % 2); return 0; }
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.
Bitwise operations
Bitwise operations are methods rather than operators: bitwise_and, bitwise_or, bitwise_xor. Hexadecimal and binary literals are written as in C.
#include <stdio.h> int main(void) { unsigned char left = 0x0C; unsigned char right = 0x0A; printf("%u\n", left & right); printf("%u\n", left | right); printf("%u\n", left ^ right); return 0; }
main! = |_args| { left : U8 left = 0x0C right : U8 right = 0x0A echo!(left.bitwise_and(right).to_str()) echo!(left.bitwise_or(right).to_str()) echo!(left.bitwise_xor(right).to_str()) Ok({}) }
Where C spells these with operators, Roc spells them as methods named for what they do, so a bit-twiddling expression reads as a chain of calls rather than a line of punctuation.
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" rather than truncating it.
#include <stdio.h> #include <stdlib.h> #include <errno.h> int main(void) { const char *text = "42"; char *end; errno = 0; long value = strtol(text, &end, 10); /* Three things to check: errno, the end pointer, and whether anything was consumed. */ if (errno != 0 || end == text || *end != '\0') printf("not a number\n"); else printf("%ld\n", value); return 0; }
main! = |_args| { # One call, 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 C column is the correct use of strtol, and its length is the point: atoi is shorter and reports no errors at all, returning 0 for "abc", which is why it survives in code that has never been given bad input.
Control Flow
if is an expression
if is an expression that produces a value, and its branches are braces rather than a parenthesized condition. Both branches must produce the same type, so an else is required whenever the result is used.
#include <stdio.h> int main(void) { int score = 75; /* The statement form needs a variable declared first; the ternary is the expression form. */ const char *grade = score >= 70 ? "pass" : "fail"; printf("%s\n", grade); return 0; }
main! = |_args| { score : I64 score = 75 # if produces a value, so there is one form # rather than a statement and a ternary. grade = if score >= 70 { "pass" } else { "fail" } echo!(grade) Ok({}) }
C has the same idea twice — the statement for effects and ?: for values — which is why the ternary gets nested into unreadable chains when a third case appears. A Roc if / else if / else chain is the ordinary spelling and stays readable.
No fallthrough
Each match arm is a value, and arms do not run into one another. There is no break to write and no way to leave one out.
#include <stdio.h> int main(void) { int code = 2; switch (code) { case 1: printf("one\n"); break; case 2: printf("two\n"); break; /* Omitting a break falls into the next case, which is occasionally wanted and usually a bug. */ default: printf("other\n"); break; } return 0; }
main! = |_args| { code : I64 code = 2 echo!(match code { 1 => "one" 2 => "two" _ => "other" }) Ok({}) }
Fallthrough is C's default because a switch is a computed jump into a block of statements rather than a selection between alternatives. -Wimplicit-fallthrough and the fallthrough attribute exist to make the deliberate cases distinguishable from the forgotten ones.
Loops
A for loop walks a list directly — there is no index, no bound and no increment. Accumulating across iterations needs a mutable binding, which is var and a name beginning with $.
#include <stdio.h> int main(void) { int numbers[3] = {1, 2, 3}; int total = 0; for (size_t i = 0; i < 3; i++) total += numbers[i]; printf("%d\n", total); return 0; }
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3] # A plain binding cannot be reassigned, so an # accumulator is var with a $ on its name. var $total = 0.I64 for number in numbers { $total = $total + number } echo!($total.to_str()) Ok({}) }
The $ is part of the name and is written at every use, so mutation is visible wherever it happens rather than only at the declaration. Most code that would be a loop in C is a fold here, and this row is the version that stays closest to the C shape.
No goto, and no cleanup label
Roc has no goto and no labels. Releasing what a function acquired is the compiler's job, on every path out, so the pattern the label exists for does not come up.
#include <stdio.h> #include <stdlib.h> int main(void) { int *first = malloc(sizeof(int)); if (first == NULL) goto fail; int *second = malloc(sizeof(int)); if (second == NULL) goto free_first; printf("both allocated\n"); free(second); free_first: free(first); return 0; fail: return 1; }
main! = |_args| { # Nothing is acquired, so there is no cleanup # ladder to unwind. The compiler emits the # frees, on every path out. echo!("both allocated") Ok({}) }
The goto cleanup ladder is good C — it is how the Linux kernel handles multi-step acquisition — and it is entirely bookkeeping. Adding a third allocation means a third label, in the right place, releasing the right subset, and getting that wrong is a leak nothing will report.
Functions & Closures
A function value can capture
A function written inline with |parameters| captures whatever it names from the surrounding scope. It is an ordinary value: it can be stored, passed and returned.
#include <stdio.h> static int add_ten(int number) { return number + 10; } int main(void) { int numbers[3] = {1, 2, 3}; /* A function pointer carries no state, so the 10 has to be compiled in or passed through a void* context parameter. */ int (*transform)(int) = add_ten; for (size_t i = 0; i < 3; i++) printf("%d\n", transform(numbers[i])); return 0; }
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3] step = 10 # The closure captures step from the scope # around it. Nothing is passed through. for value in numbers.map(|number| number + step) { echo!(value.to_str()) } Ok({}) }
A C function pointer is just an address, so every callback-taking API in C also takes a void *user_data that gets threaded through and cast back — qsort_r, every GUI toolkit, every event loop. Capture removes that parameter and the cast with it.
Passing a function to a function
sort_with takes a comparison that returns one of three tags — Before, After or Same — and the compiler checks that all three are accounted for.
#include <stdio.h> #include <stdlib.h> static int ascending(const void *a, const void *b) { int left = *(const int *)a, right = *(const int *)b; return (left > right) - (left < right); } int main(void) { int numbers[3] = {3, 1, 2}; qsort(numbers, 3, sizeof(int), ascending); for (size_t i = 0; i < 3; i++) printf("%d\n", numbers[i]); return 0; }
main! = |_args| { numbers : List(I64) numbers = [3, 1, 2] ascending = numbers.sort_with(|left, right| { if left < right { Before } else if left > right { After } else { Same } }) for value in ascending { echo!(value.to_str()) } Ok({}) }
The qsort comparator is the shape of every generic C interface: it receives const void *, casts both back by hand, and returns a sign that the caller must encode correctly. Getting the sign backwards compiles and sorts wrongly; returning the tag by the wrong name does not compile.
No variadic functions
There are no variadic functions. A function that takes an unknown number of values takes a list, which carries its own length and whose element type is checked.
#include <stdio.h> #include <stdarg.h> static int total(int count, ...) { va_list args; va_start(args, count); int sum = 0; /* Nothing checks that count matches how many arguments were actually passed. */ for (int index = 0; index < count; index++) sum += va_arg(args, int); va_end(args); return sum; } int main(void) { printf("%d\n", total(3, 1, 2, 3)); return 0; }
total : List(I64) -> I64 total = |numbers| numbers.sum() main! = |_args| { # A variable number of arguments is a list, and # a list knows how long it is. echo!(total([1, 2, 3]).to_str()) Ok({}) }
va_arg is unchecked in both directions: it trusts the count and it trusts the type, so asking for an int when a double was passed reads the wrong bytes. This is the same hole printf lives in, and why its format checking is a special case the compiler knows about rather than a property of the language.
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.
#include <stdio.h> /* Nothing in this signature rules out printing, writing a file or mutating a global. */ static int total(const int *numbers, size_t count) { printf("(logging from inside)\n"); int sum = 0; for (size_t i = 0; i < count; i++) sum += numbers[i]; return sum; } int main(void) { int numbers[3] = {1, 2, 3}; printf("%d\n", total(numbers, 3)); return 0; }
# 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, which is the thing const gestures at and cannot deliver: a const int * parameter promises only that this function will not write through this pointer, and says nothing about the globals it touches or the file it opens.
Generic Code Without void*
One function, many types
The type variable a stands for one type used consistently, and the comparison arrives as an ordinary function argument — so the capability travels with the call rather than being assumed of the type.
#include <stdio.h> /* C's two options are a macro, which is textual and unchecked, or void*, which forgets the type. This is the macro. */ #define LARGEST(a, b) ((a) > (b) ? (a) : (b)) int main(void) { printf("%d\n", LARGEST(3, 9)); printf("%.1f\n", LARGEST(2.5, 1.5)); return 0; }
largest : a, a, (a, a -> Bool) -> a largest = |left, right, is_greater| if is_greater(left, right) { left } else { right } main! = |_args| { whole : I64 whole = largest(3, 9, |left, right| left > right) echo!(whole.to_str()) fraction : F64 fraction = largest(2.5, 1.5, |left, right| left > right) echo!(fraction.to_str()) Ok({}) }
The macro works and is checked only after substitution, so an error is reported against expanded text; it also evaluates its arguments twice, which makes LARGEST(i++, j) quietly wrong. The Roc version is type-checked once, at its definition, before any caller exists.
A container of any type
List(a) is a list of any one element type, and nth returns that same type. The element type is known at every call site even though the function is written once.
#include <stdio.h> #include <stdlib.h> #include <string.h> /* A generic container in C is a void* array plus an element size, and every read is a cast. */ static void *nth(void *base, size_t size, size_t i) { return (char *)base + i * size; } int main(void) { int numbers[3] = {10, 20, 30}; printf("%d\n", *(int *)nth(numbers, sizeof(int), 1)); return 0; }
nth : List(a), U64 -> Try(a, [OutOfBounds, ..]) nth = |items, index| items.get(index) main! = |_args| { numbers : List(I64) numbers = [10, 20, 30] echo!(nth(numbers, 1)?.to_str()) Ok({}) }
The C version has to be told the element size and hands back an address the caller casts. Pass sizeof(long) for an array of int and it compiles, runs and reads the wrong memory — which is why the generic C containers people actually use are macros that generate a type-specific version.
Nothing is generated by text
Generic code is written once and type-checked once, in terms of type variables. Nothing is expanded into text and nothing is generated per type at the source level.
#include <stdio.h> /* The X-macro and friends: a real technique, and a real reason C code can be hard to read and impossible to step through. */ #define DEFINE_TOTAL(NAME, TYPE) \ static TYPE NAME(const TYPE *v, size_t n) { \ TYPE s = 0; \ for (size_t i = 0; i < n; i++) s += v[i]; \ return s; } DEFINE_TOTAL(total_int, int) int main(void) { int numbers[3] = {1, 2, 3}; printf("%d\n", total_int(numbers, 3)); return 0; }
# One definition, checked once, working for every # numeric type the caller supplies. total : List(a), a, (a, a -> a) -> a total = |items, zero, add| items.fold(zero, add) main! = |_args| { numbers : List(I64) numbers = [1, 2, 3] echo!(total(numbers, 0, |left, right| left + right).to_str()) Ok({}) }
Macro-generated code is C's only way to get type-specific generics, and the costs are all in the tooling: the error messages point at the expansion, the debugger steps through one line, and the definition has to be written with backslashes and no way to break for readability.
errno & Return Codes 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. The failure is data returned from the function.
#include <stdio.h> #include <stdlib.h> /* The signature is const char * -> int. Nothing in it says atoi cannot report failure, and it cannot: 0 is both an answer and a non-answer. */ static int parse(const char *text) { return atoi(text); } int main(void) { printf("%d\n", parse("17")); return 0; }
# 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's three conventions for this — a sentinel return, a status code with the answer in an out parameter, and errno — all share the same weakness: none of them appears in the type, so the compiler cannot tell a caller who handled the failure from one who did not.
No errno
A failing call returns its failure. There is no global to inspect, so the answer cannot be stale and cannot be clobbered by an unrelated call in between.
#include <stdio.h> #include <errno.h> #include <string.h> #include <stdlib.h> int main(void) { errno = 0; /* errno is a thread-local global. It must be cleared first, and read before anything else overwrites it — including printf. */ double value = strtod("x", NULL); if (errno != 0) printf("%s\n", strerror(errno)); else printf("%.1f\n", value); return 0; }
main! = |_args| { # The failure comes back from the call that # failed, so there is nothing to clear first # and nothing else can overwrite it. match F64.from_str("x") { Ok(value) => echo!(value.to_str()) Err(_) => echo!("not a number") } Ok({}) }
errno requires a protocol — clear it, call, check it before doing anything else — and every step is easy to skip. It is also why a function that fails must document whether it sets errno at all, since many set it only sometimes.
Propagating a failure
A ? after an expression unwraps the Ok and returns early on Err. The happy path reads straight down, and the failure path is one character per step rather than a block.
#include <stdio.h> #include <stdlib.h> /* Every step checks and returns, and the checking is most of the function. */ static int sum(const char *a, const char *b, int *out) { char *end; long first = strtol(a, &end, 10); if (*end != '\0') return -1; long second = strtol(b, &end, 10); if (*end != '\0') return -1; *out = (int)(first + second); return 0; } int main(void) { int total; if (sum("3", "4", &total) != 0) printf("bad input\n"); else printf("%d\n", total); return 0; }
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({}) }
The two columns do the same work, and the C one is four times as long because the checking is written out. That length is why the checks get skipped in practice — and a skipped check in the Roc version is not a risk, because the value cannot be reached without it.
An error can carry information
An error case is a tag with a payload — Insufficient(I64) — so the failure carries whatever the caller needs, and match binds it by name.
#include <stdio.h> /* A return code is a number, so anything else the caller wants has to come back some other way. */ #define ERROR_INSUFFICIENT (-1) static int withdraw(int balance, int amount, int *out) { if (amount > balance) return ERROR_INSUFFICIENT; *out = balance - amount; return 0; } int main(void) { int remaining; if (withdraw(100, 150, &remaining) == ERROR_INSUFFICIENT) printf("short by %d\n", 150 - 100); else printf("%d\n", remaining); return 0; }
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 C column has to recompute the shortfall at the call site, because the error channel is one integer and it is already carrying the error identity. The alternative is another out parameter, which is only written when it fails, so the caller must not read it otherwise.
When there is genuinely no recovery
crash ends the program with a message. It has no type, so it can stand wherever a value is expected, and nothing catches it.
#include <stdio.h> #include <stdlib.h> int main(void) { printf("about to fail\n"); /* No handler runs, and the process ends. */ abort(); }
main! = |_args| { echo!("about to fail") crash "the configuration is impossible" }
Both languages keep a way out for the state that was supposed to be impossible, and both make it look unlike ordinary error handling so it is not reached for by habit.
No Preprocessor
No #define
There is no preprocessor and no macro. What a function-like macro was used for is a function, and the compiler inlines it when that is worth doing.
#include <stdio.h> /* A macro is textual substitution with no type and no scope. The parentheses are defensive. */ #define DOUBLED(x) ((x) * 2) int main(void) { printf("%d\n", DOUBLED(21)); return 0; }
# An ordinary function, with a type, a scope and # one evaluation of its argument. doubled = |number| number * 2 main! = |_args| { echo!(doubled(21.I64).to_str()) Ok({}) }
A macro has no type, obeys no scope, evaluates its arguments however many times the body mentions them, and reports its errors against text the programmer never wrote. The defensive parentheses in the C column are there because omitting them makes DOUBLED(1 + 1) return 3.
No headers, and no include guards
Top-level definitions are visible to one another regardless of order, so a function can be used above where it is written and no forward declaration exists.
#include <stdio.h> /* A declaration here, a definition elsewhere, and an include guard in the header so the textual inclusion is not repeated. */ static int doubled(int number); int main(void) { printf("%d\n", doubled(21)); return 0; } static int doubled(int number) { return number * 2; }
# Definition order does not matter, so nothing is # declared ahead of its use. main! = |_args| { echo!(doubled(21.I64).to_str()) Ok({}) } doubled = |number| number * 2
A C header is textual inclusion, which is why it needs a guard against being pasted twice, why a change to one triggers a rebuild of everything that includes it, and why the declaration and the definition can disagree without the compiler noticing — they are in different translation units.
No #ifdef
There is no conditional compilation. A choice that depends on configuration is an ordinary value and an ordinary if, and both branches are compiled and checked.
#include <stdio.h> int main(void) { /* #ifdef removes code before the compiler sees it, so the disabled branch is never checked and can rot unnoticed. */ #ifdef VERBOSE printf("verbose\n"); #else printf("quiet\n"); #endif return 0; }
main! = |_args| { # An ordinary value and an ordinary if. Both # branches are type-checked, always. verbose : Bool verbose = Bool.False echo!(if verbose { "verbose" } else { "quiet" }) Ok({}) }
That is the real cost of #ifdef: the branch that is switched off is not compiled, so it is not checked, and a rename three months ago may already have broken it. Anyone who has enabled a rarely-used build flag and watched it fail to compile has met this.
What C Still Has
Control of layout
Roc can read a byte list and compute from it, but it cannot map a record onto those bytes: there is no layout, no memcpy into a struct and no way to say how a value is represented.
#include <stdio.h> #include <string.h> #include <stdint.h> /* A wire format: exact widths, known order, and a defined way to read the bytes. */ struct Header { uint16_t kind; uint16_t length; }; int main(void) { unsigned char wire[4] = {0x01, 0x00, 0x08, 0x00}; struct Header header; memcpy(&header, wire, sizeof header); printf("%u %u\n", header.kind, header.length); return 0; }
main! = |_args| { # The bytes have to be taken apart by hand, # because a record has no layout to overlay # them onto. wire : List(U8) wire = [0x01, 0x00, 0x08, 0x00] kind = wire.get(0)?.to_u64() + wire.get(1)?.to_u64() * 256 length = wire.get(2)?.to_u64() + wire.get(3)?.to_u64() * 256 echo!("${kind.to_str()} ${length.to_str()}") Ok({}) }
For a protocol parser, a file header or a memory-mapped device register this is a genuine loss, and it is not one a library can fix — the control has to come from the language. C keeps it, and the two columns here print the same answer by entirely different means.
Choosing where memory comes from
Roc decides where values live and when they are released. There is no allocator parameter, no arena and no way to place a value in memory you provide.
#include <stdio.h> int main(void) { /* One buffer, carved up by hand: no per-item allocation and one release at the end. An arena, in eight lines. */ static unsigned char arena[64]; size_t used = 0; int *first = (int *)(arena + used); used += sizeof(int); int *second = (int *)(arena + used); *first = 1; *second = 2; printf("%d\n", *first + *second); return 0; }
main! = |_args| { # Allocation is the compiler's decision. There # is no allocator to supply, no arena, no pool # and no placement. echo!((1.I64 + 2).to_str()) Ok({}) }
Arena and pool allocation are how C programs get both speed and predictable release, and they are the reason a game engine or a compiler is written this way. Zig makes the allocator an explicit parameter to address the same need; Roc takes the decision away, and that is a real trade rather than a simplification.
Running with no operating system
Every Roc application is compiled against a platform, and the platform is written in another language. Roc is the layer above; it is not the layer that talks to the hardware.
#include <stdio.h> int main(void) { /* The same language compiles freestanding, with no libc at all: a kernel, a bootloader, a microcontroller with two kilobytes of RAM. */ printf("hosted, but it need not be\n"); return 0; }
main! = |_args| { # A Roc application always has a platform under # it, and that platform is written in something # else — often C, Zig or Rust. echo!("hosted, but it need not be") Ok({}) }
That is the structural answer to "could Roc replace C here", and for a bootloader or a device driver it is no. It also means the two languages are not really competing for the same job: a Roc platform is exactly the kind of program C is still the right tool for.
The ABI everything else speaks
Roc has no foreign function interface of its own. A C library becomes available by the platform exposing it, which means someone writes that binding in the platform's language.
#include <stdio.h> #include <math.h> int main(void) { /* libm is a shared object with a C ABI, and so is every other library on the machine. */ printf("%.1f\n", sqrt(16.0)); return 0; }
main! = |_args| { # No foreign function interface in the language. # Reaching a C library means the PLATFORM # exposes it as an effect. echo!(4.0.to_str()) Ok({}) }
C's ABI is the interface every language on the machine agrees on, and being that interface is a large part of why C is still everywhere. Roc reaches it one layer down rather than directly, which is a deliberate boundary and not an oversight.
Fifty years of not changing
Roc is pre-1.0. The examples on this page were verified against one pinned nightly build, and the language has changed underneath pages like this one more than once.
#include <stdio.h> int main(void) { /* This program compiles on every C compiler written since 1989, on every platform. */ printf("still compiles\n"); return 0; }
main! = |_args| { # Roc is pre-1.0, and its syntax and standard # library move week to week. Every example on # this page is pinned to one nightly build. echo!("still compiles") Ok({}) }
C89 code still compiles, everywhere, unchanged. That stability is worth a great deal on its own, and it is the fairest thing to weigh against everything the rest of this page argues — a language is not only its design.
Gotchas for C Programmers
An unannotated number 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.
#include <stdio.h> int main(void) { /* An integer literal is an int. */ int count = 6; printf("%d\n", count); return 0; }
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 surprises nearly everyone, and it surprises quietly — the program is correct and the arithmetic is exact, but 6 prints as 6.0. Any example whose printed form matters should say which number type it means.
A 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.
#include <stdio.h> int main(void) { int total = 0; int numbers[3] = {1, 2, 3}; for (size_t i = 0; i < 3; i++) total = total + numbers[i]; printf("%d\n", total); return 0; }
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.
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.
#include <stdio.h> #include <stdbool.h> int main(void) { bool flag = false; printf("%d\n", !flag); return 0; }
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. C's bool from stdbool.h is a different kind of trap — it is an integer, so !flag prints 1 rather than a word.
There is no character type
A single-quoted literal is a U8 byte, and there is no character type between that and Str. Indexing a string by character is therefore not an operation — you work in bytes or in whole strings.
#include <stdio.h> int main(void) { char letter = 'A'; /* A char is a small integer, so this is arithmetic and prints 66. */ printf("%d\n", letter + 1); return 0; }
main! = |_args| { # 'A' is a U8 byte literal, not a character # type, and there is nothing between a byte and # a whole Str. letter : U8 letter = 'A' echo!((letter + 1).to_str()) Ok({}) }
C's char is the same idea with worse edges: its signedness is implementation-defined, so char c = 0xFF may be −1 or 255 depending on the platform, which is why ctype.h functions take an int and must be passed an unsigned char.
Interpolation converts nothing
Interpolation accepts a Str and nothing else. Forgetting the conversion is a compile error naming the type that arrived, which is among the first things to trip over.
#include <stdio.h> int main(void) { int count = 3; /* printf will happily take %s here and read the 3 as an address. -Wformat catches it for a literal format string. */ printf("count: %d\n", count); return 0; }
main! = |_args| { count : I64 count = 3 # "count: ${count}" does NOT compile — the # interpolation takes a Str and converts nothing. echo!("count: ${count.to_str()}") Ok({}) }
The strictness earns its keep by comparison: printf takes whatever it is given and interprets it according to a format string it cannot check in general, so the same mistake there is a wrong number on a good day and a crash reading an invalid address on a bad one.

Thank you — anything else?