Hello World & The Platform Model
Hello, World
A Roc program is a definition of
main!, a function the platform calls with the command-line arguments. The ! at the end of a name means "this performs effects", and the compiler enforces it — there is no equivalent marking in Elixir, where IO.puts can be called from anywhere.IO.puts("Hello, World!")main! = |_args| {
echo!("Hello, World!")
Ok({})
}Ok({}) is the return value, where {} is the empty record. An Elixir script is a sequence of expressions with no entry point to declare; a Roc application always has exactly one, because the platform has to have something to call.Where the standard library comes from
A Roc application declares the platform it is compiled against, and the platform supplies the complete list of effects the program may perform. An Elixir application declares dependencies; it does not declare what it is allowed to do.
# OTP is simply there. :gen_server, :ets, File and
# every process primitive exist because the BEAM
# started before your code did.
IO.puts("the runtime came with the platform")main! = |_args| {
# Nothing is ambient. This program was built
# against a platform providing exactly echo!,
# so echo! is the only effect it can perform.
echo!("the runtime came with the platform")
Ok({})
}This is the row that Elixir readers find most familiar and most different at once. An OTP release also bundles exactly the applications it needs — but any module in it can still call
File.read!, and nothing in a function's signature says whether it will.Comments
Comments start with
# and run to the end of the line — the same character Elixir uses. There is no block form and no documentation attribute.# A single-line comment
count = 42 # an inline comment
# Elixir has no block comment either. Documentation
# is @doc / @moduledoc, which are attributes rather
# than comments and only exist inside a module.
IO.puts(count)main! = |_args| {
# A single-line comment
count : I64
count = 42 # an inline comment
# Roc has no documentation form either, and no
# block comment — every comment line starts
# with its own #.
echo!(count.to_str())
Ok({})
}The
count : I64 line is a type annotation. Elixir's nearest equivalent is @spec, which Dialyzer checks separately and the compiler ignores; a Roc annotation is checked by the compiler against what it inferred, and a wrong one is an error rather than a warning from another tool.How a program reports failure
main! returns a Try — Ok for success, Err for failure — and the platform turns that into whatever the operating system wants.# A script's exit status is whatever the VM ends
# with. System.halt(1) sets it from ANYWHERE in the
# program — it is not shown running here because it
# stops the VM this page runs in.
IO.puts("all good")main! = |_args| {
echo!("all good")
# Ok is success, Err is failure; the platform
# turns the return value into an exit status.
Ok({})
}Because the status is a return value, the compiler type-checks it, and nothing deep in a library can end the program on its own.
System.halt does exactly that, which is why the advice is System.stop instead — it at least lets the application shut down in order. On this page System.halt is described rather than run, because it takes the whole runtime down with it and the next example would find nothing there.Static Types, and What They Replace
The mistake the compiler catches
Roc infers a complete static type for every expression. A string used where a number is wanted is a compile error naming both types, so the program never reaches a state where the question can be asked at run time.
value = "7"
try do
IO.puts(value + 1)
rescue
ArithmeticError ->
IO.puts("found at RUNTIME, when this line ran")
endmain! = |_args| {
value = "7"
# value + 1 does not compile: Str has no +.
# The mistake is found before the program runs,
# so there is nothing to rescue.
echo!("found at COMPILE time, before it ran")
Ok({})
}This is the whole axis of the page. Everything else —
{:ok, _}, atoms as states, maps with the right keys — is a convention Elixir maintains by discipline and Roc maintains by type. Elixir's set-theoretic gradual types are moving toward the same ground from the other side, which makes this a live comparison rather than a settled one.Inference, not annotation
Roc infers across the whole program, so an annotation is a claim you choose to state rather than information the compiler needs. Writing one is checked against what was inferred.
# @spec is documentation that Dialyzer checks
# separately. The compiler does not.
defmodule InferenceDemo do
@spec doubled(integer()) :: integer()
def doubled(number), do: number * 2
end
IO.puts(InferenceDemo.doubled(21))# The whole signature is inferred from the body and
# the call sites, so the annotation is optional —
# and when written, it is CHECKED.
doubled = |number| number * 2
main! = |_args| {
result : I64
result = doubled(21)
echo!(result.to_str())
Ok({})
}A wrong
@spec is found by Dialyzer, if it is run, in a separate pass, with output that is famously hard to read. A wrong Roc annotation is a compile error naming the two types. The difference is not that one has types and the other does not — it is who checks them and when.A missing case is an error, not a warning
A
match must cover every case the type allows, and leaving one out is a compile error naming the missing pattern. The type lists exactly three tags, so three arms are complete.# Handling only two of three is a warning at best
# and a CaseClauseError at run time.
color = :red
IO.puts(
case color do
:red -> "red"
:green -> "green"
:blue -> "blue"
end
)Color : [Red, Green, Blue]
describe : Color -> Str
describe = |color| match color {
Red => "red"
Green => "green"
Blue => "blue"
}
main! = |_args| {
# Delete any arm above and this does not build.
echo!(describe(Red))
Ok({})
}Elixir cannot check this, because
color has no type saying which atoms it might be — any atom is a possible value, and there are infinitely many. Adding a fourth color to a Roc union breaks the build at every match that needs updating, which is the refactoring tool this row is really about.A map with the right keys is a type
A Roc record type is its set of fields, so the compiler knows which fields exist and what each holds. Reading one that is not there is a compile error listing the ones that are.
# A map is a map. Nothing says which keys it has,
# so a typo in a key is found when it is read.
person = %{name: "Ada", age: 36}
IO.puts("#{person.name} is #{person.age}")Person : { name : Str, age : I64 }
main! = |_args| {
person : Person
person = { name: "Ada", age: 36 }
# person.nmae is a compile error naming the
# fields this record actually has.
echo!("${person.name} is ${person.age.to_str()}")
Ok({})
}The literals are almost identical — Elixir's
%{name: "Ada"} and Roc's { name: "Ada" } — and what differs is entirely what is known about them. person.nmae in Elixir raises KeyError at the moment that line runs, which may be in production.No nil
There is no nil
Absence is spelled as a tag union —
Some(Str) or None — and the only way to reach the string inside is to handle both cases, because a match must cover every tag.# nil is an ordinary atom, and any expression may
# evaluate to it. Nothing in the code says which.
name = nil
IO.puts(if name, do: String.length(name), else: 0)main! = |_args| {
# There is no nil to write and no type that
# could hold one. Absence is a value you
# construct on purpose.
name : [Some(Str), None]
name = None
length = match name {
Some(text) => text.count_utf8_bytes()
None => 0
}
echo!(length.to_str())
Ok({})
}Elixir's
nil is milder than most languages' null: it is just an atom, so passing it somewhere usually raises a clear FunctionClauseError rather than corrupting anything. It is still a value that can arrive anywhere, and the check is still yours to remember.A lookup that might find nothing
Dict.get returns a Try, and match forces both arms. This is Map.fetch exactly — the difference is that there is no Map.get beside it returning nil.ages = %{"alice" => 30}
# Map.get returns nil for a missing key; Map.fetch
# returns {:ok, v} or :error. Two functions,
# because one answer cannot say both things.
case Map.fetch(ages, "bob") do
{:ok, age} -> IO.puts("bob is #{age}")
:error -> IO.puts("bob is not listed")
endmain! = |_args| {
ages = Dict.empty().insert("alice", 30.I64)
# get returns a Try, so there is one way to ask
# and the answer carries its own failure case.
match ages.get("bob") {
Ok(age) => echo!("bob is ${age.to_str()}")
Err(_) => echo!("bob is not listed")
}
Ok({})
}Elixir readers will notice how close these are, and the closeness is the point:
{:ok, value} / :error is the right shape, and Roc's contribution is making it the only shape and giving it a type.No truthiness
An
if condition must be a Bool. There is no truthiness, so a number, a string or a list cannot stand in for a condition and the comparison has to be written.# nil and false are falsy; 0, "" and [] are truthy.
# Three atoms decide the behavior of every if.
value = 0
IO.puts(if value, do: "truthy", else: "falsy")main! = |_args| {
# An if condition must be a Bool. A number is
# not a Bool, so "if value" is a compile error.
value : I64
value = 0
echo!(if value == 0 { "zero" } else { "not zero" })
Ok({})
}Elixir's rule is unusually sane — only
nil and false are falsy, so 0 and "" behave as most people expect. Requiring a real Bool removes even that, along with the &&/and distinction that exists because one accepts any term and the other demands a boolean.Atoms vs Tags
An atom becomes a tag
A tag is written by writing it, needs no declaration, and is compared by name — all of which will read as familiar. The difference is that the compiler infers which tags a value can be, and checks the
match against that.status = :ok
IO.puts(
case status do
:ok -> "fine"
:busy -> "busy"
_ -> "unknown"
end
)# Writing Fine or Busy CREATES the tag — nothing is
# declared, exactly as with an atom.
describe = |status| match status {
Fine => "fine"
Busy => "busy"
_ => "unknown"
}
main! = |_args| {
echo!(describe(Fine))
Ok({})
}The case is spelled
Fine rather than Ok only to keep it clear of the Ok that Try uses. Tags are capitalized where atoms are prefixed with a colon; otherwise this is the same idea.A tag carries its payload directly
A tag carries its own payload —
Circle(F64) — so there is no tuple wrapped around it. The union declares which tags exist and what each one holds.# An atom holds nothing, so anything it needs to
# carry goes in a tuple beside it.
shape = {:circle, 2.0}
IO.puts(
case shape do
{:circle, radius} -> 3.14159 * radius * radius
{:rect, width, height} -> width * height
end
)Shape : [Circle(F64), Rect(F64, F64)]
area : Shape -> F64
area = |shape| match shape {
Circle(radius) => 3.14159 * radius * radius
Rect(width, height) => width * height
}
main! = |_args| {
echo!(area(Circle(2.0)).to_str())
Ok({})
}The Elixir version is the same design without the type: nothing says that a shape is one of exactly two shapes, that a circle carries one number, or that the
case is complete. That is the row where the tagged-tuple convention and the tag union visibly part company.A union that stays open
The
.. in [Warning, ..] leaves the union open: this function handles Warning by name and anything else through the wildcard, and a caller may pass a tag the function has never heard of.# Every atom set in Elixir is open — any atom can
# arrive — and none of them is checked. There is no
# way to say "at least these, and I handle the rest"
# and have it verified.
level = :warning
IO.puts(
case level do
:warning -> "warning"
_ -> "other"
end
)# The type says "at least Warning, possibly more",
# and _ catches whatever a caller passes in — with
# the compiler checking that the rest are handled.
describe : [Warning, ..] -> Str
describe = |level| match level {
Warning => "warning"
_ => "other"
}
main! = |_args| {
echo!(describe(Warning))
echo!(describe(Fatal))
Ok({})
}Elixir is open by default and cannot be closed; Roc lets a function choose, per signature, and checks whichever it chose. Openness is the thing Elixir programmers will miss least on this page, because they already have it — what is new is being able to ask for the other one.
{:ok, _} Tuples vs Try
The convention becomes a type
Try(ok, err) is the {:ok, _} / {:error, _} convention with a type attached: the success value on one side, a tag union of what can go wrong on the other.# {:ok, value} / {:error, reason} is a convention.
# Nothing enforces it, and a function is free to
# return something else entirely.
case Integer.parse("17") do
{number, _rest} -> IO.puts(number)
:error -> IO.puts("not a number")
end# Try(I64, [BadNumStr, ..]) is a TYPE. The compiler
# knows both halves and will not let the value be
# reached without handling the failure.
parse : Str -> Try(I64, [BadNumStr, ..])
parse = |text| I64.from_str(text)
main! = |_args| {
match parse("17") {
Ok(number) => echo!(number.to_str())
Err(_) => echo!("not a number")
}
Ok({})
}Elixir got the shape right long before most languages, which is why this row reads as a formalization rather than a correction. What the type adds is that a caller who forgets the error case does not compile, and that
Integer.parse returning {number, rest} rather than {:ok, number} — a real inconsistency in the standard library — could not happen.with vs the ? operator
A
? after an expression unwraps the Ok and returns early on Err. It is with at the level of a single expression rather than a block, so the happy path is ordinary code rather than a special form.# with chains matches and falls out to else on the
# first one that does not match.
result =
with {first, ""} <- Integer.parse("3"),
{second, ""} <- Integer.parse("4") do
first + second
else
_ -> :error
end
IO.puts(if result == :error, do: "bad input", else: result)sum : Str, Str -> Try(I64, [BadNumStr, ..])
sum = |first_text, second_text| {
first = I64.from_str(first_text)?
second = I64.from_str(second_text)?
Ok(first + second)
}
main! = |_args| {
match sum("3", "4") {
Ok(total) => echo!(total.to_str())
Err(_) => echo!("bad input")
}
Ok({})
}with is one of Elixir's best ideas and it has a known rough edge: the else block sees every non-matching value from every clause, with nothing saying which clause produced it, so a careful else ends up re-tagging each step. ? propagates the error unchanged and the type records what it can be.An error carries information
An error case is a tag with a payload —
Insufficient(I64) — written in the return type. The nesting Elixir needs, {:error, {:insufficient, n}}, collapses to one tag because the tag itself carries the number.withdraw = fn balance, amount ->
if amount > balance do
{:error, {:insufficient, amount - balance}}
else
{:ok, balance - amount}
end
end
case withdraw.(100, 150) do
{:ok, remaining} -> IO.puts(remaining)
{:error, {:insufficient, short}} -> IO.puts("short by #{short}")
endwithdraw : I64, I64 -> Try(I64, [Insufficient(I64), ..])
withdraw = |balance, amount|
if amount > balance {
Err(Insufficient(amount - balance))
} else {
Ok(balance - amount)
}
main! = |_args| {
match withdraw(100, 150) {
Ok(remaining) => echo!(remaining.to_str())
Err(Insufficient(shortfall)) =>
echo!("short by ${shortfall.to_str()}")
}
Ok({})
}Both columns print the same thing and read almost alike. The difference is in the signature: the Roc one says
Insufficient is the only failure, so a match that handles it is complete, and adding a second failure breaks every caller that now has a case to consider.No bang-variant pairs
There is no
!-suffixed variant of anything — the ! in Roc means "performs effects", not "raises". A fallible function returns a Try and that is the only version of it.# The convention is a pair: File.read returns a
# tuple, File.read! raises. Every fallible function
# in the standard library is written twice.
IO.puts(
case Integer.parse("nope") do
:error -> "not a number"
{number, _} -> to_string(number)
end
)main! = |_args| {
# One function. There is no raising variant,
# because there is nothing to raise.
match I64.from_str("nope") {
Ok(number) => echo!(number.to_str())
Err(_) => echo!("not a number")
}
Ok({})
}Elixir's pairs exist because raising is genuinely the right answer when a failure means the process should die, and writing both is the cost of offering the choice. Roc has no exceptions at all, so the choice does not arise — and the Processes section is where that stops being obviously an improvement.
Pattern Matching
case vs match
match is Elixir's case with a different keyword: each arm is a pattern and a value, _ is the wildcard, and the whole thing is an expression.name = fn code ->
case code do
200 -> "OK"
404 -> "Not Found"
_ -> "Unknown"
end
end
IO.puts(name.(404))name : I64 -> Str
name = |code| match code {
200 => "OK"
404 => "Not Found"
_ => "Unknown"
}
main! = |_args| {
echo!(name(404))
Ok({})
}Roc writes
=> where Elixir writes ->, and needs no end. Otherwise an Elixir programmer can read this without being told anything.Destructuring a map
Writing a record pattern on the left of
= pulls fields out and binds each to a name. Roc's shorthand binds each field to its own name; Elixir writes name: name where Roc writes just name.person = %{name: "Ada", age: 36}
%{name: name, age: age} = person
IO.puts("#{name}/#{age}")main! = |_args| {
person = { name: "Ada", age: 36.I64 }
{ name, age } = person
echo!("${name}/${age.to_str()}")
Ok({})
}The deeper difference is what
= means. In Elixir it is the match operator, so %{name: "Bob"} = person is a runtime assertion that raises if it fails. In Roc = only binds, and a pattern that could fail belongs in a match.Guards
An arm may carry an
if guard: the pattern binds a name first, then the guard decides whether this arm runs. Roc writes if where Elixir writes when.classify = fn number ->
cond do
number == 0 -> "zero"
number > 0 -> "positive"
true -> "negative"
end
end
IO.puts(classify.(0))
IO.puts(classify.(42))classify : I64 -> Str
classify = |number| match number {
0 => "zero"
n if n > 0 => "positive"
_ => "negative"
}
main! = |_args| {
echo!(classify(0))
echo!(classify(42))
Ok({})
}Roc's guards are ordinary expressions. Elixir's are restricted to a fixed list of functions that the compiler can prove side-effect-free, which is why
String.length/1 is allowed in a guard and a function you wrote is not — the whole reason defguard exists.No multiple function heads
A Roc function has one parameter list and one body. Dispatching on the shape of an argument is done with a
match inside it rather than by writing the function several times.defmodule HeadsDemo do
# Elixir dispatches on the pattern in the head,
# so one function is several clauses.
def describe(0), do: "zero"
def describe(n) when n > 0, do: "positive"
def describe(_), do: "negative"
end
IO.puts(HeadsDemo.describe(0))# One function, one body, and the match inside it.
describe : I64 -> Str
describe = |number| match number {
0 => "zero"
n if n > 0 => "positive"
_ => "negative"
}
main! = |_args| {
echo!(describe(0))
Ok({})
}Multi-clause heads are among the things Elixir programmers miss most in other languages, and the loss here is real — the Elixir version reads as a table of cases while the Roc one nests them one level deeper. What is gained is that the cases are checked for completeness, which a set of function heads is not.
Structs vs Records
Declaring and building
A record literal is braces with
field: value pairs, and the type line above is an alias for that shape rather than a declaration of a new thing. Any record with those fields already is a Person.defmodule PersonStruct do
defstruct name: "", age: 0
end
alice = %PersonStruct{name: "Alice", age: 30}
IO.puts("#{alice.name} is #{alice.age}")Person : { name : Str, age : I64 }
main! = |_args| {
alice : Person
alice = { name: "Alice", age: 30 }
echo!("${alice.name} is ${alice.age.to_str()}")
Ok({})
}An Elixir struct is a map with a
__struct__ key naming its module, which is what makes it nominal — a %PersonStruct{} will not match a %Coordinate{} of the same shape. Roc's records are structural; := is how you ask for the nominal behavior.Changing one field
{ ..alice, age: 31 } copies every field and overrides the ones named after it. It is Elixir's %{alice | age: 31} with the spread written first rather than the source.defmodule PersonUpdate do
defstruct name: "", age: 0
end
alice = %PersonUpdate{name: "Alice", age: 30}
older = %{alice | age: 31}
IO.puts("#{alice.age} then #{older.age}")main! = |_args| {
alice = { name: "Alice", age: 30.I64 }
older = { ..alice, age: 31 }
echo!("${alice.age.to_str()} then ${older.age.to_str()}")
Ok({})
}Both refuse to add a field that is not already there — Elixir's update syntax raises
KeyError for an unknown key, and Roc's is a compile error. That is one of the places Elixir's struct is doing real work, and the two languages agree.Updating something nested
Updating a nested field is the same update syntax applied twice — build the new inner record, then a new outer one around it. There is no path-based helper.
team = %{name: "core", lead: %{name: "Ada", age: 36}}
# put_in and friends exist because nested update
# syntax does not compose.
updated = put_in(team, [:lead, :age], 37)
IO.puts(updated.lead.age)main! = |_args| {
team = { name: "core", lead: { name: "Ada", age: 36.I64 } }
# Nested update is the same syntax, nested.
updated = { ..team, lead: { ..team.lead, age: 37 } }
echo!(updated.lead.age.to_str())
Ok({})
}Elixir has
put_in, update_in and Access for exactly this, and they are genuinely more concise once the nesting is more than two deep. Roc's version is more typing and needs nothing imported; neither is obviously better, and this is the row where Elixir's standard library is ahead.A recursive type
A type that refers to itself must be declared with
:= rather than : — a recursive alias is a compile error. Constructing a case qualifies it with the type name, Tree.Node(…), while matching does not.sum_tree = fn
_self, {:leaf, value} -> value
self, {:node, left, right} ->
self.(self, left) + self.(self, right)
end
tree = {:node, {:leaf, 1}, {:node, {:leaf, 2}, {:leaf, 3}}}
IO.puts(sum_tree.(sum_tree, tree))Tree := [Leaf(I64), Node(Tree, Tree)]
total : Tree -> I64
total = |tree| match tree {
Leaf(value) => value
Node(left, right) => total(left) + total(right)
}
main! = |_args| {
tree = Tree.Node(Tree.Leaf(1), Tree.Node(Tree.Leaf(2), Tree.Leaf(3)))
echo!(total(tree).to_str())
Ok({})
}The Elixir column threads the function through itself because an anonymous function cannot call itself by name — a named function in a
defmodule would read more naturally. The Roc side needs no such trick: a top-level definition can recurse, and Tree.Node(…) to construct against bare Node(left, right) to match is the asymmetry to remember.A module with a struct becomes a method block
A nominal type is declared with
:=, and a trailing .{ … } block holds the functions that belong to it — the same grouping defmodule gives a defstruct. The difference is where the name lives: Account.new(100) reaches the block through the type, and .withdraw(30) reaches it through a value of that type, so the calls chain without a pipe.defmodule Account do
defstruct balance: 0
def new(balance), do: %Account{balance: balance}
def withdraw(account, amount),
do: %Account{account | balance: account.balance - amount}
def describe(account), do: "balance is #{account.balance}"
end
Account.new(100)
|> Account.withdraw(30)
|> Account.describe()
|> IO.puts()Account := { balance : I64 }.{
new : I64 -> Account
new = |starting| { balance: starting }
withdraw : Account, I64 -> Account
withdraw = |account, amount|
{ balance: account.balance - amount }
describe : Account -> Str
describe = |account| "balance is ${account.balance.to_str()}"
}
main! = |_args| {
echo!(Account.new(100).withdraw(30).describe())
Ok({})
}These are the same shape rather than two different ideas. In both languages a method is an ordinary function taking the value as its first argument, and neither has inheritance or virtual dispatch. Elixir keeps the module and the struct as separate names and pipes between them; Roc attaches the block to the type, which is what buys the dot. What Roc gives up is everything that needs a runtime — no protocol to implement later, no
defimpl for a type you do not own, and every call here resolved at compile time.Pipes & Enum vs List Functions
The pipe operator, in both languages
|> is in both languages, spelled the same, meaning the same thing: the value on the left becomes the first argument of the call on the right.[1, 2, 3, 4, 5]
|> Enum.filter(fn number -> rem(number, 2) == 1 end)
|> Enum.map(fn number -> number * 10 end)
|> Enum.join(", ")
|> IO.puts()main! = |_args| {
numbers : List(I64)
numbers = [1, 2, 3, 4, 5]
numbers
|> List.keep_if(|number| number % 2 == 1)
|> List.map(|number| number * 10)
|> List.map(|number| number.to_str())
|> Str.join_with(", ")
|> echo!
Ok({})
}This is the closest row on the page, and worth noticing for that reason — a language with no shared ancestry arrived at the same operator with the same semantics.
keep_if is Enum.filter, and a lambda's parameters go between vertical bars where Elixir writes fn … ->.reduce and fold
fold is Enum.reduce: a starting value and a function of the accumulator and the next element.numbers = [1, 2, 3, 4]
IO.puts(Enum.sum(numbers))
IO.puts(Enum.reduce(numbers, 1, fn number, running -> running * number end))main! = |_args| {
numbers : List(I64)
numbers = [1, 2, 3, 4]
echo!(numbers.sum().to_str())
echo!(numbers.fold(1, |running, n| running * n).to_str())
Ok({})
}The argument order is flipped — Elixir passes the element first and the accumulator second, Roc the other way round — which is exactly the kind of detail that costs ten minutes the first time.
Enum.reduce/2 without a seed raises on an empty list; Roc has no such variant.No Stream, and everything is eager
Roc's list functions are eager: each one runs where it is written and returns a finished list. There is no lazy counterpart and no
Stream.# Stream is lazy: nothing runs until Enum.to_list.
result =
1..5
|> Stream.map(fn number -> number * 2 end)
|> Stream.filter(fn number -> number > 4 end)
|> Enum.to_list()
IO.puts(Enum.join(result, ", "))main! = |_args| {
numbers : List(I64)
numbers = [1, 2, 3, 4, 5]
# Every step runs where it is written and
# produces a finished list.
result =
numbers
|> List.map(|number| number * 2)
|> List.keep_if(|number| number > 4)
|> List.map(|number| number.to_str())
echo!(Str.join_with(result, ", "))
Ok({})
}That is a real loss for anything unbounded or expensive —
Stream is how Elixir reads a large file line by line without holding it in memory, and there is no way to express that here. The compiler does fuse some chains, but fusing is not the same as never building the intermediate list.Finding the first match
find_first returns a Try: Ok with the element or Err when nothing matched. The two outcomes have different shapes, so they cannot be confused.numbers = [1, 3, 8, 5]
# Enum.find returns nil when nothing matches, so
# the caller cannot tell it from a stored nil.
case Enum.find(numbers, fn number -> rem(number, 2) == 0 end) do
nil -> IO.puts("nothing matched")
found -> IO.puts(found)
endmain! = |_args| {
numbers : List(I64)
numbers = [1, 3, 8, 5]
match numbers.find_first(|number| number % 2 == 0) {
Ok(value) => echo!(value.to_str())
Err(_) => echo!("nothing matched")
}
Ok({})
}Enum.find returning nil is the one place Elixir's otherwise excellent result conventions slip — a list that legitimately contains nil makes the answer ambiguous, which is why Enum.find_value and a default argument both exist.Immutability, and What It Costs
Rebinding vs mutation
Roc does not allow rebinding: a name is given a value once. Where a loop or a sequence genuinely needs to accumulate,
var declares a mutable binding whose name carries a $ at every use.# The VALUE is immutable; the NAME can be rebound,
# which is why this looks like mutation and is not.
counter = 1
counter = counter + 1
IO.puts(counter)main! = |_args| {
# A plain binding cannot be rebound at all —
# counter = counter + 1 is a compile error.
# Accumulating needs var and a $ on the name.
var $counter = 1.I64
$counter = $counter + 1
echo!($counter.to_str())
Ok({})
}This is stricter than Elixir, which allows rebinding freely while keeping values immutable — and it is the difference that catches Elixir programmers most often. The error message names self-reference rather than mutation, because
counter = counter + 1 reads to the compiler as a definition whose body mentions itself.Immutable in the source, mutated in the binary
Every list function returns a new list, and
set wraps it in a Try because the index may be out of range. When the old list has exactly one reference and is about to go out of scope, the compiler reuses its memory instead of copying.numbers = [1, 2, 3]
updated = List.replace_at(numbers, 0, 99)
# numbers is untouched; the BEAM copied what it
# had to and shared the rest.
IO.puts(Enum.at(updated, 0))main! = |_args| {
numbers : List(I64)
numbers = [1, 2, 3]
# set returns a new list — but numbers is never
# used again, so its count is 1 and the compiler
# writes into the same memory rather than copying.
updated = numbers.set(0, 99)?
echo!(updated.get(0)?.to_str())
Ok({})
}The BEAM achieves the same end differently — persistent data structures that share everything unchanged, decided at run time. Roc decides at compile time and can therefore mutate outright, which is faster when it applies and gives up the structural sharing that makes a large map cheap to copy in Elixir.
Lists are not linked lists
A Roc
List is a contiguous array, so len and get are constant time. Indexing returns a Try, which the ? unwraps.numbers = [10, 20, 30]
# length/1 walks the list; Enum.at/2 walks to the
# index. Both are O(n) on a cons list.
IO.puts(length(numbers))
IO.puts(Enum.at(numbers, 1))main! = |_args| {
numbers : List(I64)
numbers = [10, 20, 30]
# A Roc list is a contiguous array, so both of
# these are constant time.
echo!(numbers.len().to_str())
echo!(numbers.get(1)?.to_str())
Ok({})
}This is a bigger change than it looks. An Elixir list is a cons cell chain, so prepending is free and indexing is a walk — which is why idiomatic Elixir builds lists backwards and reverses. Roc's array makes indexing cheap and prepending the expensive end, so the idiom inverts.
Functions & Closures
Anonymous functions and the dot
A function is written
|parameters| body and called like any other function. There is one kind of function, so there is no . and no & capture syntax.# Named and anonymous functions are different
# things, which is why calling one needs a dot.
add = fn left, right -> left + right end
IO.puts(add.(2, 3))main! = |_args| {
# One kind of function. No dot, and no
# distinction to remember.
add : I64, I64 -> I64
add = |left, right| left + right
echo!(add(2, 3).to_str())
Ok({})
}Elixir's split comes from Erlang, where named functions live in modules and have an arity, and anonymous ones are values. The dot is the visible cost;
&Module.fun/1 capture syntax is the other half of it.Closures
A function written inline captures whatever it names from the surrounding scope, exactly as in Elixir.
for … in walks a list for its effects.step = 10
[1, 2, 3]
|> Enum.map(fn number -> number + step end)
|> Enum.each(&IO.puts/1)main! = |_args| {
step = 10
numbers : List(I64)
numbers = [1, 2, 3]
for value in numbers.map(|number| number + step) {
echo!(value.to_str())
}
Ok({})
}Both languages capture by value, and in both that is unambiguous because the captured value cannot change afterwards. The
&IO.puts/1 capture in the Elixir column has no Roc counterpart — a function reference is just the name.Sorting with a comparison
sort_by takes a key function and orders by what it returns — Enum.sort_by with the key written as a lambda rather than captured with &.["pear", "fig", "apple"]
|> Enum.sort_by(&String.length/1)
|> Enum.each(&IO.puts/1)main! = |_args| {
words = ["pear", "fig", "apple"]
by_length = words.sort_by(|candidate| candidate.count_utf8_bytes())
for word in by_length {
echo!(word)
}
Ok({})
}Another row where the two languages agree outright. They differ in the general form underneath:
Enum.sort/2 takes a function returning a boolean and getting its sense backwards compiles and sorts incorrectly, while Roc's sort_with takes three named tags — Before, After or Same — which cannot be written the wrong way round.Strings
String interpolation
Interpolation is
${…} where Elixir writes #{…}, and it accepts only a Str — anything that is not already text is converted explicitly.name = "Ada"
age = 36
# Interpolation calls String.Chars.to_string, so
# anything implementing that protocol works.
IO.puts("#{name} is #{age}")main! = |_args| {
name = "Ada"
age : I64
age = 36
# Interpolation takes a Str and converts
# nothing, so the number is converted first.
echo!("${name} is ${age.to_str()}")
Ok({})
}Elixir's version goes through the
String.Chars protocol, which is why a map or a tuple raises rather than printing something unhelpful. Roc has no protocols, so the conversion is a method call you write, and forgetting it is a compile error naming the type that arrived.How long is a string
A Roc string is UTF-8 and
count_utf8_bytes is named for exactly what it counts. Asking a string for .len() is deliberately refused: it answers with an explanation that length depends on what you count, and leaves grapheme counting to a Unicode library.text = "café"
# String.length counts graphemes; byte_size counts
# bytes. Elixir gives you both, named clearly.
IO.puts(String.length(text))
IO.puts(byte_size(text))main! = |_args| {
text = "café"
# count_utf8_bytes counts bytes, and its name
# says so: "é" is two of them.
echo!(text.count_utf8_bytes().to_str())
Ok({})
}Elixir is unusually good here — binaries are UTF-8,
String.length/1 counts graphemes and byte_size/1 counts bytes, and the names say which is which. Roc's standard library makes the byte count the one it names and hands the rest to a library, on purpose.Splitting and joining
split_on takes a literal separator and Str.join_with puts a list back together — list first, separator second, the same order Enum.join uses.line = "red,green,blue"
parts = String.split(line, ",")
IO.puts(length(parts))
IO.puts(Enum.join(parts, " | "))main! = |_args| {
line = "red,green,blue"
parts = line.split_on(",")
echo!(parts.len().to_str())
echo!(Str.join_with(parts, " | "))
Ok({})
}These correspond almost exactly.
String.split/2 also accepts a list of separators or a regular expression; split_on takes one literal separator.Numbers
Integers have a width
Roc's integer types name their width and signedness —
I64, I32, U8 — and arithmetic that leaves a type's range aborts the program rather than growing or wrapping.# Elixir integers are arbitrary precision — they
# grow until memory runs out.
big = 9_000_000_000_000_000_000_000
IO.puts(big)main! = |_args| {
# Every integer type names its width. There is
# no arbitrary-precision integer, and a literal
# that does not fit is a compile error.
big : I64
big = 9_000_000_000
echo!(big.to_str())
Ok({})
}Arbitrary precision is a genuine Elixir advantage and it is not free: every integer carries a tag and may be boxed. A fixed-width integer is the trade Roc makes for values that fit in a register, and the abort is what keeps it from being silently wrong.
Integer and fractional division
Roc has two division operators:
// truncates toward zero, as Elixir's div/2 does, and / divides. % is the remainder with the sign of the number being divided, where Elixir uses rem/2.# / always produces a float in Elixir; div/2 is
# the integer one.
IO.puts(div(7, 2))
IO.puts(7 / 2)
IO.puts(rem(7, 2))main! = |_args| {
whole : I64
whole = 7
# Two operators rather than an operator and a
# function.
echo!((whole // 2).to_str())
echo!((7.0 / 2.0).to_str())
echo!((whole % 2).to_str())
Ok({})
}The two languages part on
/. Elixir's 7 / 2 is 3.5 whatever the operands are; Roc's / follows the type, so on two I64 values it discards the remainder and gives 3. The unannotated case is saved by a default: a Roc number literal with no annotation is a Dec, so 7 / 2 written with bare literals is 3.5 in both languages.Parsing a number
I64.from_str is named for the type it produces and returns a Try. The type on the left decides the result, so U8.from_str rejects "300" rather than truncating.# Integer.parse returns {number, rest} — not
# {:ok, number} — so the caller must check that
# the remainder is empty.
case Integer.parse("42") do
{number, ""} -> IO.puts(number)
_ -> IO.puts("not a number")
endmain! = |_args| {
# from_str is all-or-nothing: trailing text is
# a failure, not a remainder to inspect.
match I64.from_str("42") {
Ok(value) => echo!(value.to_str())
Err(_) => echo!("not a number")
}
Ok({})
}Integer.parse/1 returning a remainder rather than a result is a deliberate choice — it supports parsing a prefix, as in "42kg" — and it is also why {number, ""} is the pattern everyone actually writes. Roc's from_str has no prefix mode, so trailing text is a failure rather than a remainder to inspect.Processes & OTP — What Roc Lacks
There are no processes
Roc has no concurrency primitives in the language at all. A platform may provide them as effects, and the echo platform this page runs on provides only
echo!.parent = self()
spawn(fn -> send(parent, {:result, 6 * 7}) end)
receive do
{:result, value} -> IO.puts(value)
after
1000 -> IO.puts("timed out")
endmain! = |_args| {
# There is no spawn, no send, no receive and no
# mailbox. Concurrency is something a PLATFORM
# offers, and the echo platform offers none.
answer : I64
answer = 6 * 7
echo!(answer.to_str())
Ok({})
}This is the section where the comparison runs out rather than resolving, and it is the largest thing on the page. Processes are not a feature of Elixir the way pipes are — they are what the language is for, and everything from GenServer to Phoenix LiveView is built on them. Nothing here replaces that.
No GenServer, and no state in a process
Mutable state that outlives a function call has no home in Roc: a value is threaded through the functions that transform it, and the caller holds the current one.
defmodule CounterServer do
use GenServer
def init(count), do: {:ok, count}
def handle_call(:bump, _from, count),
do: {:reply, count + 1, count + 1}
end
{:ok, pid} = GenServer.start_link(CounterServer, 0)
GenServer.call(pid, :bump)
IO.puts(GenServer.call(pid, :bump))# State is a value threaded through functions.
bump : I64 -> I64
bump = |count| count + 1
main! = |_args| {
# No process to hold it, so the caller holds it.
count = 0 |> bump |> bump
echo!(count.to_str())
Ok({})
}That is the functional-core pattern Elixir programmers already write inside a GenServer — and the GenServer is the part that does not come across. What is lost is not the state handling but everything around it: a named process other code can find, a mailbox that serializes concurrent access, and a supervisor that can restart it.
Let it crash vs make it impossible
Roc has no exceptions and no way for a failure to leave a function other than as a return value. A function that can fail says so in its type, and every caller handles it.
# The happy path only. A bad value crashes this
# process; a supervisor restarts it from a known
# good state, and the rest of the system continues.
divide = fn numerator, denominator -> numerator / denominator end
IO.puts(divide.(10, 2))# The failure is in the type, so the caller cannot
# reach the number without deciding what a zero
# denominator means.
divide : I64, I64 -> Try(I64, [DivideByZero, ..])
divide = |numerator, denominator|
if denominator == 0 { Err(DivideByZero) } else { Ok(numerator // denominator) }
main! = |_args| {
match divide(10, 2) {
Ok(value) => echo!(value.to_str())
Err(_) => echo!("cannot divide by zero")
}
Ok({})
}These are opposite answers to the same question and both work. Elixir's bet is that you cannot anticipate every failure, so isolate it and recover; Roc's is that most failures can be made unrepresentable, so the ones left are worth handling explicitly. Elixir's bet pays off exactly where Roc has nothing — a long-running system with many independent activities, where something will go wrong that nobody typed.
No hot code loading, and no distribution
A Roc program is a compiled binary. There is no node, no distribution protocol and no mechanism for replacing a module while the program runs.
# Two things with no counterpart: a release can be
# upgraded while it runs, and Node.connect/1 makes
# a cluster that sends messages transparently.
IO.puts(inspect(Node.self()))main! = |_args| {
# A compiled binary with no node name, no
# cluster and no way to replace code in a
# running program.
echo!("nonode@nohost")
Ok({})
}Hot code loading and transparent distribution are the two things the BEAM does that almost nothing else does, and they are why telecoms built it. Neither is a gap Roc intends to fill — it is a different kind of program — but an Elixir programmer weighing the two should know they are not on the list. The columns differ by a colon because the Elixir one is printing a real atom and the Roc one a string: there is no node to ask.
Protocols & Behaviors
No protocols
Roc can require an operation — a
where clause names the methods a type variable must have, as the Generics section shows — but nothing can add one to a type afterwards, which is the half a protocol is for. So polymorphism over cases you did not write is a tag union listing them, with one function that handles them all.# A protocol dispatches on the type of its first
# argument, and any module can implement it later
# — including for a type you do not own.
IO.puts(to_string(42))
IO.puts(to_string(:an_atom))
IO.puts(to_string("already a string"))# No protocols and no dispatch. A union names the
# cases up front, and one function handles them.
describe : [Number(I64), Tag(Str), Text(Str)] -> Str
describe = |value| match value {
Number(number) => number.to_str()
Tag(name) => name
Text(text) => text
}
main! = |_args| {
echo!(describe(Number(42)))
echo!(describe(Tag("an_atom")))
echo!(describe(Text("already a string")))
Ok({})
}The two are open in opposite directions. A protocol lets a new type be added without touching the function; a union lets a new function be added without touching the types. Elixir's
String.Chars, Enumerable and Inspect are all the first kind, and a Roc program has to know its cases in advance.No behaviors
Where Elixir declares a set of callbacks a module must implement, Roc passes the functions themselves. The capability travels with the call rather than being declared against a module.
defmodule Greeter do
@callback greet(String.t()) :: String.t()
end
defmodule Polite do
@behaviour Greeter
@impl true
def greet(name), do: "Good day, #{name}"
end
IO.puts(Polite.greet("Ada"))# A behavior declaration is a function parameter. The caller
# supplies the implementation at the call site.
greet_with : (Str -> Str), Str -> Str
greet_with = |greeting, name| greeting(name)
main! = |_args| {
polite = |name| "Good day, ${name}"
echo!(greet_with(polite, "Ada"))
Ok({})
}A behavior declaration gives something a function parameter does not: the compiler warns when an implementing module is missing a callback, and the module can be named in configuration and swapped without touching call sites. That is how a GenServer, a Plug and an Ecto adapter are all wired, and it has no counterpart here.
One function, any type
A lowercase name in a type signature is a type variable:
a in List(a), a -> a stands for one type used consistently, so the element type and the fallback must agree.# Dynamic typing makes this free — and unchecked.
first_or = fn
[], fallback -> fallback
[head | _], _ -> head
end
IO.puts(first_or.([], 7))
IO.puts(first_or.([], "none"))# A type variable: one type, used consistently,
# checked at every call.
first_or : List(a), a -> a
first_or = |items, fallback| match items.first() {
Ok(value) => value
Err(_) => fallback
}
main! = |_args| {
empty_numbers : List(I64)
empty_numbers = []
empty_words : List(Str)
empty_words = []
echo!(first_or(empty_numbers, 7).to_str())
echo!(first_or(empty_words, "none"))
Ok({})
}Elixir gets this for free by not checking, and the difference shows in what each rejects.
first_or.([1, 2], "none") compiles and runs in Elixir, returning an integer from a call whose fallback was a string; in Roc it does not compile, because a cannot be both.Macros vs No Metaprogramming
No macros
Roc has no macro system: no
quote, no unquote, and no way for code to generate code. Every construct in the language is built into it.defmodule Unless do
# A macro receives the AST and returns AST. This
# is how if, unless, |> and defstruct are all
# defined — in Elixir, in Elixir.
defmacro unless_true(condition, do: body) do
quote do
if !unquote(condition), do: unquote(body)
end
end
end
require Unless
Unless.unless_true(false, do: IO.puts("ran"))main! = |_args| {
# There is no quote, no unquote and no macro.
# What a macro would build, you write.
condition : Bool
condition = Bool.False
if !condition {
echo!("ran")
} else {}
Ok({})
}Elixir is unusual in how much of itself is written as macros —
if, unless, defstruct and |> among them — and a whole ecosystem depends on that, Ecto schemas and Phoenix routers most visibly. Roc's answer is that the language should have the constructs; whether that scales to a DSL is a question it has not had to answer yet.No use, and no code injection
There is no
use and nothing that adds definitions to a module you wrote. Every function that exists is one somebody typed.# use calls a macro that injects code into the
# calling module — which is why GenServer gives you
# default callbacks you never wrote.
defmodule Injected do
defmacro __using__(_opts) do
quote do
def injected_greeting, do: "written by a macro"
end
end
end
defmodule Host do
use Injected
end
IO.puts(Host.injected_greeting())main! = |_args| {
# Nothing writes code into your module, so
# everything a function does is in its body.
greeting = "written by hand"
echo!(greeting)
Ok({})
}use GenServer injecting default callbacks is the single most convenient thing in Elixir and the single hardest to read, because the module's behavior is not in the module. Removing it removes both halves, and an Elixir programmer will feel the loss before they feel the gain.Effects & the ! Marker
A function with no ! cannot print
A function whose name has no
! cannot perform an effect. The body above could not print even if it wanted to — the compiler rejects the call, not the output.# Nothing in this function's shape rules out
# printing, opening a file or spawning a process.
total = fn numbers ->
IO.puts("(logging from inside)")
Enum.sum(numbers)
end
IO.puts(total.([1, 2, 3]))# No ! in the name, so the body CANNOT call echo!
# — the compiler rejects the call. All this can do
# is compute.
total : List(I64) -> I64
total = |numbers| numbers.sum()
main! = |_args| {
echo!(total([1, 2, 3]).to_str())
Ok({})
}Elixir uses
! for something else entirely — "this raises instead of returning a tuple" — so the character is familiar and the meaning is not. What Roc's marker buys is a signature that is a guarantee: no ! means no reading, no writing, no printing, checked.Effects color a function
A name ending in
! performs effects, and only another ! function may call it. The marker spreads up the call chain to main!, which is the same shape async has in other languages.# Any function may call IO.puts, so there is no
# boundary between the pure part of a program and
# the part that talks to the world.
announce = fn message -> IO.puts(message) end
announce.("starting")# Only a ! function may call a ! function, so the
# boundary is drawn by the compiler rather than by
# convention.
announce! = |message| {
echo!(message)
Ok({})
}
main! = |_args| {
announce!("starting")?
Ok({})
}Elixir draws this boundary by discipline — a functional core with effects at the edges is the pattern every Elixir book teaches — and nothing enforces it. Roc enforces it and pays the usual price of colored functions: a pure helper that later needs to log must be renamed, and so must everything that calls it.
The BEAM vs No Runtime
No VM to start
A Roc program is compiled to a native binary and starts the way any binary does. There is no virtual machine, no module loading and no scheduler.
# Before this line ran: the BEAM started, loaded
# the standard library, and began a scheduler per
# core. Startup is measured in hundreds of ms.
IO.puts("running")main! = |_args| {
# A compiled native binary. No VM, no bytecode
# to load, no schedulers to start.
echo!("running")
Ok({})
}That matters for a command-line tool and matters much less for a server that starts once and runs for a year — which is what the BEAM was built for. The comparison is not "faster"; it is that the BEAM's startup cost buys preemptive scheduling, per-process heaps and observability that a native binary does not have.
Reference counting, not per-process heaps
Roc frees a value by decrementing a count the compiler inserted while compiling, at the point the value stops being used. Nothing scans the heap.
numbers = List.duplicate(1, 1000)
IO.puts(length(numbers))
# Each process has its own heap and is collected
# independently, so a collection pauses ONE process
# rather than the system.
IO.puts("no global pause")main! = |_args| {
numbers = List.repeat(1.I64, 1000)
echo!(numbers.len().to_str())
# The compiler inserted the decrement that frees
# this list, at the last line that uses it.
echo!("no global pause")
Ok({})
}The BEAM already solved the pause problem differently and well: per-process heaps mean a collection stops one process, which is why the BEAM has predictable latency under load. Roc's answer is no collection at all, which is stronger for one program and has nothing to say about ten thousand concurrent ones.
No introspection at run time
A Roc program has no run-time reflection — no way to enumerate modules, inspect a value's type, or ask the program about its own state.
# The BEAM can be inspected while it runs: every
# process, its mailbox, its memory, its stack.
IO.puts(inspect(Process.info(self(), :message_queue_len)))main! = |_args| {
# There is nothing to inspect: no processes, no
# module list, no way to ask a running program
# about itself.
echo!("{message_queue_len, 0}")
Ok({})
}Observer,
:recon and remote-shelling into a production node are among the strongest arguments for the BEAM, and this is the counterweight to the type system: Roc moves knowledge to compile time and has correspondingly less available at run time. Which you want depends on whether your hard problems happen before or after deployment. The columns differ by one colon, because the Elixir side is printing a real answer and the Roc side a string standing in for one it cannot get.Twenty-five years against pre-1.0
Roc is pre-1.0. The examples on this page were verified against one pinned nightly build, and the language has changed underneath pages like this one more than once.
# Erlang has been in production since 1998 and
# Elixir since 2012. The semantics below have not
# changed in that time.
IO.puts("still runs")main! = |_args| {
# Roc is pre-1.0. Its syntax and standard
# library move week to week, and every example
# on this page is pinned to one nightly build.
echo!("still runs")
Ok({})
}Elixir has had a stable core for a decade on a VM with a decade and a half more behind it, and the ecosystem to match. That is worth weighing against everything else on this page — a language is not only its design, and the Processes section already named what a rewrite would be giving up.
Gotchas for Elixir Programmers
An unannotated number prints with a decimal point
A number literal with nothing to constrain it defaults to
Dec, Roc's exact decimal type, and Dec always prints a decimal point. Annotating the binding or suffixing with .I64 fixes it.count = 6
IO.puts(count)main! = |_args| {
# A bare literal with nothing to constrain it
# becomes Dec, and Dec prints a decimal point.
loose = 6
echo!(loose.to_str())
# Annotate or suffix to fix the type.
exact : I64
exact = 6
echo!(exact.to_str())
Ok({})
}This is the first thing that surprises nearly everyone, and it surprises quietly — the arithmetic is exact and correct, but
6 prints as 6.0. Any example whose printed form matters should say which number type it means.A name cannot be rebound
A plain binding is fixed once. Accumulating needs
var and a name beginning with $, and the sigil is part of the name at every use.total = 0
total = total + 1
total = total + 2
IO.puts(total)main! = |_args| {
# Rebinding is a compile error, and the message
# names SELF-REFERENCE rather than mutation:
# total = total + 1 reads as a definition whose
# body mentions itself.
var $total = 0.I64
$total = $total + 1
$total = $total + 2
echo!($total.to_str())
Ok({})
}This catches Elixir programmers more than anyone, because rebinding is so ordinary there and looks identical. The idiomatic Roc version is usually a
fold; var exists for when a loop genuinely reads better.A bare True is a Bool, until it meets another tag
A bare
True or False is a Bool, and !, and and or work on it with no annotation. It stops being one when it shares a value with a tag Bool does not have: if ready { True } else { Maybe } produces the tag union [Maybe, True, ..], and ! rejects that because it is not a Bool.flag = false
IO.puts(!flag)main! = |_args| {
# A bare False is a Bool; nothing needs annotating.
ready = False
echo!(Str.inspect(!ready))
# Mix True with a tag Bool does not have, and the
# result is a union of tags rather than a Bool:
# answer = if ready { True } else { Maybe }
# !answer <- type mismatch: [Maybe, True, ..]
# is not Bool
Ok({})
}Elixir readers will recognize the idea:
true and false there are atoms, and a Roc tag is the same kind of thing. Roc's True and False are the tags of its Bool type, so they behave as booleans until a tag from outside that type joins them in the same value.Dot syntax does not reach a free function
Dot syntax reaches the methods a type declares —
numbers.len() works because List declares len, and a nominal type you declare yourself can carry its own, shown in the Structs vs Records section. What it never reaches is a free function like this one, which is called prefix or piped with |>.# Elixir has no method syntax at all, so this
# question does not arise: everything is
# Module.function(value) or value |> function().
IO.puts(String.upcase("hi"))
IO.puts("hi" |> String.upcase())shout : Str -> Str
shout = |text| "${text}!"
main! = |_args| {
# "hi".shout() does NOT work — dot syntax reaches
# the methods a type declares, and Str declares no
# shout. You cannot add one to a type you did not
# declare, so it is prefix, or |>.
echo!(shout("hi"))
echo!("hi" |> shout)
Ok({})
}An Elixir programmer is the least likely reader to be caught by this, because
|> is already the habit and method syntax was never on offer. The trap is the other way round: seeing numbers.len() in the standard library and assuming your own functions work the same way.An annotated error type has to stay open
When you annotate a function's error type, end the tag union with
.. to leave it open. A closed error type cannot unify with the one the platform's main! declares, and ? stops compiling.# Elixir has no error type in a signature at all,
# so there is no analogue of this to get wrong.
parse = fn text ->
case Integer.parse(text) do
{number, rest} when rest == "" -> {:ok, number}
_ -> {:error, :bad_number}
end
end
{:ok, value} = parse.("17")
IO.puts(value)# The .. matters: Try(I64, [BadNumStr]) — closed —
# will not unify with the error type main! returns,
# and the ? below stops compiling.
parse : Str -> Try(I64, [BadNumStr, ..])
parse = |text| I64.from_str(text)
main! = |_args| {
echo!(parse("17")?.to_str())
Ok({})
}Leaving the annotation off works too, since an inferred error type is open already. The failure mode is worth recognizing: the message names a payload type rather than the annotation, so it points at the
? and not at the signature that caused it.