PONYλM2Modula-2

Go.CodeCompared.To/Roc

An interactive executable cheatsheet comparing Go and Roc

Go 1.26.5 Roc nightly
Hello World & The Platform Model
Hello, World
Both languages center on a main, but Roc's takes the command-line arguments as a parameter rather than reading them from a package. The ! at the end of a name means "this performs effects", and the compiler enforces it.
package main import "fmt" func main() { fmt.Println("Hello, World!") }
main! = |_args| { echo!("Hello, World!") Ok({}) }
The _args parameter is named with a leading underscore because it is required by the signature and unused — Go would refuse to compile an unused variable for the same reason the convention exists here. Ok({}) is the return value, where {} is the empty record.
The runtime, chosen at build time
A Go binary is self-contained because the runtime is linked into it. Roc goes further in the same direction: the host is a separate program you choose at build time, and the set of effects an application may perform is whatever that host provides.
package main import "fmt" func main() { // The Go runtime is linked into every binary: // the scheduler, the collector, os, net and // the rest of the standard library. fmt.Println("the runtime ships with the binary") }
main! = |_args| { # A Roc application has no runtime of its own. # It is compiled against a PLATFORM — a host in # Rust or Zig that owns the entry point, the # allocator and every effect it may perform. echo!("every effect comes from the platform") Ok({}) }
The platform behind this page provides exactly one effect, echo!, which is why no example here opens a file or a socket. Swap the platform and the same application code targets a command-line tool, a web server, or a microcontroller with no runtime at all — which is where Go cannot follow.
How a program reports failure
The run() error pattern exists because Go's main returns nothing, so the last step of every program is turning an error into an exit code by hand. Roc's main! returns a Try and the platform does that step.
package main import ( "fmt" "os" ) func run() error { fmt.Println("all good") return nil } func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } }
main! = |_args| { echo!("all good") Ok({}) }
Because the exit status is the function's return value, the compiler type-checks it. There is no os.Exit to call from the wrong place — and no deferred function silently skipped because os.Exit does not run them.
Comments
Comments start with #, and there is no block form, so a multi-line comment is several # lines.
package main import "fmt" func main() { // A single-line comment count := 42 // an inline comment /* A block comment, which can span lines. */ fmt.Println(count) }
main! = |_args| { # A single-line comment count : I64 count = 42 # an inline comment # Roc has no block comment form — 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 beside the name as in Go. Writing it is optional — Roc would infer a type — but it pins down which number type this is, and that decides what gets printed.
Types, and the End of Zero Values
There are no zero values
Go guarantees that every variable has a usable value, which means a struct can always be created without saying what is in it. Roc has no zero values at all: a record is constructed with every field or the program does not compile.
package main import "fmt" type Config struct { Host string Port int Verbose bool } func main() { var config Config // every field silently filled fmt.Printf("%q %d %v\n", config.Host, config.Port, config.Verbose) }
Config : { host : Str, port : U16, verbose : Bool } main! = |_args| { # There is no "var config Config". A record is # built with every field or it is not built: config = { host: "example.com", port: 8080.U16, verbose: Bool.False } echo!(Str.inspect(config)) Ok({}) }
The Go column prints "" 0 false, which is a perfectly valid Config that nobody configured. That is the cost of the guarantee — "was this built by the constructor" is a question Go code asks constantly and cannot answer, and in Roc it cannot be asked.
Inference covers whole programs
Go's := infers the type of a local from its initializer and stops there. Roc infers types for the whole program, including function signatures, so an annotation is a claim you choose to state rather than a requirement.
package main import "fmt" // := infers inside a function; a signature never // infers, and neither does a package-level var // without an initializer. func double(number int) int { return number * 2 } func main() { result := double(21) fmt.Println(result) }
# The annotation is optional here — Roc infers the # whole signature from the body and the call sites. double = |number| number * 2 main! = |_args| { result : I64 result = double(21) echo!(result.to_str()) Ok({}) }
The trade is readability at a distance: a Go signature always tells you the types, and a Roc one only does when someone wrote it. Convention in Roc is to annotate top-level functions for exactly that reason, and the compiler checks the annotation rather than trusting it.
No conversions that can lie
Go's T(value) conversion is checked for kind and not for range, so narrowing an integer truncates without comment. Roc has no conversion syntax at all: widening is an ordinary method, and a value that does not fit is rejected rather than trimmed.
package main import "fmt" func main() { big := 300 small := byte(big) // silently truncates to 44 fmt.Println(small) }
main! = |_args| { # Roc has no cast syntax at all. A literal that # does not fit its type is a compile error: # # byte : U8 # byte = 300 # ^ "This number literal does not fit in the # inferred type." small : U8 small = 44 # Widening is an explicit method call, and there # is no narrowing form that quietly discards # bits the way byte(big) does. echo!(small.to_i64().to_str()) Ok({}) }
Both columns print 44, and that is the point — Go arrived at it by removing the high bits of 300 and calling it a conversion, while the Roc column had to be written with a number that fits. The Go behavior is documented and it is still the kind of thing that reaches production, because nothing at the call site looks wrong.
Records are structural, like your interfaces
Go structs are nominal — two structs with identical fields are different types — while Go interfaces are structural. Roc applies the structural rule to records as well: a record type is its set of fields, and a literal with those fields already is one.
package main import "fmt" type Point struct { X int Y int } func describe(point Point) string { return fmt.Sprintf("(%d, %d)", point.X, point.Y) } func main() { fmt.Println(describe(Point{X: 1, Y: 2})) }
Point : { x : I64, y : I64 } describe : Point -> Str describe = |point| "(${point.x.to_str()}, ${point.y.to_str()})" main! = |_args| { echo!(describe({ x: 1, y: 2 })) Ok({}) }
Point here is an alias, not a new type, so nothing is constructed and no name is written at the call site. When you want Go's nominal behavior, := instead of : gives it to you — see the newtype row in Gotchas.
Values & Immutability
A name is bound once
Roc's = defines a name once. A second definition in the same scope is an error rather than a reassignment, and there is no separate declaration form to opt out with.
package main import "fmt" func main() { greeting := "hello" greeting = "rebound" fmt.Println(greeting) }
main! = |_args| { greeting = "hello" # greeting = "rebound" # ^ warns "duplicate definition" — and still # compiles. Roc means a name to be bound once, # but this pre-1.0 build only warns, and the # name takes the new value from there on. echo!(greeting) Ok({}) }
Go's const is the nearest thing, and it works only for compile-time constants of basic types — there is no const struct and no const slice. In Roc that restriction disappears because everything has it. 🚨 Measured on the pinned build, a duplicate definition is a WARNING rather than an error — it compiles, and the name takes the new value from that point on. Single assignment is Roc's intent and the compiler says so loudly, but a pre-1.0 build has not finished enforcing it. Write as though it were an error, because it is meant to become one.
Opting in to mutation
When something genuinely has to change, var declares it and a $ sigil marks every use — so mutation is visible where you read it rather than inferred from a declaration further up.
package main import "fmt" func main() { total := 0 total = total + 5 total = total + 10 fmt.Println(total) }
main! = |_args| { var $total = 0.I64 $total = $total + 5 $total = $total + 10 echo!($total.to_str()) Ok({}) }
A var is local to its function and cannot escape, so there is no package-level mutable state and no equivalent of a global that two goroutines might reach at once.
No pointers, and no copy-versus-share question
Roc has no pointers, no &, and no value-versus-pointer receiver decision. A function takes a value and returns a new one, and whether that involves a copy is the compiler's business rather than yours.
package main import "fmt" type Counter struct{ Value int } func incrementCopy(counter Counter) { counter.Value++ } func incrementShared(counter *Counter) { counter.Value++ } func main() { counter := Counter{Value: 0} incrementCopy(counter) fmt.Println(counter.Value) // 0: it took a copy incrementShared(&counter) fmt.Println(counter.Value) // 1: it took a pointer }
Counter : { value : I64 } increment : Counter -> Counter increment = |counter| { ..counter, value: counter.value + 1 } main! = |_args| { counter = { value: 0.I64 } once = increment(counter) echo!(counter.value.to_str()) echo!(once.value.to_str()) Ok({}) }
The Go column shows the whole reason the decision exists: the same field, incremented twice, changes once. Roc removes the question rather than answering it — nothing can be modified through any reference, so passing a value is always safe and the compiler still avoids the copy whenever it can prove nobody else holds the original.
Destructuring
Go's multiple return values become a single tuple in Roc — one value with a type, which can be stored in a list or passed on. Record destructuring has no Go equivalent at all.
package main import "fmt" func coordinates() (int, int) { return 3, 4 } func main() { x, y := coordinates() fmt.Printf("%d, %d\n", x, y) person := struct { Name string Age int }{Name: "Grace", Age: 85} name, age := person.Name, person.Age fmt.Printf("%s: %d\n", name, age) }
coordinates : () -> (I64, I64) coordinates = || (3, 4) main! = |_args| { (x, y) = coordinates() echo!("${x.to_str()}, ${y.to_str()}") person = { name: "Grace", age: 85.I64 } { name, age } = person echo!("${name}: ${age.to_str()}") Ok({}) }
Naming a record's fields pulls them out by name, with the compiler checking that each one exists. Go has to name each field twice, once to read it and once to bind it, which is why struct-to-local unpacking is rare in Go code.
There Is No nil
There is no nil
Roc has no nil, no null pointer and no zero value standing in for one. Absence is modeled by a tag that says what is absent, and the function's type names both possibilities.
package main import "fmt" func findUser(userID int) *string { if userID == 1 { name := "Ada" return &name } return nil } func main() { if name := findUser(1); name != nil { fmt.Println("found " + *name) } else { fmt.Println("missing") } }
find_user : U32 -> [Found(Str), Missing] find_user = |user_id| { if user_id == 1 { Found("Ada") } else { Missing } } main! = |_args| { match find_user(1) { Found(name) => echo!("found ${name}") Missing => echo!("missing") } Ok({}) }
The Go signature says *string, which means "a string, or nothing, and the compiler will not remind you which". Forgetting the check is a nil dereference at run time; forgetting the Missing branch in Roc is a compile error naming it.
The typed nil, which cannot happen here
An interface value in Go carries a type alongside a pointer, so an interface holding a nil pointer is itself not nil. This is the most-reported surprise in the language, and it has no analogue in Roc because a Try is Ok or Err and there is no third state.
package main import "fmt" type MyError struct{} func (e *MyError) Error() string { return "boom" } func mightFail() error { var err *MyError // nil pointer... return err // ...wrapped in a non-nil interface } func main() { fmt.Println(mightFail() == nil) }
might_fail : {} -> Try({}, [Boom]) might_fail = |{}| Ok({}) main! = |_args| { match might_fail({}) { Ok(_) => echo!("no error") Err(Boom) => echo!("boom") } Ok({}) }
The Go column prints false: the function returned a nil *MyError, and err != nil at the call site is therefore true, so a program with no error takes the error path. Nothing in the Roc column can be nil, so there is nothing for a wrapper to hide.
No nil map, no nil slice
A nil Go map reads like an empty one and panics when written; a nil slice appends like an empty one. Those are two different accommodations for the same missing concept, and Roc needs neither.
package main import "fmt" func main() { var scores map[string]int fmt.Println(scores["art"]) // reads fine: 0 fmt.Println(len(scores)) // also fine: 0 // scores["art"] = 95 would PANIC var numbers []int numbers = append(numbers, 1) // a nil slice fmt.Println(numbers) // appends fine }
main! = |_args| { # An empty Dict and an empty List are ordinary # values with nothing special about them, and # there is no other state they could be in. scores : Dict(Str, I64) scores = Dict.empty() echo!((scores.get("art") ?? 0).to_str()) echo!(scores.len().to_str()) numbers : List(I64) numbers = [] echo!(Str.inspect(numbers.append(1))) Ok({}) }
The rules above are learnable and they are still rules — "can I write to this map" is a question about how the map was created, answered at run time. An empty Dict and an empty List in Roc behave exactly like non-empty ones.
The comma-ok idiom becomes a Try
Go's comma-ok idiom is the right idea with optional syntax: the second return value exists, and nothing makes you take it. Roc returns one value, a Try, which you cannot look inside without deciding what to do about failure.
package main import "fmt" func main() { scores := map[string]int{"art": 95} if value, ok := scores["art"]; ok { fmt.Println(value) } // Without the ok, a missing key is // indistinguishable from a stored zero: fmt.Println(scores["music"]) }
main! = |_args| { scores = Dict.empty().insert("art", 95.I64) match scores.get("art") { Ok(value) => echo!(value.to_str()) Err(_) => echo!("absent") } echo!((scores.get("music") ?? 0).to_str()) Ok({}) }
The last Go line prints 0 for a key that is not there, which is also what it would print for a key stored as zero. ?? makes the same fallback explicit in Roc, and the reader of the code can see that a default was chosen. Both columns print the same two lines, which is the point: the difference this row demonstrates is in what the source obliges you to write, not in the answer.
Sum Types, Which Go Lacks
One of several shapes
This is the largest gap on the page. Go has no sum type, so "one of several shapes" is a sealed interface — an interface with an unexported method nobody outside the package can implement — plus a type switch. Roc has the thing itself, in two lines.
package main import "fmt" type Shape interface{ isShape() } type Circle struct{ Radius float64 } type Rectangle struct{ Width, Height float64 } func (Circle) isShape() {} func (Rectangle) isShape() {} func area(shape Shape) float64 { switch value := shape.(type) { case Circle: return 3.14159 * value.Radius * value.Radius case Rectangle: return value.Width * value.Height } return 0 // unreachable, and required anyway } func main() { fmt.Println(area(Circle{Radius: 2})) fmt.Println(area(Rectangle{Width: 3, Height: 4})) }
Shape := [Circle(Dec), Rectangle(Dec, Dec)] area : Shape -> Dec area = |shape| match shape { Circle(radius) => 3.14159 * radius * radius Rectangle(width, height) => width * height } main! = |_args| { echo!(area(Shape.Circle(2)).to_str()) echo!(area(Shape.Rectangle(3, 4)).to_str()) Ok({}) }
Count what the Go column needs: an interface, two marker methods, a type switch, and a return 0 for a case that cannot happen but that the compiler demands. Add a third shape and the Go version still compiles and silently returns zero; the Roc version stops compiling until the new case is handled.
Enums, which Go fakes with iota
Go's iota enum is a named integer type, which means every integer is a potential value of it. Roc's := declares a genuinely new type whose values are exactly the tags listed.
package main import "fmt" type Color int const ( Red Color = iota Green Blue ) func toHex(color Color) string { switch color { case Red: return "#FF0000" case Green: return "#00FF00" case Blue: return "#0000FF" } return "?" } func main() { fmt.Println(toHex(Green)) fmt.Println(toHex(Color(99))) // a valid Color }
Color := [Red, Green, Blue] to_hex : Color -> Str to_hex = |color| match color { Red => "#FF0000" Green => "#00FF00" Blue => "#0000FF" } main! = |_args| { echo!(to_hex(Color.Green)) # Color(99) has no equivalent: the only values # of type Color are the three named above. Ok({}) }
The second Go line is the point: Color(99) is a legal Color and the switch falls through to the "?" that had to be written for it. Roc's match needs no fallback, because there is no fourth value to fall back for.
Tags that need no declaration at all
A tag can be used with no declaration anywhere. Morning is a value the moment you write it, and its type is inferred as the set of tags that can reach that position.
package main import "fmt" func main() { hour := 14 period := "afternoon" if hour < 12 { period = "morning" } label := "PM" if period == "morning" { label = "AM" } fmt.Println(label) }
main! = |_args| { hour : I64 hour = 14 period = if hour < 12 { Morning } else { Afternoon } label = match period { Morning => "AM" Afternoon => "PM" } echo!(label) Ok({}) }
Go's stand-in for a two-state value with no declaration is a string, and a string is a poor discriminant: "mornign" compares false and nothing complains. Roc knows the union is exactly [Morning, Afternoon], so a misspelling is a compile error and so is a missing branch.
Open unions: room for tags you have not met
The .. in the type means "and possibly other tags". The function handles two by name and everything else with a wildcard, and callers may pass tags that did not exist when it was written.
package main import "fmt" func describe(signal any) string { switch signal { case "go": return "go" case "stop": return "stop" } return "something else" } func main() { fmt.Println(describe("go")) fmt.Println(describe(7)) }
describe : [Go, Stop, ..] -> Str describe = |signal| match signal { Go => "go" Stop => "stop" _ => "something else" } main! = |_args| { echo!(describe(Go)) echo!(describe(Custom(7.I64))) Ok({}) }
Go's version of "or something else" is any, which gives up on the type entirely. The Roc signature keeps the closed part closed — the compiler still checks that Go and Stop are handled — while leaving the extension point open.
Recursive data structures
A declared union may mention itself, which is how trees and syntax trees are written. No indirection is spelled out — the compiler works out where a pointer is needed.
package main import "fmt" type Tree interface{ isTree() } type Leaf struct{ Value int } type Node struct{ Left, Right Tree } func (Leaf) isTree() {} func (Node) isTree() {} func sumTree(tree Tree) int { switch value := tree.(type) { case Leaf: return value.Value case Node: return sumTree(value.Left) + sumTree(value.Right) } return 0 } func main() { tree := Node{ Left: Leaf{Value: 1}, Right: Node{Left: Leaf{Value: 2}, Right: Leaf{Value: 3}}, } fmt.Println(sumTree(tree)) }
Tree := [Leaf(I64), Node(Tree, Tree)] sum_tree : Tree -> I64 sum_tree = |tree| match tree { Leaf(value) => value Node(left, right) => sum_tree(left) + sum_tree(right) } main! = |_args| { tree = Tree.Node(Tree.Leaf(1), Tree.Node(Tree.Leaf(2), Tree.Leaf(3))) echo!(sum_tree(tree).to_str()) Ok({}) }
The two columns do the same work in twenty-five lines and six. The Go version also admits a state the Roc one does not: a Node with a nil Left is constructible, compiles, and panics at whatever depth it happens to sit.
Numbers
A familiar menu of sizes
This is one of the closest correspondences on the page. Roc has I8 through I128, U8 through U128, F32 and F64 — Go's menu, one size wider, and with the same refusal to widen implicitly.
package main import "fmt" func main() { var byteValue uint8 = 255 var ratio float64 = 2.5 fmt.Println(byteValue, ratio) }
main! = |_args| { byte : U8 byte = 255 ratio : F64 ratio = 2.5 echo!("${byte.to_str()} ${ratio.to_str()}") Ok({}) }
What Roc adds is Dec, a fixed-point decimal type with no Go equivalent in the standard library. What it removes is int and uint, the platform-dependent sizes, so every integer in a Roc program has a width you can name.
Exact decimals, without a library
Dec is a fixed-point decimal type and an ordinary member of the number menu — same operators, same literals. It is also what an unannotated decimal literal becomes, so exactness is the default rather than the opt-in.
package main import "fmt" func main() { fmt.Println(0.1 + 0.2) // math/big has big.Rat, at the cost of a // different type and a different API. }
main! = |_args| { lossy : F64 lossy = 0.1 + 0.2 echo!(lossy.to_str()) precise : Dec precise = 0.1 + 0.2 echo!(precise.to_str()) Ok({}) }
Go's answer is math/big, which means a different type with methods instead of operators, and a decision at every arithmetic site. Here the difference between the two Roc lines is one annotation.
Integer division and remainder
Roc separates the two divisions into two operators: // floors and / divides. Go uses / for both and decides which you meant from the operand types.
package main import "fmt" func main() { fmt.Println(17 / 5) // 3: integer division, // because both are ints fmt.Println(17 % 5) fmt.Println(17.0 / 5.0) }
main! = |_args| { quotient : I64 quotient = 17 // 5 echo!(quotient.to_str()) remainder : I64 remainder = 17 % 5 echo!(remainder.to_str()) exact : Dec exact = 17 / 5 echo!(exact.to_str()) Ok({}) }
That decision is the trap: in Go, 17 / 5 and 17.0 / 5.0 are different operations spelled almost identically, and a change of variable type silently changes which one runs. Two operators cannot be confused that way.
Overflow is caught, not wrapped
Roc's integer arithmetic is checked. An addition that would pass the top of the range is an error, and when both operands are known at compile time — as here — it is caught before the program runs.
package main import ( "fmt" "math" ) func main() { big := int64(math.MaxInt64) fmt.Println(big + 1) // wraps, silently }
main! = |_args| { big : I64 big = 9_223_372_036_854_775_807 # echo!((big + 1).to_str()) # ^ COMPILE ERROR: "Integer addition overflowed!" echo!(big.to_str()) Ok({}) }
The Go column prints the smallest int64, because Go defines signed overflow as wrapping. That is predictable and it is still a bug every time it happens. Underscores as digit separators work in both languages.
Strings
Sprintf becomes interpolation
Every Roc string can interpolate with ${}, so there is no format string and no verb to choose. The one catch is that interpolation takes a Str and will not convert a number for you.
package main import "fmt" func main() { name := "Roc bird" age := 10 fmt.Printf("%s is %d\n", name, age) message := fmt.Sprintf("%s turns %d", name, age+1) fmt.Println(message) }
main! = |_args| { name = "Roc bird" age : I64 age = 10 echo!("${name} is ${age.to_str()}") message = "${name} turns ${(age + 1).to_str()}" echo!(message) Ok({}) }
A format string is a second little language that the compiler checks separately — go vet catches a mismatched verb, and only because somebody wrote a checker for it. Interpolation puts the expression where it is used, so there is nothing to keep in step.
Concatenation without +
Roc reserves + for numbers. Joining two strings is concat, available either as a method on the left-hand string or as a plain function.
package main import ( "fmt" "strings" ) func main() { fmt.Println("Fast " + "and friendly") var builder strings.Builder builder.WriteString("also") builder.WriteString(" works") fmt.Println(builder.String()) }
main! = |_args| { echo!("Fast ".concat("and friendly")) echo!(Str.concat("also", " works")) Ok({}) }
The two forms are the same function: "a".concat("b") is resolved at compile time to Str.concat("a", "b"). There is no strings.Builder because there is nothing to build into — but repeated concatenation costs the same as it does in Go, so joining a list is Str.join_with rather than a fold.
Everyday string methods
The same operations, as methods on the string rather than functions in a strings package — and with no import, because there is no package to import from.
package main import ( "fmt" "strings" ) func main() { padded := " systems " fmt.Println(strings.TrimSpace(padded)) fmt.Println(strings.Repeat("ab", 3)) fmt.Println(strings.HasPrefix("systems", "sys")) fmt.Println(strings.Contains("systems", "stem")) }
main! = |_args| { padded = " systems " echo!(padded.trim()) echo!("ab".repeat(3)) echo!(Str.inspect("systems".starts_with("sys"))) echo!(Str.inspect("systems".contains("stem"))) Ok({}) }
Method syntax here is sugar: "ab".repeat(3) is resolved at compile time to Str.repeat("ab", 3), so it is the strings.Repeat call with the receiver moved. Str.inspect turns a non-string value into something printable.
Splitting and joining
Splitting is split_on and joining is Str.join_with, with the same argument order as strings.Join: the list first, the separator second.
package main import ( "fmt" "strings" ) func main() { parts := strings.Split("red,green,blue", ",") fmt.Println(len(parts)) fmt.Println(strings.Join(parts, " | ")) }
main! = |_args| { parts = "red,green,blue".split_on(",") echo!(parts.len().to_str()) echo!(Str.join_with(parts, " | ")) Ok({}) }
What is missing is the rest of the strings package and all of regexp — this build has no regular expressions at all, so anything more elaborate than a literal separator is a fold over the bytes.
Strings are UTF-8 bytes in both languages
This is the closest correspondence on the page: a Go string is immutable UTF-8 bytes, and so is a Roc Str. len and count_utf8_bytes answer the same question.
package main import ( "fmt" "unicode/utf8" ) func main() { fmt.Println("rocket: \U0001F680") fmt.Println(len("héllo")) // 6 bytes fmt.Println(utf8.RuneCountInString("héllo")) // 5 runes }
main! = |_args| { echo!("rocket: \u(1F680)") echo!("héllo".count_utf8_bytes().to_str()) Ok({}) }
The difference is what sits on top. Go adds a rune type and unicode/utf8 for character-level work; Roc has no Char type and no way to subscript a string at all, so there is no byte index to get wrong in the first place.
Slices vs Lists
A list is a value, not a window
A Go slice is a window onto an array — a pointer, a length and a capacity. A Roc List is the thing itself, so there is no capacity, no backing array and no distinction between the list and a view of it.
package main import "fmt" func main() { numbers := []int{3, 1, 4, 1, 5} fmt.Println(len(numbers), cap(numbers)) fmt.Println(numbers) }
main! = |_args| { numbers : List(I64) numbers = [3, 1, 4, 1, 5] echo!(numbers.len().to_str()) echo!(Str.inspect(numbers)) Ok({}) }
That removes the most surprising thing about slices, covered two rows down: because a Roc list has no backing array to share, no two lists can ever be views of the same memory.
The aliasing bug that cannot happen
Appending to a slice writes into the backing array when there is spare capacity, so a slice taken from the front of another can overwrite it. This is the single most-reported Go surprise after the typed nil.
package main import "fmt" func main() { numbers := []int{1, 2, 3, 4, 5} head := numbers[:2] head = append(head, 99) // writes into numbers! fmt.Println(numbers) fmt.Println(head) }
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3, 4, 5] head = numbers.take_first(2).append(99) echo!(Str.inspect(numbers)) echo!(Str.inspect(head)) Ok({}) }
The Go column prints [1 2 99 4 5] — appending to head replaced the third element of numbers, which nobody asked it to touch. The Roc column prints the original unchanged, because take_first produced a separate list and there is nothing to share.
map and filter, which Go writes by hand
Roc has map and keep_iffilter under another name — and they chain. Go's generics made such functions possible to write, and the standard library mostly still expects the loop.
package main import "fmt" func main() { numbers := []int{1, 2, 3, 4, 5, 6} var doubledEvens []int for _, number := range numbers { if number%2 == 0 { doubledEvens = append(doubledEvens, number*2) } } fmt.Println(doubledEvens) }
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3, 4, 5, 6] doubled_evens = numbers .keep_if(|number| number % 2 == 0) .map(|number| number * 2) echo!(Str.inspect(doubled_evens)) Ok({}) }
The Go loop is not hard to read; it is hard to read quickly, because the intent is spread across a declaration, a range, a condition and an append. The chain says filter-then-double in the order it happens.
Accumulating with fold
fold takes a starting value and a function and threads the accumulator through the list — the loop above with the bookkeeping already written.
package main import "fmt" func main() { numbers := []int{1, 2, 3, 4} total := 0 for _, number := range numbers { total += number } fmt.Println(total) }
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3, 4] total = numbers.fold(0, |accumulator, number| accumulator + number) echo!(total.to_str()) echo!(numbers.sum().to_str()) Ok({}) }
The starting value is not optional, so there is no empty-slice special case to get wrong. sum exists for this particular fold because it is so common.
Indexing cannot panic
Reading past the end of a Roc list produces a Try rather than stopping the program. The out-of-bounds case becomes something the type system makes you handle where it happens.
package main import "fmt" func main() { numbers := []int{10, 20, 30} fmt.Println(numbers[1]) // numbers[9] compiles and PANICS at run time: // index out of range [9] with length 3 }
main! = |_args| { numbers : List(I64) numbers = [10, 20, 30] match numbers.get(9) { Ok(value) => echo!(value.to_str()) Err(_) => echo!("out of bounds") } fallback = numbers.get(1) ?? 0 echo!(fallback.to_str()) Ok({}) }
Go's bounds check is real and its response is a panic, which is an exception in all but name — recovered with defer and recover, or not recovered at all. ?? supplies a default when that is what you want, which keeps the Roc version about as short as the subscript.
Sorting returns a new list
sort returns a new list and orders by the element type, because the element type is known. There is no comparator to pass for the ordinary case and no type-specific function to pick.
package main import ( "fmt" "sort" ) func main() { numbers := []int{3, 1, 2} sort.Ints(numbers) // in place, returns nothing fmt.Println(numbers) }
main! = |_args| { numbers : List(I64) numbers = [3, 1, 2] echo!(Str.inspect(numbers.sort())) echo!(Str.inspect(numbers.sort_reversed())) echo!(Str.inspect(numbers)) Ok({}) }
The last Roc line shows numbers unchanged after two sorts. sort.Ints always rearranges the slice it was given, which matters when that slice is a view of something else — see the aliasing row above.
any, all and find
Three predicates that take a function: any, all and find_first.
package main import "fmt" func main() { numbers := []int{2, 4, 6, 7} hasOdd := false for _, number := range numbers { if number%2 == 1 { hasOdd = true break } } fmt.Println(hasOdd) found, ok := 0, false for _, number := range numbers { if number > 5 { found, ok = number, true break } } if ok { fmt.Printf("found %d\n", found) } else { fmt.Println("none") } }
main! = |_args| { numbers : List(I64) numbers = [2, 4, 6, 7] echo!(Str.inspect(numbers.any(|number| number % 2 == 1))) echo!(Str.inspect(numbers.all(|number| number > 0))) match numbers.find_first(|number| number > 5) { Ok(found) => echo!("found ${found.to_str()}") Err(_) => echo!("none") } Ok({}) }
The Go column shows what these cost written out, and where the comma-ok habit comes from — the "was there a match" flag has to be carried alongside the value because there is no way to return one-or-nothing. find_first returns a Try, which carries both in one value.
Index and element together
Roc's map passes only the element, so the index needs a different method: map_with_index, which takes the element first and the index second.
package main import "fmt" func main() { words := []string{"one", "two"} labeled := make([]string, 0, len(words)) for index, word := range words { labeled = append(labeled, fmt.Sprintf("%d:%s", index, word)) } fmt.Println(labeled) }
main! = |_args| { words = ["one", "two"] labeled = words.map_with_index(|word, index| "${index.to_str()}:${word}") echo!(Str.inspect(labeled)) Ok({}) }
Note the argument order is the reverse of range, which yields the index first. Getting it backwards still type-checks whenever both happen to be numbers, so it is worth reading twice the first few times.
Structs vs Records
Building from defaults
Roc's .. spread copies a record and overrides named fields in one expression, and it cannot introduce a field the record did not already have.
package main import "fmt" type Config struct { Verbose bool Retries int TimeoutSeconds int } func main() { defaults := Config{Verbose: false, Retries: 3, TimeoutSeconds: 30} custom := defaults // a copy, because it is a value custom.Retries = 5 fmt.Println(custom) fmt.Println(defaults) }
main! = |_args| { defaults = { verbose: Bool.False, retries: 3.I64, timeout_seconds: 30.I64 } custom = { ..defaults, retries: 5 } echo!(Str.inspect(custom)) echo!(Str.inspect(defaults)) Ok({}) }
Go reaches the same place by a different route: a struct is a value, so assigning it copies. That works until the struct contains a slice or a map, at which point the copy shares them — a shallow-copy question Roc does not have, because nothing it could share is mutable.
Naming a shape
A one-line alias names a record shape. Nothing constructs an Employee — the literal already is one, because it has those fields with those types.
package main import "fmt" type Employee struct { Name string Department string } func describe(employee Employee) string { return employee.Name + " works in " + employee.Department } func main() { fmt.Println(describe(Employee{Name: "Nia", Department: "Compilers"})) }
Employee : { name : Str, department : Str } describe : Employee -> Str describe = |employee| "${employee.name} works in ${employee.department}" main! = |_args| { employee = { name: "Nia", department: "Compilers" } echo!(describe(employee)) Ok({}) }
The difference is nominal versus structural. Go would reject an identically-shaped struct of another name; Roc accepts any record with those fields. Equality and Str.inspect come free on every record, so there is no String() method to write.
Maps become Dict, with a stable order
When the keys really are data, Roc has Dict. Every key shares one type and so does every value, exactly as in a Go map.
package main import "fmt" func main() { scores := map[string]int{} scores["math"] = 90 scores["art"] = 95 fmt.Println(len(scores)) fmt.Println(scores["art"]) value, ok := scores["music"] if !ok { value = 0 } fmt.Println(value) }
main! = |_args| { scores = Dict.empty() .insert("math", 90.I64) .insert("art", 95.I64) echo!(scores.len().to_str()) echo!((scores.get("art") ?? 0).to_str()) echo!((scores.get("music") ?? 0).to_str()) Ok({}) }
insert returns a new dict rather than changing the old one, which is why the calls chain. Roc also preserves insertion order when iterating, where Go randomizes it deliberately — a decision made to stop people depending on an order that was never guaranteed.
Tuples, which Go spells as multiple returns
Go's multiple return values are not a value — they exist only at the boundary of a call. A Roc tuple is an ordinary value with a type, so it can be stored, passed on, or put in a list.
package main import "fmt" func divide(numerator, denominator int) (int, int) { return numerator / denominator, numerator % denominator } func main() { quotient, remainder := divide(17, 5) fmt.Printf("%d remainder %d\n", quotient, remainder) // The pair cannot be stored, passed on, or put // in a slice without a struct to hold it. }
divide : I64, I64 -> (I64, I64) divide = |numerator, denominator| (numerator // denominator, numerator % denominator) main! = |_args| { (quotient, remainder) = divide(17, 5) echo!("${quotient.to_str()} remainder ${remainder.to_str()}") pairs = [divide(17, 5), divide(9, 2)] echo!(Str.inspect(pairs)) Ok({}) }
The second Roc line shows what that buys: a list of pairs, with no struct declared for it. In Go the same thing needs a named type, or an anonymous struct written out at every mention.
Deep equality on everything
Roc's == compares values all the way down, on every type — records, lists, dicts and tag unions included.
package main import ( "fmt" "reflect" ) func main() { first := map[string]int{"a": 1} second := map[string]int{"a": 1} // first == second does not COMPILE: maps are // not comparable. fmt.Println(reflect.DeepEqual(first, second)) }
main! = |_args| { first = Dict.empty().insert("a", 1.I64) second = Dict.empty().insert("a", 1.I64) echo!(Str.inspect(first == second)) Ok({}) }
Go's == works on structs of comparable fields and refuses to compile for maps, slices and functions, so comparing them means reflect.DeepEqual — reflection, unchecked at compile time, and slow. Roc needs no such escape hatch, because nothing has an identity separate from its contents.
Pattern Matching vs switch
switch is a statement; match is an expression
Go's switch is a statement, so each arm assigns to a variable declared before it. Roc's match produces a value, so the whole thing sits on the right of one =.
package main import "fmt" func main() { statusCode := 404 var message string switch statusCode { case 200: message = "ok" case 404: message = "not found" default: message = "something else" } fmt.Println(message) }
main! = |_args| { status_code : I64 status_code = 404 message = match status_code { 200 => "ok" 404 => "not found" _ => "something else" } echo!(message) Ok({}) }
Go already fixed the fall-through problem, so that is not the difference here. What is: every Roc arm must produce a value of the same type, so an arm that forgets to assign is a compile error rather than a zero value the reader has to notice.
Guards
A guard is a condition attached to a pattern. Roc mixes literal patterns and guards in one match, where Go's expressionless switch makes every arm a condition.
package main import "fmt" func describe(number int) string { switch { case number == 0: return "zero" case number < 0: return "negative" case number%2 == 0: return "positive even" default: return "positive odd" } } func main() { fmt.Println(describe(0)) fmt.Println(describe(-5)) fmt.Println(describe(8)) }
describe : I64 -> Str describe = |number| match number { 0 => "zero" n if n < 0 => "negative" n if n % 2 == 0 => "positive even" _ => "positive odd" } main! = |_args| { echo!(describe(0)) echo!(describe(-5)) echo!(describe(8)) Ok({}) }
The first arm shows the difference: 0 is a pattern, not a comparison, and the compiler counts it toward exhaustiveness. A guarded arm never counts, in either language, which is why the wildcard is still required.
Matching on a list's shape
A pattern can describe a list's shape directly — empty, exactly one element, or a first element plus the rest — and bind the pieces in the same breath.
package main import "fmt" func describe(numbers []int) string { switch len(numbers) { case 0: return "empty" case 1: return fmt.Sprintf("one: %d", numbers[0]) default: return fmt.Sprintf("first %d, %d more", numbers[0], len(numbers)-1) } } func main() { fmt.Println(describe([]int{})) fmt.Println(describe([]int{7})) fmt.Println(describe([]int{1, 2, 3})) }
describe : List(I64) -> Str describe = |numbers| match numbers { [] => "empty" [single] => "one: ${single.to_str()}" [first, .. as rest] => "first ${first.to_str()}, ${rest.len().to_str()} more" } main! = |_args| { echo!(describe([])) echo!(describe([7])) echo!(describe([1, 2, 3])) Ok({}) }
The Go version switches on a length and then indexes, so the compiler cannot connect the two: numbers[0] in the case 1 arm is bounds-checked at run time like any other subscript. In the Roc version the binding is the check.
Exhaustiveness is checked, not linted
This is the payoff for declaring the union. The compiler knows every tag the value can be, so it can name the one you forgot, at the moment you forget it.
package main import "fmt" type Color int const ( Red Color = iota Green Blue ) func toHex(color Color) string { switch color { case Red: return "#FF0000" case Green: return "#00FF00" } // Blue is missing. The compiler requires a // return here and says nothing about Blue. return "" } func main() { fmt.Printf("%q\n", toHex(Green)) fmt.Printf("%q\n", toHex(Blue)) }
Color := [Red, Green, Blue] to_hex : Color -> Str to_hex = |color| match color { Red => "#FF0000" Green => "#00FF00" # Deleting the next line is a COMPILE ERROR # naming Blue as the case not handled. Blue => "#0000FF" } main! = |_args| { echo!(to_hex(Color.Green)) echo!(to_hex(Color.Blue)) Ok({}) }
Go's compiler cannot do this even in principle, because Color is an integer type with four billion values. The nearest thing is exhaustive, a third-party linter you have to install, configure and remember to run — and it works by convention, not by type.
Or-patterns
Alternatives within one branch are written with | rather than a comma.
package main import "fmt" func sizeClass(number int) string { switch number { case 1, 2, 3: return "small" default: return "big" } } func main() { fmt.Println(sizeClass(2)) fmt.Println(sizeClass(9)) }
size_class : I64 -> Str size_class = |number| match number { 1 | 2 | 3 => "small" _ => "big" } main! = |_args| { echo!(size_class(2)) echo!(size_class(9)) Ok({}) }
This is another place the two languages agree in substance. It matters more in Roc, where the alternatives can be tags carrying payloads rather than only constants.
Try vs if err != nil
The error return becomes the return type
Go and Roc agree on the big decision: errors are ordinary values and there are no exceptions. The difference is that Go returns two values and trusts you to look at the second, while Roc returns one value you cannot use without deciding what to do about failure.
package main import ( "fmt" "strconv" "strings" ) func parseScore(text string) (int, error) { score, err := strconv.Atoi(strings.TrimSpace(text)) if err != nil { return 0, fmt.Errorf("bad score: %s", text) } return score, nil } func main() { for _, candidate := range []string{"95", "not a number"} { score, err := parseScore(candidate) if err != nil { fmt.Println(err) continue } fmt.Printf("score: %d\n", score) } }
parse_score : Str -> Try(I64, [BadScore(Str)]) parse_score = |text| match I64.from_str(text.trim()) { Ok(score) => Ok(score) Err(_) => Err(BadScore(text)) } main! = |_args| { for candidate in ["95", "not a number"] { match parse_score(candidate) { Ok(score) => echo!("score: ${score.to_str()}") Err(BadScore(bad)) => echo!("bad score: ${bad}") } } Ok({}) }
The Go version has to invent a zero score to return alongside the error, and a caller that ignores err gets that zero silently. In Roc there is no second value to ignore: Ok and Err are different shapes, and the compiler will not let you read a score out of a failure.
if err != nil becomes one character
? unwraps an Ok and returns early from the enclosing function on an Err. It is the three lines of if err != nil { return err }, compressed to one character and placed where the failure happens.
package main import ( "errors" "fmt" ) func showFirst(numbers []int) error { if len(numbers) == 0 { return errors.New("empty") } first := numbers[0] fmt.Printf("first: %d\n", first*2) return nil } func main() { if err := showFirst([]int{5, 6, 7}); err != nil { fmt.Println(err) } }
show_first! = |numbers| { first = numbers.first()? echo!("first: ${(first * 2).to_str()}") Ok({}) } main! = |_args| { numbers : List(I64) numbers = [5, 6, 7] show_first!(numbers) }
This is the proposal Go has debated for a decade and repeatedly declined, most visibly as try in 2019. The argument against it is that hiding the return makes control flow less obvious; the counter is on screen here, where one character marks the exit and the reader can still see every one of them.
Error types compose without errors.Is
An error in Roc is a tag, and the set of errors a function can return is written in its signature as a union. There is no sentinel to declare, no %w to remember and no errors.Is at the call site.
package main import ( "errors" "fmt" "strconv" ) var ErrBadPort = errors.New("bad port") func readPort(text string) (uint16, error) { value, err := strconv.ParseUint(text, 10, 16) if err != nil { return 0, fmt.Errorf("%w: %s", ErrBadPort, text) } return uint16(value), nil } func main() { for _, candidate := range []string{"8080", "eighty"} { port, err := readPort(candidate) if errors.Is(err, ErrBadPort) { fmt.Printf("bad port: %s\n", candidate) continue } fmt.Println(port) } }
read_port : Str -> Try(U16, [BadPort(Str)]) read_port = |text| match U16.from_str(text) { Ok(port) => Ok(port) Err(_) => Err(BadPort(text)) } main! = |_args| { for candidate in ["8080", "eighty"] { match read_port(candidate) { Ok(port) => echo!(port.to_str()) Err(BadPort(bad)) => echo!("bad port: ${bad}") } } Ok({}) }
Go's error is one interface, so the signature says only "something can go wrong" and the caller has to know which sentinels to test for. The Roc signature lists them, and a match that misses one does not compile.
crash, and why there is no recover
There is exactly one way to stop a Roc program abruptly, and unlike panic it cannot be caught: there is no recover and no deferred function to put one in.
package main import "fmt" func divide(numerator, denominator int) int { if denominator == 0 { panic("impossible: checked upstream") } return numerator / denominator } func main() { defer func() { if recovered := recover(); recovered != nil { fmt.Println("recovered:", recovered) } }() fmt.Println(divide(10, 2)) }
divide : I64, I64 -> I64 divide = |numerator, denominator| if denominator == 0 { # crash is not catchable. It is for states # the program has already established # cannot happen. crash "impossible: checked upstream" } else { numerator // denominator } main! = |_args| { echo!(divide(10, 2).to_str()) Ok({}) }
That is deliberate. recover turns an assertion failure into a catchable event, so a library can swallow a genuine invariant violation and carry on with whatever state caused it — which is how a panic in one request handler becomes a corrupted cache for everybody else.
Functions & Closures
One function form
Roc has one way to write a function, and it is the anonymous one. A named function is a name bound to a closure, so the top-level and local forms are identical.
package main import "fmt" func add(left, right int) int { return left + right } func main() { alsoAdd := func(left, right int) int { return left + right } fmt.Println(add(2, 3)) fmt.Println(alsoAdd(2, 3)) }
add : I64, I64 -> I64 add = |left, right| left + right main! = |_args| { also_add = |left, right| left + right echo!(add(2, 3).to_str()) echo!(also_add(2.I64, 3.I64).to_str()) Ok({}) }
Go already comes close — func literals are values and closures work the same way — so this is mostly a change of notation: | instead of parentheses, and the type on its own line above.
Closures capture values, not variables
A Go closure captures the variable, so a later assignment is visible inside it. A Roc closure captures the value, and the value cannot change.
package main import "fmt" func main() { amount := 10 addAmount := func(number int) int { return number + amount } amount = 1000 // the closure sees this fmt.Println(addAmount(5)) }
main! = |_args| { amount : I64 amount = 10 add_amount = |number| number + amount # There is no second assignment to "amount", # so nothing can change under the closure. echo!(add_amount(5).to_str()) Ok({}) }
The Go column prints 1005, not 15 — the closure saw the assignment that happened after it was created. This is the same mechanism behind the loop-variable capture bug that Go 1.22 finally changed the semantics to fix.
No variadic parameters
Roc functions take a fixed number of arguments, with no defaults and no .... The pattern that replaces them is a record of options and a named set of defaults.
package main import "fmt" type Options struct { Host string Port uint16 Verbose bool } type Option func(*Options) func WithVerbose() Option { return func(o *Options) { o.Verbose = true } } func connect(host string, options ...Option) string { settings := Options{Host: host, Port: 8080} for _, apply := range options { apply(&settings) } return fmt.Sprintf("%s:%d verbose=%t", settings.Host, settings.Port, settings.Verbose) } func main() { fmt.Println(connect("example.com")) fmt.Println(connect("example.com", WithVerbose())) }
Options : { host : Str, port : U16, verbose : Bool } connect : Options -> Str connect = |options| "${options.host}:${options.port.to_str()} verbose=${Str.inspect(options.verbose)}" main! = |_args| { defaults = { host: "example.com", port: 8080.U16, verbose: Bool.False } echo!(connect(defaults)) echo!(connect({ ..defaults, verbose: Bool.True })) Ok({}) }
The Go column is the functional-options pattern, which exists precisely because Go has no default arguments either — and it costs a type, a constructor per option, and a loop. The record is the same idea with the machinery removed.
Generic functions
A lowercase name in a Roc signature is a type variable, with no separate parameter list to declare it in. a -> a says the function returns exactly the type it was given.
package main import "fmt" func identity[T any](value T) T { return value } func main() { fmt.Println(identity("same")) fmt.Println(identity(7)) }
identity : a -> a identity = |value| value main! = |_args| { echo!(identity("same")) echo!(identity(7.I64).to_str()) Ok({}) }
Go reached the same place in 1.18 with more ceremony: square brackets, a constraint (any) that must be written even when there is none, and a set of rules about what can be inferred at the call site. Roc has had type variables since the beginning, and inference for them is not special-cased.
Control Flow
if is an expression
Roc has no if statement — if produces a value, so the multi-branch choice is written once and reads as a single expression.
package main import "fmt" func main() { score := 85 var grade string if score >= 90 { grade = "A" } else if score >= 80 { grade = "B" } else { grade = "C" } fmt.Println(grade) }
main! = |_args| { score : I64 score = 85 grade = if score >= 90 { "A" } else if score >= 80 { "B" } else { "C" } echo!(grade) Ok({}) }
Go has no ternary either, deliberately, so the declaration-then-assign shape above is the idiomatic one. The Roc version cannot leave grade unassigned, because every branch must produce a value and an else is required when the result is used.
range becomes for ... in
Roc's for loop is available only in effectful code, because a loop that produces no value has nothing to do in a pure function.
package main import "fmt" func main() { for _, word := range []string{"alpha", "beta", "gamma"} { fmt.Println(word) } }
main! = |_args| { for word in ["alpha", "beta", "gamma"] { echo!(word) } Ok({}) }
There is no index to discard, so the _ disappears. The same loop can also be written as a method, words.for_each!(|word| echo!(word)), which is the form that chains — and in pure code the equivalent is fold or map, which produce a value.
while and break
Roc has a real while loop with break, spelled with the keyword Go left out. It needs a var, since a loop over an unchanging condition would never end.
package main import "fmt" func main() { count := 0 for count < 5 { count++ if count == 3 { break } } fmt.Println(count) }
main! = |_args| { var $count = 0.I64 while $count < 5 { $count = $count + 1 if $count == 3 { break } } echo!($count.to_str()) Ok({}) }
Go famously has one loop keyword doing four jobs; Roc splits them into for ... in and while. There is no labeled break and no continue, so a loop that wants either is usually asking to be a fold or a keep_if.
Guard clauses and early return
return exists and does what you expect, so the guard-clause style transfers unchanged. The last expression of a block is its value, so the final line needs no return.
package main import "fmt" func clampPositive(number int) int { if number < 0 { return 0 } return number } func main() { fmt.Println(clampPositive(-5)) fmt.Println(clampPositive(9)) }
clamp_positive : I64 -> I64 clamp_positive = |number| { if number < 0 { return 0 } number } main! = |_args| { echo!(clamp_positive(-5).to_str()) echo!(clamp_positive(9).to_str()) Ok({}) }
Writing return on that last line would also work; leaving it off is the convention, and it is the same one that makes if and match expressions.
No defer, because there is nothing to close
defer exists to release something the function acquired: a file, a lock, a connection. A Roc application acquires none of those — the platform owns every resource, and memory is released by counts the compiler inserted.
package main import "fmt" func work() { defer fmt.Println("cleanup runs last") fmt.Println("doing the work") } func main() { work() }
work! : {} => {} work! = |{}| { echo!("doing the work") # No defer. Nothing here owns a handle, a lock # or a buffer, so there is nothing to release — # the platform owns every resource. echo!("cleanup runs last") } main! = |_args| { work!({}) Ok({}) }
So the feature is missing and the need is mostly missing with it. What is genuinely lost is defer as a general "run this on the way out" mechanism, including the recover idiom — and that is gone on purpose, as the crash row explains.
Recursion without growing the stack
A call in tail position — the last thing a function does — is compiled to a jump rather than a new stack frame, so a tail-recursive Roc function runs in constant stack space.
package main import "fmt" func sumTo(limit, accumulator int) int { if limit <= 0 { return accumulator } return sumTo(limit-1, accumulator+limit) } func main() { fmt.Println(sumTo(100000, 0)) }
sum_to : I64, I64 -> I64 sum_to = |limit, accumulator| { if limit <= 0 { accumulator } else { sum_to(limit - 1, accumulator + limit) } } main! = |_args| { echo!(sum_to(100_000, 0).to_str()) Ok({}) }
Go does not optimize tail calls, so the same function really does build a hundred thousand frames. It survives because goroutine stacks grow on demand, which is a different solution to the same problem and one that costs memory rather than refusing to.
Interfaces vs where Clauses
An interface becomes a where clause
A where clause states what a function needs from its type variable — here, a to_str method with that signature. It is Go's structural interface idea applied to a type parameter rather than to a value.
package main import "fmt" type Stringer interface { String() string } type Celsius float64 func (c Celsius) String() string { return fmt.Sprintf("%.1f°C", float64(c)) } func announce(value Stringer) { fmt.Println(value.String()) } func main() { announce(Celsius(21.5)) }
Celsius := { degrees : Dec }.{ to_str : Celsius -> Str to_str = |celsius| "${celsius.degrees.to_str()}°C" } announce! : a => {} where [a.to_str : a -> Str] announce! = |value| { echo!(value.to_str()) } main! = |_args| { announce!(Celsius.{ degrees: 21.5 }) Ok({}) }
Both are structural: neither Celsius declares that it satisfies anything, and both columns print 21.5°C. The difference is dispatch — Go's interface value carries a method table and is resolved at run time, while Roc resolves the call at compile time from the concrete type, so there is no boxing and no indirect call.
Static dispatch only — no interface values
Roc has no interface values and no dynamic dispatch, so there is no such thing as a list of "things that can be measured". A heterogeneous collection is a list of one tag union, and the dispatch is a match.
package main import "fmt" type Shape interface{ Area() float64 } type Square struct{ Side float64 } func (s Square) Area() float64 { return s.Side * s.Side } func main() { // A slice of interface values: each element // carries its own method table. shapes := []Shape{Square{Side: 2}, Square{Side: 3}} total := 0.0 for _, shape := range shapes { total += shape.Area() } fmt.Println(total) }
Shape := [Square(I64)] area : Shape -> I64 area = |shape| match shape { Square(side) => side * side } main! = |_args| { shapes = [Shape.Square(2), Shape.Square(3)] total = shapes.fold(0, |running, shape| running + area(shape)) echo!(total.to_str()) Ok({}) }
The trade runs both ways. Go's version is open — a new shape in another package joins the slice without touching this code — while Roc's is closed and therefore exhaustively checked. Choose the tag union when the set of cases is known and should be complete; Go's interface when it is genuinely open-ended.
Methods become a block on the type
The .{ } after a type definition is a block of functions associated with that type — Go's methods, gathered in one place instead of scattered as receivers.
package main import "fmt" type Counter struct{ Value int } func NewCounter() Counter { return Counter{} } func (c Counter) Increment() Counter { return Counter{Value: c.Value + 1} } func (c Counter) Describe() string { return fmt.Sprintf("count is %d", c.Value) } func main() { fmt.Println(NewCounter().Increment().Increment().Describe()) }
Counter := { value : I64 }.{ new : () -> Counter new = || { value: 0 } increment : Counter -> Counter increment = |{ value }| { value: value + 1 } describe : Counter -> Str describe = |counter| "count is ${counter.value.to_str()}" } main! = |_args| { counter = Counter.new().increment().increment() echo!(counter.describe()) Ok({}) }
The receiver becomes an ordinary first parameter, which is what a Go receiver already is. There is no pointer-versus-value receiver decision, because there are no pointers, and no embedding — a Roc type cannot be extended by another.
Purity & Effects
A pure function cannot print
Two things mark an effectful function: its name ends in ! and its arrow is => rather than ->. Only an effectful function may call another one, and the compiler enforces it.
package main import "fmt" func describe(name string) string { return "hello " + name } func announce(name string) { // Nothing in the signature says this touches // the outside world. fmt.Println(describe(name)) } func main() { announce("Roc") }
# Pure: Str -> Str (thin arrow) describe : Str -> Str describe = |name| "hello ${name}" # Effectful: Str => {} (fat arrow, name ends in !) announce! : Str => {} announce! = |name| { echo!(describe(name)) } main! = |_args| { announce!("Roc") Ok({}) }
Adding an echo! to describe would not compile: its signature would have to change, and so would every caller. Go's nearest equivalent is passing an io.Writer and hoping nobody reaches for fmt.Println instead — a convention rather than a rule.
expect is part of the language
expect is a keyword rather than a library call, and it can appear at the top level of a file as well as inside a function — which is how Roc writes unit tests without a testing framework.
package main import "fmt" func main() { total := 2 + 2 if total != 4 { panic("arithmetic is broken") } fmt.Println("the assertion held") }
main! = |_args| { total : I64 total = 2 + 2 expect total == 4 echo!("the assertion held") Ok({}) }
A failing expect prints every value that fed the expression, not just the expression that was false. Go has no assertion at all, deliberately, which is why the column above is an if and a panic.
Top-level values are computed before the program starts
A top-level Roc definition is evaluated by the compiler. By the time the program runs, squared is the constant 100 baked into the binary.
package main import "fmt" const limit = 10 const squared = limit * limit func main() { fmt.Println(squared) }
limit : I64 limit = 10 squared : I64 squared = limit * limit main! = |_args| { echo!(squared.to_str()) Ok({}) }
Go does this too — for const, and only for the basic types const supports. A package-level var with a function call in its initializer runs at startup instead, which is where init() ordering problems come from. In Roc there is no distinction, because every top-level definition is a constant.
Goroutines, Which Roc Has Not
There are no goroutines
This is the largest thing Go has that Roc does not. There is no goroutine, no channel, no select, no sync package and no async keyword — concurrency is not part of the language.
package main import ( "fmt" "sync" ) func main() { var waiting sync.WaitGroup results := make([]int, 3) for index := 0; index < 3; index++ { waiting.Add(1) go func(slot int) { defer waiting.Done() results[slot] = slot * slot }(index) } waiting.Wait() fmt.Println(results) }
main! = |_args| { # Roc has no goroutine, no thread and no async. # Whether work can be spread across cores is the # PLATFORM's decision, and the application does # not express it at all. results = [0.I64, 1, 2].map(|slot| slot * slot) echo!(Str.inspect(results)) Ok({}) }
The reasoning is the platform model: a platform written in Rust may well run a Roc application's work across every core, and the application stays pure code that says what to compute rather than when. That is coherent, and it is also a real limitation today, because the set of platforms that actually do it is small.
No shared mutable state, so no data races
The other side of having no concurrency primitives is having no concurrency hazards. Nothing in Roc can be mutated, so the data race — the thing sync.Mutex and go test -race exist for — has no way to occur.
package main import ( "fmt" "sync" ) func main() { var lock sync.Mutex total := 0 var waiting sync.WaitGroup for index := 1; index <= 3; index++ { waiting.Add(1) go func(value int) { defer waiting.Done() lock.Lock() total += value // the lock is what makes lock.Unlock() // this safe }(index) } waiting.Wait() fmt.Println(total) }
main! = |_args| { # There is no mutex because there is nothing to # protect: a value cannot be modified, so two # readers can never disagree about it. total = [1.I64, 2, 3].sum() echo!(total.to_str()) Ok({}) }
This is not a fair trade as it stands: Go gives you the hazard and the tools, Roc gives you neither. It does say what a platform-provided parallel map could safely do, though, which is the direction the design is pointed.
No Collector, No Runtime
Reference counting, with no cycles to collect
Go needs a tracing collector precisely because of the third line here: two values holding each other would keep each other alive forever under simple reference counting. Roc needs no collector, and this row is why.
package main import "fmt" type Peer struct { Name string Peer *Peer } func main() { first := &Peer{Name: "first"} second := &Peer{Name: "second", Peer: first} first.Peer = second // closing the cycle fmt.Println(second.Peer.Name) fmt.Println(first.Peer.Name) }
main! = |_args| { first = { name: "first" } second = { name: "second", peer: first } # first cannot be made to point back at second: # it was finished the moment it was defined, so # this program has no second line to print. echo!(second.peer.name) Ok({}) }
Nothing in Roc can be made to point back at a value that already exists, which is exactly the condition under which counting alone suffices — so the retain and release calls are inserted by the compiler and there is no collector, no write barrier, and no pause to tune. The cost is on screen: the Go column can build the cycle and the Roc column cannot.
Functional updates that mutate when it is safe
Semantically set builds a new list. When the reference count of the old one is one — nobody else is holding it — the compiler mutates in place and copies nothing.
package main import "fmt" func main() { numbers := []int{1, 2, 3} updated := make([]int, len(numbers)) copy(updated, numbers) // an explicit copy, updated[1] = 99 // then mutate it fmt.Println(updated) fmt.Println(numbers) }
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3] updated = numbers.set(1, 99) ?? numbers echo!(Str.inspect(updated)) echo!(Str.inspect(numbers)) Ok({}) }
That is why an immutable style costs less in Roc than copy-then-mutate costs in Go. Here both lists are printed, so both must exist and a copy really is made; drop the second echo! and the copy disappears.
A single binary, and no runtime inside it
Both languages produce a single static binary with no interpreter and no shared library to install, and both treat that as a headline feature.
package main import "fmt" func main() { // go build produces one static binary with the // runtime inside it — scheduler, collector and // standard library, a couple of megabytes even // for hello world. fmt.Println("one binary, runtime included") }
main! = |_args| { # A Roc binary has no scheduler and no collector # to carry, because the platform supplies the # runtime and the memory management is compiled # in as retain and release calls. echo!("one binary, no runtime") Ok({}) }
The difference is what is inside. Go carries its runtime — a scheduler, a garbage collector and the standard library — which is why a hello-world binary is measured in megabytes. Roc carries whatever the platform is, which for a minimal platform can be almost nothing, and is how the same application code can target a microcontroller.
Gotchas for Go Programmers
An untyped integer prints as a decimal
This is the first thing that will confuse you. An unconstrained number literal defaults to Dec, so a list that looks like integers prints as [1.0, 2.0, 3.0].
package main import "fmt" func main() { fmt.Println([]int{1, 2, 3}) fmt.Println(1 + 2) }
main! = |_args| { # No annotation: these literals become Dec, # and print with a decimal point. echo!(Str.inspect([1, 2, 3])) echo!(Str.inspect(1 + 2)) typed : List(I64) typed = [1, 2, 3] echo!(Str.inspect(typed)) Ok({}) }
The fix is an annotation or a suffix: typed : List(I64), or 42.I64 on the literal. Go's untyped constants have a similar rule with a friendlier default — an untyped integer constant becomes int in context — so the instinct to ask which numeric type you have is already there.
A bare True is not a Bool
Bool is an ordinary tag union in Roc, and True and False written bare are just tags — not necessarily that union.
package main import "fmt" func main() { ready := false fmt.Println(!ready) }
main! = |_args| { # Without the annotation, "False" is inferred as # a one-off structural tag rather than a Bool, # and ! would have nothing to negate. ready : Bool ready = Bool.False echo!(Str.inspect(!ready)) Ok({}) }
Annotating the binding, or writing Bool.True and Bool.False in full, pins it down. It is the same inference rule that turns untyped numbers into Dec, seen from another angle.
There is no working [i] on a list
Subscript syntax exists in the grammar and does not work, which is worse than not existing — the error it produces talks about type variables rather than about indexing.
package main import "fmt" func main() { numbers := []int{10, 20, 30} fmt.Println(numbers[0]) fmt.Println(numbers[len(numbers)-1]) }
main! = |_args| { numbers : List(I64) numbers = [10, 20, 30] # numbers[0] parses in this build but does not # type-check into a usable value. echo!((numbers.get(0) ?? 0).to_str()) echo!((numbers.last() ?? 0).to_str()) Ok({}) }
Use get(index), and first() or last() for the ends. Once you are used to it, last() reads better than numbers[len(numbers)-1] and cannot be off by one.
When you want a nominal type, use :=
Roc's : makes an alias and := makes a genuinely new type. This is the one place a Go programmer has to pick the second on purpose, because Go's type X Y is always nominal.
package main import "fmt" type UserID uint64 func greet(userID UserID) string { return fmt.Sprintf("user #%d", userID) } func main() { fmt.Println(greet(UserID(42))) // greet(42) compiles too: an untyped constant // converts implicitly. fmt.Println(greet(42)) }
UserId := { value : U64 } greet : UserId -> Str greet = |user_id| "user #${user_id.value.to_str()}" main! = |_args| { user_id = UserId.{ value: 42 } echo!(greet(user_id)) # greet(42) does not compile. Ok({}) }
Go's nominal type still accepts an untyped constant, as the last line shows, so the protection has a hole exactly where the literals are. UserId.{ value: 42 } has to be written out, which is the point.
The standard library is still settling
Roc is pre-1.0 and its standard library is visibly incomplete. Functions Go has had since 1.0 are simply absent, and which ones are absent changes between nightly builds.
package main import ( "fmt" "strings" ) func main() { fmt.Println(strings.ReplaceAll("hello world", " ", "_")) fmt.Println(strings.ToUpper("shout")) }
main! = |_args| { # There is no Str.replace in this build, and no # case conversion — compose what exists: parts = "hello world".split_on(" ") echo!(Str.join_with(parts, "_")) echo!("shout") Ok({}) }
There is also no regular-expression support, no JSON, no HTTP and no time package — which is a fair summary of the gap. Go's promise is that code written in 2012 still compiles; Roc has not made that promise yet and could not keep it if it did.
The honest comparison
It is worth stating plainly where this comparison lands. Go and Roc share a surprising amount of temperament — compile speed as a design goal, one static binary, errors as values, structural typing, no exceptions — and one of them is fifteen years old.
package main import "fmt" func main() { // Go: a compiler that finishes before you look // away, one binary, a standard library that // covers servers, and fifteen years of modules. fmt.Println("boring on purpose, and it works") }
main! = |_args| { # Roc: the same compile-speed goal, the same # single binary, no runtime at all — and no 1.0, # a package ecosystem measured in dozens, and # very few production platforms. echo!("the ideas are ready; the language is not") Ok({}) }
What Roc adds is worth studying now: sum types with exhaustiveness, no nil, no zero values, effects in the type system, and reference counting instead of a collector. What it lacks is everything around the language. Read it for the ideas, and keep writing the service in Go.

Thank you — anything else?