PONYλM2Modula-2

Python.CodeCompared.To/Roc

An interactive executable cheatsheet comparing Python and Roc

Python 3.14 Roc nightly
Hello World & The Platform Model
Hello, World
A Roc program is not a list of statements that run top to bottom. It is a definition of main!, a function the platform calls with the command-line arguments. The ! on the end of a name means "this performs effects", and it is enforced by the compiler.
print("Hello, World!")
main! = |_args| { echo!("Hello, World!") Ok({}) }
The _args parameter is named with a leading underscore for the same reason it would be in Python: the value is required by the signature and unused by the body. Ok({}) is the return value — {} is the empty record, Roc's equivalent of returning nothing.
Where printing comes from
In Python the runtime is simply there: sys, open and socket are always importable because CPython is always underneath you. Roc has no ambient runtime at all. A Roc program is compiled against a platform — a host written in Rust or Zig that owns the entry point, the allocator and every effect the program is allowed to perform.
import sys sys.stdout.write("printed by the interpreter's runtime\n")
main! = |_args| { echo!("printed by whatever platform hosts this program") Ok({}) }
The platform on this page provides exactly one effect, echo!, which is why none of these examples open a file or a socket. A different platform gives the same application code a filesystem, a web server or a microcontroller, chosen when the program is built rather than imported at run time.
How a program reports failure
Python signals failure by raising, or by handing an integer to sys.exit. Roc's main! returns a Try: Ok for success, Err for failure, and the platform turns that into whatever the operating system wants.
import sys def main(): print("all good") return 0 sys.exit(main())
main! = |_args| { echo!("all good") Ok({}) }
Because the exit status is the function's return value rather than a side effect, the compiler checks it like any other value. There is no way to forget to report failure and no way to report it with the wrong type.
Comments
Roc borrows Python's # for comments. There is no block comment form, so a multi-line comment is several # lines, and a documentation comment starts with ##, where Python would use a docstring.
# A single-line comment count = 42 # an inline comment """A triple-quoted string, often used as a block comment or a docstring.""" print(count)
main! = |_args| { # A single-line comment count : I64 count = 42 # an inline comment ## Two hashes start a documentation comment, ## Roc's counterpart of a docstring. echo!(count.to_str()) Ok({}) }
The count : I64 line is a type annotation, and unlike a Python type hint it is checked by the compiler rather than ignored at run time. Writing it is optional here — Roc would infer a type — but it pins down which number type this is, which matters for what gets printed.
From Duck Typing to Full Inference
Duck typing vs whole-program inference
Python asks at run time whether a value supports *. Roc decides before the program starts, by inferring a type for every expression in the whole program — not just the annotated ones.
def double(number): return number * 2 print(double(21)) print(double("ab")) # also "works" — string repetition
double : I64 -> I64 double = |number| number * 2 main! = |_args| { echo!(double(21).to_str()) # double("ab") would be a COMPILE error, # caught before the program ever runs. Ok({}) }
The Python call double("ab") succeeds and prints abab, which is exactly the kind of accident duck typing permits. The Roc equivalent is rejected at compile time with a message naming both types.
Type hints vs real annotations
Python's annotations are documentation that a separate tool may or may not check; CPython itself discards them. Roc's annotations are part of the program, and a mismatch is an error rather than a lint.
def repeat(text: str, times: int) -> str: return text * times # A hint is not enforced: this runs anyway. print(repeat("ab", 3)) print(repeat([1, 2], 2))
repeat : Str, U64 -> Str repeat = |text, times| text.repeat(times) main! = |_args| { echo!(repeat("ab", 3)) # repeat([1, 2], 2) is rejected by the compiler. Ok({}) }
The Python version happily repeats a list, because a hint constrains nothing at run time. Note also that the count is a U64: Roc distinguishes signed from unsigned integers, and a repetition count cannot meaningfully be negative.
No Any, and no casts
The escape hatches Python offers — Any, cast(), # type: ignore — have no equivalent in Roc. There is nothing to write that means "trust me".
from typing import Any def unwrap(value: Any) -> int: return value # a lie the type checker allows print(unwrap("42")) # prints a string
main! = |_args| { # Roc has no Any type and no cast operator, so # there is no way to assert a type into being. # Every conversion is a real function that can fail: parsed = I64.from_str("42") ?? 0 echo!(parsed.to_str()) Ok({}) }
I64.from_str returns a Try because the text might not be a number, and ?? supplies the fallback. That is the whole conversion story: parse, then handle the failure, with no third option.
Protocols vs structural records
A Protocol lets Python describe a shape rather than a class, and a value matches it by having the right attributes. Roc works that way by default: a record type is its set of fields, so nothing has to declare that it implements anything.
from typing import Protocol class HasPosition(Protocol): x: int y: int def describe(point: HasPosition) -> str: return f"({point.x}, {point.y})" class Marker: def __init__(self, x, y): self.x = x self.y = y print(describe(Marker(1, 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 — the literal { x: 1, y: 2 } is already a Point because it has those fields with those types. Nothing was constructed, registered or subclassed.
Values & Immutability
A name is bound once
Python's = rebinds a name as often as you like. Roc's = defines it, once, and a second definition of the same name in the same scope is reported as a duplicate definition rather than accepted as a shadow.
greeting = "hello" greeting = "rebound" # perfectly legal print(greeting)
main! = |_args| { greeting = "hello" # greeting = "rebound" # ^ warns "duplicate definition", by design: the # program still runs, but the roc command exits # with status 2, so the rebinding cannot pass CI. echo!(greeting) Ok({}) }
This is stricter than it first looks: each step of a calculation needs its own name, which rules out the whole family of bugs where a variable quietly means something different fifteen lines further down. A duplicate definition is a warning rather than an error, and that is deliberate: Roc runs code that has problems so you can try something quickly, and the roc command then exits with status 2, so the rebinding cannot survive a continuous-integration check. This page runs the program regardless and shows it as a success, with the name taking the new value from that point on.
Opting in to mutation
When a value genuinely has to change, Roc makes you say so: var declares it and a $ sigil marks every use, so mutation is visible at the point of reading rather than inferred from context.
total = 0 total = total + 5 total = total + 10 print(total)
main! = |_args| { var $total = 0.I64 $total = $total + 5 $total = $total + 10 echo!($total.to_str()) Ok({}) }
A var is local to the function it is declared in and cannot escape it, so there is no Roc equivalent of a mutable global or a mutable default argument.
The mutable default argument, which cannot happen
Python evaluates a default argument once, when the function is defined, so a mutable default is shared by every call that omits it. Roc has no default arguments at all, and nothing to share even if it did — append returns a new list rather than changing the one it was given.
def collect(item, into=[]): into.append(item) return into print(collect(1)) print(collect(2)) # [1, 2] — the default is shared!
collect : List(I64), I64 -> List(I64) collect = |into, item| into.append(item) main! = |_args| { empty : List(I64) empty = [] echo!(Str.inspect(collect(empty, 1))) echo!(Str.inspect(collect(empty, 2))) Ok({}) }
The Python version prints [1] then [1, 2], which is the famous gotcha. The Roc version prints [1] then [2], because empty is still empty after the first call and always will be.
Immutable all the way down
A Python tuple protects only its own slots; anything mutable stored in one is still mutable. Roc has no shallow immutability to work around, because there is no mutable value to put inside anything.
frozen = (1, [2, 3]) frozen[1].append(4) # the tuple is immutable, print(frozen) # the list inside it is not
main! = |_args| { inner : List(I64) inner = [2, 3] frozen = (1.I64, inner) extended = frozen.1.append(4) echo!(Str.inspect(frozen)) echo!(Str.inspect(extended)) Ok({}) }
The Python tuple prints as (1, [2, 3, 4]) despite being "immutable". In Roc, append produced a separate list and the original is untouched, which is why the two printed lines differ.
Destructuring
Tuple unpacking works the way it does in Python. Record destructuring goes further than anything Python offers for a dict: naming the fields pulls them out by name, with the compiler checking that they exist.
x, y = 3, 4 print(f"{x}, {y}") person = {"name": "Grace", "age": 85} name, age = person["name"], person["age"] print(f"{name}: {age}")
main! = |_args| { (x, y) = (3.I64, 4.I64) echo!("${x.to_str()}, ${y.to_str()}") person = { name: "Grace", age: 85.I64 } { name, age } = person echo!("${name}: ${age.to_str()}") Ok({}) }
Note that the Python version has to name each key twice, because a dict has no destructuring form. Roc's { name, age } = person is closer to unpacking a dataclass, except that it needs no class.
Numbers
Two number types vs a full menu
Python gives you int and float and hides the machine. Roc exposes the machine: I8 through I128, U8 through U128, F32, F64 and Dec, all sharing one set of operators.
byte = 255 # int ratio = 2.5 # float print(f"{byte} {ratio}")
main! = |_args| { byte : U8 byte = 255 ratio : F64 ratio = 2.5 echo!("${byte.to_str()} ${ratio.to_str()}") Ok({}) }
Choosing U8 is a statement that the value fits in a byte, and the compiler holds you to it — byte = 256 would not compile. There is no automatic widening, so mixing sizes means converting explicitly.
Exact decimals without importing anything
Python has exact decimal arithmetic, but only if you import decimal and remember to build every value from a string. Roc has Dec, a fixed-point type, as an ordinary number type — and it is what an unannotated decimal literal becomes.
from decimal import Decimal print(0.1 + 0.2) # float print(Decimal("0.1") + Decimal("0.2")) # exact
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({}) }
The first line of each column shows the familiar binary-floating-point answer; the second is exact. In Roc the difference is one annotation, and Dec is the default rather than the opt-in.
Integer division and remainder
Roc spells these the way Python does, but two of them mean something different for negative numbers. Python's // floors and its % takes the sign of the divisor, so -7 // 2 is -4 and -7 % 2 is 1. Roc's // truncates toward zero and its % takes the sign of the number being divided, giving -3 and -1. With positive operands, as here, the two agree.
print(17 // 5) print(17 % 5) print(17 / 5)
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({}) }
Python's 17 / 5 gives 3.4 as a binary float; Roc's gives 3.4 as an exact decimal, because the exact binding is a Dec. The two would diverge on a value like 1 / 3 printed to enough places. They also part on integer operands: Python's / always produces a float, while Roc's / on two I64 values discards the remainder.
Arbitrary precision vs a fixed ceiling
Python's int grows to whatever size the calculation needs, so 2 ** 200 is an ordinary expression. Roc's integers are machine integers with a hard ceiling, and I128 is the top of the range.
huge = 2 ** 200 print(huge)
main! = |_args| { # I128 is the largest integer type, and this is # its maximum — there is nothing above it. big : I128 big = 170_141_183_460_469_231_731_687_303_715_884_105_727 echo!(big.to_str()) Ok({}) }
This is a genuine capability Python has and Roc does not. Arbitrary-precision arithmetic in Roc means a library implementing it over lists of digits, which is what Python's runtime is doing for you underneath.
int() and float() become explicit methods
Every conversion in Roc is a method on the type being converted from, and parsing is separate from widening because parsing can fail and widening cannot.
count = 200 print(count / 3) print(str(count) + " units") print(int("42") + 1)
main! = |_args| { count : I64 count = 200 echo!((count.to_f64() / 3).to_str()) echo!(count.to_str().concat(" units")) parsed = I64.from_str("42") ?? 0 echo!((parsed + 1).to_str()) Ok({}) }
The Python int("42") raises ValueError on bad input; the Roc I64.from_str returns a Try and ?? supplies the fallback, so nothing is thrown and nothing is ignored.
Overflow is caught, not silently widened
Because Roc's integers have a fixed width, an addition that would pass the top of the range is an error. When both operands are known at compile time — as here — the compiler catches it before the program runs.
big = 9_223_372_036_854_775_807 print(big + 1) # Python just grows the integer
main! = |_args| { big : I64 big = 9_223_372_036_854_775_807 # echo!((big + 1).to_str()) # ^ compile time crash: "Integer addition overflowed" echo!(big.to_str()) Ok({}) }
Underscores as digit separators work in both languages, which is why the same enormous literal is readable in each column. The Python line prints the next integer up, because int has no ceiling to hit.
Strings
f-strings become ${} — with one catch
Every Roc string can interpolate, so there is no f prefix to remember. The catch is that interpolation takes a Str and will not call anything for you: a number has to be converted first.
name = "Roc bird" age = 10 print(f"{name} is {age}") message = f"{name} turns {age + 1}" print(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({}) }
Python's f-string calls str() on whatever it finds. Roc will not, so "${age}" is a type error naming I64 where Str was expected — an annoyance at first and a guarantee afterwards that nothing is stringified by accident.
Concatenation without +
Roc reserves + for numbers. Joining strings is concat, available either as a method on the left-hand string or as a plain function.
combined = "Fast " + "and friendly" print(combined) print("also".__add__(" works"))
main! = |_args| { combined = "Fast ".concat("and friendly") echo!(combined) echo!(Str.concat("also", " works")) Ok({}) }
The two Roc forms are the same function: "a".concat("b") is resolved at compile time to Str.concat("a", "b"). Method syntax in Roc is sugar over a function call, with no object and no lookup at run time.
Everyday string methods
The names differ a little — trim rather than strip, repeat rather than * — but the shapes are familiar. Str.inspect is how a non-string value is turned into something printable, so it stands in for what Python's print does implicitly.
padded = " systems " print(padded.strip()) print("ab" * 3) print("systems".startswith("sys")) print("stem" in "systems")
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({}) }
Python's in operator does substring search for strings and membership for collections; Roc keeps those separate, as Str.contains and List.contains, so the reader always knows which is meant.
Splitting and joining
Splitting is split_on, and joining is Str.join_with — note the argument order, which puts the list first rather than making the separator the receiver.
parts = "red,green,blue".split(",") print(len(parts)) print(" | ".join(parts))
main! = |_args| { parts = "red,green,blue".split_on(",") echo!(parts.len().to_str()) echo!(Str.join_with(parts, " | ")) Ok({}) }
Python's separator.join(parts) reads oddly to newcomers precisely because the separator is the receiver. Roc's ordering, Str.join_with(parts, separator), puts the thing being joined first.
Unicode and byte counts
A Python str is a sequence of code points, so len counts characters and you encode to get bytes. A Roc Str is UTF-8 bytes with no character index at all, so the question it answers directly is how many bytes there are.
print("rocket: \U0001F680") print(len("héllo")) # 5 characters print(len("héllo".encode("utf-8"))) # 6 bytes
main! = |_args| { echo!("rocket: \u(1F680)") echo!("héllo".count_utf8_bytes().to_str()) Ok({}) }
There is no Char type in Roc and no way to subscript a string by position, which sounds restrictive until you remember how often a Python character index is wrong for text outside the Basic Multilingual Plane.
Lists & Comprehensions
List literals hold one type
The literal syntax is identical, but a Roc List is a flat array of one element type rather than a sequence of arbitrary object references. That is what makes len and indexing constant-time with no boxing.
numbers = [3, 1, 4, 1, 5] print(len(numbers)) print(numbers) mixed = [1, "two", 3.0] # fine in Python print(mixed)
main! = |_args| { numbers : List(I64) numbers = [3, 1, 4, 1, 5] echo!(numbers.len().to_str()) echo!(Str.inspect(numbers)) # A List holds ONE element type. For a mixture, # use a tag union — see the Tag Unions section. Ok({}) }
A heterogeneous list is not a Roc list at all: the values would have to share a type, which in practice means wrapping each in a tag. That is more typing, and it is also the compiler forcing the question of what the mixture actually means.
Comprehensions become chained methods
Roc has no comprehension syntax. The same work is a chain of methods, read in the order it happens — filter first, then transform — rather than Python's order, which puts the transformation before the condition it depends on.
numbers = [1, 2, 3, 4, 5, 6] doubled_evens = [n * 2 for n in numbers if n % 2 == 0] print(doubled_evens)
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({}) }
keep_if is filter and drop_if is its opposite. Because each call returns a new list rather than a lazy iterator, there is no generator-versus-list distinction to keep track of.
reduce becomes fold
Guido moved reduce out of the builtins because he thought it was usually the wrong tool. Roc keeps it central, as fold, because without loops over mutable accumulators it is often the right one.
from functools import reduce numbers = [1, 2, 3, 4] print(reduce(lambda total, n: total + n, numbers, 0)) print(sum(numbers))
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 argument order differs from functools.reduce: the starting value comes first, then the function. sum exists for the common case in both languages.
Indexing cannot raise IndexError
Reading past the end of a Roc list is not an error to catch — it is a value. get returns a Try, so the out-of-bounds case is something the type system makes you deal with rather than something that interrupts the program.
numbers = [10, 20, 30] try: print(numbers[9]) except IndexError: print("out of bounds") print(numbers[1])
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({}) }
The ?? operator supplies a default and is the common case, which makes the whole thing about as short as Python's numbers[1] while still being total. There is no version of this that can raise.
sort() mutates; Roc has nothing to mutate
Python has two sorts, one that mutates and returns None and one that returns a new list — a distinction that catches everyone at least once. Roc needs only one, because nothing can be sorted in place from the program's point of view.
numbers = [3, 1, 2] numbers.sort() # in place, returns None print(numbers) others = [3, 1, 2] print(sorted(others)) # a new list print(others)
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 line shows numbers unchanged after two sorts. The compiler may still reorder the elements in place when it can prove nothing else holds the list, so "returns a new list" costs nothing here.
Slices become take and drop
Roc has no slice syntax. The two things a slice is almost always used for have names instead: take_first and drop_first, plus take_last and drop_last.
numbers = [1, 2, 3, 4, 5] print(numbers[:2]) print(numbers[2:])
main! = |_args| { numbers : List(I64) numbers = [1, 2, 3, 4, 5] echo!(Str.inspect(numbers.take_first(2))) echo!(Str.inspect(numbers.drop_first(2))) Ok({}) }
Naming the operation removes the question of what a negative index or an omitted bound means. There is also no step argument, so numbers[::2] becomes a keep_if over the index.
any, all and finding
any and all take a predicate directly rather than a generator of booleans, so there is no comprehension to write inside them.
numbers = [2, 4, 6, 7] print(any(n % 2 == 1 for n in numbers)) print(all(n > 0 for n in numbers)) found = next((n for n in numbers if n > 5), None) print(f"found {found}" if found is not None else "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({}) }
Python's idiom for "the first match, or nothing" is next(...) with a sentinel, and the sentinel is the weak point — a legitimate None in the data is indistinguishable from no match. find_first returns a Try, which has no such ambiguity.
enumerate becomes map_with_index
Where Python pairs each element with its index and then unpacks the pair, Roc passes both to the closure directly.
words = ["one", "two"] labeled = [f"{index}:{word}" for index, word in enumerate(words)] print(labeled)
main! = |_args| { words = ["one", "two"] labeled = words.map_with_index(|word, index| "${index.to_str()}:${word}") echo!(Str.inspect(labeled)) Ok({}) }
The argument order is element first, index second — the reverse of what enumerate yields. That is worth reading twice the first few times, because getting it backwards still type-checks whenever both happen to be numbers.
Records vs Dicts
String-keyed dicts become records
A Python dict with fixed, known keys is a record in Roc. The distinction that matters is when the key set is decided: a record's fields are part of its type and are checked by the compiler, while a dict's keys are data.
point = {"x": 1.5, "y": 2.5} print(f"({point['x']}, {point['y']})") print(point.get("z", 0)) # a missing key is a runtime question
main! = |_args| { point = { x: 1.5, y: 2.5 } echo!("(${point.x.to_str()}, ${point.y.to_str()})") # point.z would be a COMPILE error: no such field. Ok({}) }
This is the single biggest change of habit for a Python programmer. "Pass a dict of options around" becomes "pass a record", and a typo in a field name stops being a KeyError discovered in production.
Building from defaults
Roc's .. spread does what ** does in a dict literal, with one important difference: it cannot introduce a field that was not already there.
defaults = {"verbose": False, "retries": 3, "timeout_seconds": 30} custom = {**defaults, "retries": 5} print(custom)
main! = |_args| { defaults = { verbose: Bool.False, retries: 3.I64, timeout_seconds: 30.I64 } custom = { ..defaults, retries: 5 } echo!(Str.inspect(custom)) Ok({}) }
In Python, {**defaults, "retrys": 5} silently produces a four-key dict with a typo in it. In Roc the same mistake names a field the record does not have, and the program does not compile.
dataclass becomes a type alias
What @dataclass generates — a constructor, equality, a readable repr — Roc gives every record for free, so naming the shape is a one-line alias with no decorator and no class.
from dataclasses import dataclass @dataclass class Employee: name: str department: str def describe(employee: Employee) -> str: return f"{employee.name} works in {employee.department}" print(describe(Employee("Nia", "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({}) }
Nothing constructs an Employee here: the literal already is one. Equality and Str.inspect work on it because they work on every record, which is the part @dataclass(frozen=True, eq=True) is emulating.
Dicts, for keys decided at run time
When the keys really are data — a word-frequency count, a lookup table built from input — Roc has Dict. Every value in it shares one type, and so does every key.
scores = {} scores["math"] = 90 scores["art"] = 95 print(len(scores)) print(scores.get("art", 0)) print(scores.get("music", 0))
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. get returns a Try, so ?? 0 is the direct equivalent of the second argument to Python's dict.get.
Tuples
Roc tuples hold a fixed number of values of possibly different types, exactly like Python's, and are indexed with a dot rather than square brackets.
pair = (1, "two") print(f"{pair[0]} {pair[1]}") number, word = pair print(f"{number} {word}")
main! = |_args| { pair = (1.I64, "two") echo!("${pair.0.to_str()} ${pair.1}") (number, word) = pair echo!("${number.to_str()} ${word}") Ok({}) }
Because the index is part of the syntax rather than a runtime value, pair.2 is a compile error rather than an IndexError. There is no tuple unpacking with a star, and no variable-length tuple type.
Tag Unions
Enum becomes a tag union
A declared union is written with :=, which creates a genuinely new type rather than an alias. The variants are then reached through it, as Color.Green.
from enum import Enum class Color(Enum): RED = "#FF0000" GREEN = "#00FF00" BLUE = "#0000FF" print(Color.GREEN.value)
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)) Ok({}) }
Attaching the hex string to the variant, as Python's Enum does, would be a payload; keeping it in a function instead means the mapping is exhaustively checked. Add a fourth color and to_hex stops compiling, which is the thing an Enum cannot do for you.
Variants that carry data
The Python idiom for "one of several shapes, each with its own fields" is a dict with a kind key and a chain of comparisons. In Roc the discriminant is the tag itself, and each variant declares exactly which fields it carries.
def area(shape): kind = shape["kind"] if kind == "circle": return 3.14159 * shape["radius"] * shape["radius"] if kind == "rectangle": return shape["width"] * shape["height"] raise ValueError(f"unknown shape {kind}") print(area({"kind": "circle", "radius": 2})) print(area({"kind": "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({}) }
The Python version needs a final raise for the case that should be impossible, and nothing checks that the three branches agree with the three shapes. The Roc version needs no such branch: a fourth variant makes the match incomplete and the program stops compiling.
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 reach that position.
hour = 14 period = "morning" if hour < 12 else "afternoon" label = "AM" if period == "morning" else "PM" print(label)
main! = |_args| { hour : I64 hour = 14 period = if hour < 12 { Morning } else { Afternoon } label = match period { Morning => "AM" Afternoon => "PM" } echo!(label) Ok({}) }
Python's stand-in is a string, and a string is a bad discriminant: "mornign" compares false and nothing complains. The Roc match knows the union is exactly [Morning, Afternoon], so a misspelling is a compile error and a missing branch is too.
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.
def describe(signal): if signal == "go": return "go" if signal == "stop": return "stop" return "something else" print(describe("go")) print(describe(("custom", 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({}) }
This is the part with no Python analogue. A Python function accepting "a string or a tuple or something" has a type nobody can write down; here the openness is in the signature, and the compiler still checks that every closed part is handled.
Recursive data structures
A declared union may mention itself, which is how trees, linked lists and syntax trees are written. No indirection has to be spelled out — the compiler works out where a pointer is needed.
def sum_tree(tree): if tree["kind"] == "leaf": return tree["value"] return sum_tree(tree["left"]) + sum_tree(tree["right"]) tree = { "kind": "node", "left": {"kind": "leaf", "value": 1}, "right": { "kind": "node", "left": {"kind": "leaf", "value": 2}, "right": {"kind": "leaf", "value": 3}, }, } print(sum_tree(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, and the difference is what the compiler knows. A malformed Python tree — a node missing a right key — is a KeyError at whatever depth it happens to be; a malformed Roc tree cannot be constructed.
Pattern Matching
match, and why Roc's is an expression
Python's match is a statement, so each branch has to assign to a variable that outlives it. Roc's is an expression that produces a value, which is why the whole thing sits on the right of one =.
status_code = 404 match status_code: case 200: message = "ok" case 404: message = "not found" case _: message = "something else" print(message)
main! = |_args| { status_code : I64 status_code = 404 message = match status_code { 200 => "ok" 404 => "not found" _ => "something else" } echo!(message) Ok({}) }
Because it is an expression, every branch must produce the same type, and a branch that forgets to produce anything is an error. In Python, a case that forgets to assign message leaves it unbound and fails later.
Guards
A guard is an if attached to a pattern, and it is spelled the same way in both languages.
def describe(number): match number: case 0: return "zero" case n if n < 0: return "negative" case n if n % 2 == 0: return "positive even" case _: return "positive odd" print(describe(0)) print(describe(-5)) print(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 Roc version is a single expression, so there is no return in each branch and no risk of falling off the end of the function. A guard does not count toward exhaustiveness in either language, which is why the wildcard is still required.
Or-patterns
Alternatives within one branch use | in both languages, and mean the same thing.
def size_class(number): match number: case 1 | 2 | 3: return "small" case _: return "big" print(size_class(2)) print(size_class(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 one of the places where Python 3.10's structural pattern matching and Roc agree almost exactly, which makes it a good landmark when the rest of the syntax feels unfamiliar.
List patterns and rest bindings
Matching on a list's shape works in both languages. Roc spells the rest binding .. as rest where Python writes *rest.
def describe(numbers): match numbers: case []: return "empty" case [single]: return f"one: {single}" case [first, *rest]: return f"first {first}, {len(rest)} more" print(describe([])) print(describe([7])) print(describe([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({}) }
Python's match on a list that matches no case falls through and returns None, silently. Roc's compiler rejects a match whose patterns do not cover every possible list, so the three branches here are not a convention — they are required.
Exhaustiveness is checked, not hoped for
This is the payoff for declaring the union. The compiler knows every tag a value can be, so it can tell you which one you forgot — by name, at the moment you forget it.
def to_hex(color): match color: case "red": return "#FF0000" case "green": return "#00FF00" # "blue" is missing — this returns None print(to_hex("green")) print(to_hex("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({}) }
The Python column prints None for blue, which is what a missing case produces. Adding a variant to a Python Enum breaks nothing at the point of addition and everything later; adding one in Roc breaks every incomplete match immediately.
There Is No None
There is no None
Roc has no None, no null, and no Optional type in the standard library. Absence is modeled by a tag that says what is absent, and the type says so out loud.
def find_user(user_id): return "Ada" if user_id == 1 else None name = find_user(1) if name is not None: print(f"found {name}") else: print("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 Roc signature tells you at a glance that this function might not find anything. The Python one does not, unless someone remembered to write -> str | None — and even then nothing forces the caller to check.
or and .get(key, default) become ??
One operator, ??, covers what Python spreads across or, a conditional expression and the second argument to dict.get: it takes the value out of an Ok and supplies the fallback for an Err.
numbers = [] first = numbers[0] if numbers else 0 print(first) settings = {"verbose": "true"} print(settings.get("retries", "3"))
main! = |_args| { numbers : List(I64) numbers = [] first = numbers.first() ?? 0 echo!(first.to_str()) settings = Dict.empty().insert("verbose", "true") echo!(settings.get("retries") ?? "3") Ok({}) }
Python's value or default is the shortcut people reach for, and it is wrong whenever the value can legitimately be 0, "" or an empty list. ?? tests whether the operation succeeded, not whether the result is falsy, so it has no such failure mode.
Try vs Exceptions
try/except becomes a returned value
Roc has no exceptions and no stack unwinding. A function that can fail returns Try(ok, err), and the caller gets the failure as an ordinary value to match on.
def parse_score(text): return int(text.strip()) for candidate in ["95", "not a number"]: try: print(f"score: {parse_score(candidate)}") except ValueError: print(f"bad score: {candidate}")
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 important difference is not the syntax but the signature: parse_score announces that it can fail and names how. A Python caller has to know from documentation, or from being bitten, that int() raises ValueError.
Letting a failure bubble up
A raised exception propagates by itself, which is convenient and invisible. Roc's ? is the explicit version: it unwraps an Ok and returns early from the enclosing function on an Err.
def show_first(numbers): first = numbers[0] # raises if empty print(f"first: {first * 2}") show_first([5, 6, 7])
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) }
One character marks every place a function can exit early, so reading a Roc function tells you its failure paths. In the Python column, numbers[0] looks like a plain subscript and is in fact a hidden exit from the function.
Error types compose without a class hierarchy
An error in Roc is a tag, so a new error kind needs no class, no base class and no registration. The set of errors a function can produce is written in its return type, as a union.
class BadPort(Exception): pass def read_port(text): value = int(text) # may raise ValueError if not 0 <= value <= 65535: raise BadPort(text) return value for candidate in ["8080", "eighty"]: try: print(read_port(candidate)) except (ValueError, BadPort): print(f"bad port: {candidate}")
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({}) }
Notice that the Roc function cannot accidentally let a second, undeclared failure escape: choosing U16 makes the range check part of the parse. The Python version has to catch two unrelated exception types and keep that list in step with the body.
crash, for what cannot happen
There is one way to stop a Roc program abruptly, and it is deliberately unlike an exception: crash cannot be caught, so it can only ever mean "this state is impossible".
def divide(numerator, denominator): assert denominator != 0, "impossible: checked upstream" return numerator // denominator print(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({}) }
Python's assert is close in spirit and weaker in practice, since AssertionError is catchable and -O removes the check entirely. In Roc there is no flag that deletes a crash and no handler that swallows one.
Functions & Closures
def and lambda become one form
There is one way to write a function in Roc, 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.
def add(left, right): return left + right also_add = lambda left, right: left + right print(add(2, 3)) print(also_add(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({}) }
Python's lambda is limited to a single expression, which is why it is unusable for anything substantial. Roc's closures have no such restriction — a body in braces may hold as many statements as it likes.
Closures capture by value
A Roc closure captures the values it mentions, with no nonlocal, no cell objects and no late binding.
amount = 10 def add_amount(number): return number + amount print(add_amount(5)) greeting = "hi " greet = lambda name: greeting + name print(greet("Roc"))
main! = |_args| { amount : I64 amount = 10 add_amount = |number| number + amount echo!(add_amount(5).to_str()) greeting = "hi " greet = |name| "${greeting}${name}" echo!(greet("Roc")) Ok({}) }
The Python loop-variable trap — building closures in a loop and finding they all see the last value — cannot happen here, because what was captured cannot afterwards change.
No keyword arguments, no *args
Roc functions take a fixed number of positional arguments. There are no defaults, no keyword arguments, no *args and no **kwargs — the pattern that replaces all of them is a record of options.
def connect(host, port=8080, *, verbose=False): return f"{host}:{port} verbose={verbose}" print(connect("example.com")) print(connect("example.com", verbose=True))
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({}) }
This is more ceremony than a Python signature and it buys two things: the option set has a name and a type, and adding an option is a change to one record rather than to every call site.
Generic functions
A lowercase name in a type signature is a type variable. a -> a says the function returns exactly the type it was given, for any type at all.
def identity(value): return value print(identity("same")) print(identity(7))
identity : a -> a identity = |value| value main! = |_args| { echo!(identity("same")) echo!(identity(7.I64).to_str()) Ok({}) }
A Python function is generic by accident, because nothing checks it. A Roc one is generic on purpose, and the signature is a promise: a -> a cannot inspect the value, because nothing is known about it.
Duck typing, checked at compile time
A where clause says what the function needs from its type variable — here, that the type has a to_str method with that signature. It is duck typing with the bill checked in advance.
def announce(value): # Works for anything with a __str__ — # discovered when it does not. print(str(value)) announce(42) announce(2.5)
announce! : a => {} where [a.to_str : a -> Str] announce! = |value| { echo!(value.to_str()) } main! = |_args| { announce!(42.I64) announce!(2.5.Dec) Ok({}) }
This is the closest thing Roc has to a protocol or an interface, and it is structural: no type declares that it satisfies the clause, it simply has the method or it does not.
Control Flow
The conditional expression is the only if
Roc has no if statement — if is an expression that produces a value, so a multi-branch choice is written once rather than as assignments scattered through branches.
score = 85 if score >= 90: grade = "A" elif score >= 80: grade = "B" else: grade = "C" print(grade)
main! = |_args| { score : I64 score = 85 grade = if score >= 90 { "A" } else if score >= 80 { "B" } else { "C" } echo!(grade) Ok({}) }
Because every branch must produce a value of the same type, an else is mandatory whenever the result is used. There is no way to write an if that leaves a name unbound, which is what the Python column risks if a branch is added later.
for loops, and the pure alternative
Roc's for is a statement that works in pure functions too, updating a var as it goes, as well as in effectful code like this.
for word in ["alpha", "beta", "gamma"]: print(word)
main! = |_args| { for word in ["alpha", "beta", "gamma"] { echo!(word) } Ok({}) }
The same loop can be written as a method, words.for_each!(|word| echo!(word)), which is the form that chains. In pure code neither is available and the equivalent is fold or map, which produce a value rather than performing an action — not a restriction so much as a consequence, since there is nothing for a pure loop body to do.
while and break
Roc does have a real while loop with break, which surprises people who expect a purely functional language to insist on recursion. It needs a var, since a loop over an unchanging condition would never end.
count = 0 while count < 5: count = count + 1 if count == 3: break print(count)
main! = |_args| { var $count = 0.I64 while $count < 5 { $count = $count + 1 if $count == 3 { break } } echo!($count.to_str()) Ok({}) }
There is no else clause on a Roc loop, so Python's while ... else has no equivalent — which is no great loss, given how few Python programmers know what it does.
Guard clauses and early return
return exists and does what you expect, so the guard-clause style transfers unchanged. The final expression of a block is its value, so the last line needs no return.
def clamp_positive(number): if number < 0: return 0 return number print(clamp_positive(-5)) print(clamp_positive(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 the last line would also work. Leaving it off is the convention, and it is the same convention that makes every if and match an expression.
No truthiness
A Roc condition must be a Bool. There is no protocol that turns a list, a string or a number into one, so the question being asked has to be written out.
items = [] if not items: print("Python calls an empty list falsy") if "": print("never reached") else: print("and an empty string too")
main! = |_args| { items : List(I64) items = [] if items.is_empty() { echo!("the condition must be a real Bool") } # if items { ... } or if "" { ... } # would be COMPILE errors — nothing is # implicitly truthy. echo!("and an empty string is just a string") Ok({}) }
Python's truthiness is convenient until the day if count: silently skips the zero case, or if value: treats an empty result the same as a missing one. Naming the test — is_empty() — costs a few characters and removes that whole family of bug.
Recursion without a depth limit
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 function in Roc runs in constant stack space.
import sys def sum_to(limit, accumulator=0): if limit <= 0: return accumulator return sum_to(limit - 1, accumulator + limit) sys.setrecursionlimit(20000) print(sum_to(10000))
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(10_000, 0).to_str()) Ok({}) }
CPython has no tail-call optimization and a default recursion limit of 1000, so the Python column has to raise the limit and is still consuming a stack frame per step. This is a case where the functional style is genuinely cheaper in Roc than it is in Python.
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.
def describe(name): return f"hello {name}" def announce(name): print(describe(name)) # nothing stops a # "pure" function # from printing 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 — the signature would have to change, and so would every caller. That propagation is the point: a function's purity is visible in its type rather than in a comment or a naming convention.
expect is part of the language
expect is a language keyword rather than a library function, and it can appear at the top level of a file as well as inside a function — which is how Roc does unit tests without a testing framework.
total = 2 + 2 assert total == 4 print("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. That is a deliberate design goal of the compiler, and it is the thing most worth stealing whether or not you ever ship Roc.
No Interpreter, No GC, No GIL
Reference counting, inserted by the compiler
CPython reference counts too — and then runs a whole cycle collector on top, because two objects that hold each other keep each other's count above zero forever. Roc needs no collector, and this row is why.
first = {"name": "first"} second = {"name": "second", "peer": first} first["peer"] = second # closing the cycle, which # only works because first # can still be changed print(second["peer"]["name"]) print(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({}) }
The Python column closes the cycle on its third line and prints twice; the Roc column cannot, so it prints once. Nothing in Roc can be made to point back at a value that already exists, which is exactly the condition under which reference counting alone is sufficient — so there is no gc module to import, tune or disable.
No global interpreter lock
The global interpreter lock exists to protect CPython's reference counts, and it is why threading does not speed up CPU-bound Python. Roc's reference counts are inserted at compile time into native code, so there is nothing process-wide to lock.
# Handing this to four threads would not make it # faster: CPython's reference counts are protected # by one process-wide lock, so only one thread runs # bytecode at a time. numbers = [1, 2, 3, 4] print(sum(numbers))
main! = |_args| { # Roc has no interpreter, so there is no # interpreter lock. Whether work like this can # be spread across cores is the platform's # decision, not the language's. numbers : List(I64) numbers = [1, 2, 3, 4] echo!(numbers.sum().to_str()) Ok({}) }
Roc has no async keyword and no thread API of its own either. What concurrency a program can do is decided by the platform it is built against, which is a real limitation today given how few mature platforms exist.
Functional updates that mutate when it is safe
Semantically List.set builds a new list. If the reference count of the old one is one — nobody else is holding it — the compiler mutates in place and copies nothing.
numbers = [1, 2, 3] updated = numbers.copy() # an explicit copy, updated[1] = 99 # then mutate the copy print(updated) print(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({}) }
This is why writing in an immutable style in Roc does not carry the cost it would in Python. Here both lists are printed, so both must exist and a copy really is made; drop the second echo! and the copy disappears.
Top-level values are computed before the program starts
A top-level Roc definition is evaluated by the compiler, not at startup. By the time the program runs, squared is the constant 100 baked into the binary.
limit = 10 squared = limit * limit # computed at import time print(squared)
limit : I64 limit = 10 squared : I64 squared = limit * limit main! = |_args| { echo!(squared.to_str()) Ok({}) }
Python does this work at import time, every time the module is imported, and the cost is paid on each process start. The Roc version has no startup cost at all, which is part of why a Roc binary starts in microseconds.
Classes vs Method Blocks
A class becomes a type with a method block
The .{ } after a type definition is a block of functions associated with that type. There is no self — each function takes the value as an ordinary first parameter, which is what self already is in Python, spelled honestly.
class Counter: def __init__(self, value=0): self.value = value def increment(self): return Counter(self.value + 1) def describe(self): return f"count is {self.value}" print(Counter().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({}) }
Method syntax works because the compiler resolves counter.describe() to Counter.describe(counter) from the type it already knows. There is no dictionary lookup, no method resolution order and no inheritance — there is no way to subclass a Roc type.
No monkey patching, no __getattr__
A Roc type's method block is fixed at the point of definition. Nothing can add to it afterwards, and there is no __getattr__ to answer for a method that does not exist.
class Greeting: def __init__(self, text): self.text = text # Added from outside the class, at run time, # by anyone who has imported it: Greeting.shout = lambda self: f"{self.text}!" print(Greeting("hello").shout())
# Str cannot be reopened, and neither can a # type you wrote. Write a function instead: shout : Str -> Str shout = |text| "${text}!" main! = |_args| { echo!(shout("hello")) Ok({}) }
This closes off the whole dynamic-patching style that libraries like mock depend on, and with it the failure mode where a method appears or disappears depending on which modules were imported. The replacement is an ordinary function, which is what shout already was.
NewType, with teeth
Defining a type with := rather than : makes it nominal — a genuinely distinct type that a plain number cannot be used in place of, however similar the underlying data.
from typing import NewType UserId = NewType("UserId", int) def greet(user_id: UserId) -> str: return f"user #{user_id}" print(greet(UserId(42))) print(greet(42)) # a plain int — checkers object, # the interpreter does not
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({}) }
NewType exists only for the benefit of a separate type checker; at run time UserId(42) is the integer 42. Roc's distinction is enforced by the compiler that produces the program, so there is no mode in which it is skipped.
Gotchas for Python 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].
print([1, 2, 3]) print(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 itself. Once anything in the expression pins the type down, the rest follows.
A bare True is not a Bool
Bool in Roc is an ordinary tag union, and True and False written bare are just tags — not necessarily that union.
ready = False print(not 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. This is the same inference rule that makes untyped numbers into Dec, seen from another angle.
The standard library is still growing
Roc is pre-1.0 and its standard library is still growing, so some of what Python programmers reach for is not there yet, and names can change from one release to the next. What is there often goes by a name of its own, like replace_each and with_ascii_uppercased here.
print("hello world".replace(" ", "_")) print("shout".upper())
main! = |_args| { # Replacement and case conversion go by names # of their own. echo!("hello world".replace_each(" ", "_")) echo!("shout".with_ascii_uppercased()) Ok({}) }
This is the honest answer to "should I use Roc today": the language design is worth studying now, and the library is not ready for work you have to maintain. What is here is small, coherent and moving quickly.

Thank you — anything else?