PONY λ M2 Modula-2

Elixir.CodeCompared.To/Erlang

An interactive executable cheatsheet comparing Elixir and Erlang

Elixir 1.17 Erlang/OTP 26
Reading Erlang
Hello, World
IO.puts("Hello, World!")
io:format("Hello, World!~n").
Modules are lowercase and called with a colon (io:formatIO.puts); output goes through format strings, where ~n is the newline. The trailing period ends the expression sequence — Erlang’s most famous piece of punctuation.
Commas, semicolons, periods
numbers = [1, 2, 3] total = Enum.sum(numbers) IO.puts(total)
Numbers = [1, 2, 3], Total = lists:sum(Numbers), io:format("~p~n", [Total]).
The grammar Elixir redesigned away, inherited from Prolog: commas separate expressions in a sequence, semicolons separate clauses (visible in case and multi-clause funs below), and a period ends the whole form. The content of this example is otherwise identical line-for-line.
Capitalized variables, bare atoms
status = :active retries = 3 IO.inspect({status, retries})
Status = active, Retries = 3, io:format("~p~n", [{Status, Retries}]).
The visual convention inverts: variables are Capitalized, and atoms are bare lowercase words — no colon sigil. Elixir needed the :atom sigil precisely because it gave lowercase names to variables instead. ~p pretty-prints any term, playing IO.inspect.
Single Assignment
No rebinding — ever
count = 1 count = count + 1 # rebinding is ordinary Elixir IO.puts(count)
Count = 1, NextCount = Count + 1, % Count = Count + 1 would crash: no match io:format("~p~n", [NextCount]).
Erlang variables are single-assignment for real: once bound, = against a different value is a badmatch crash. Elixir’s rebinding is compiler sugar over this same VM — Erlang makes you name every intermediate value, which is why real Erlang code is full of State1/NewState chains.
No pin operator — everything is pinned
expected = :ok {^expected, value} = {:ok, 42} # ^ opts INTO match-against-value IO.inspect(value)
Expected = ok, {Expected, Value} = {ok, 42}, % a bound variable always matches its value io:format("~p~n", [Value]).
The ^ pin does not exist because it is never needed: a bound Erlang variable in a pattern always means "match my current value" — the behavior Elixir makes you opt into. Only unbound variables bind.
Strings: Two Kinds
"hello" is a list of integers
text = "hello" # a UTF-8 binary IO.inspect(is_binary(text)) IO.inspect(~c"hello") # the charlist, behind a sigil
Text = "hello", % a LIST of integers Binary = <<"hello">>, % what Elixir calls a string io:format("~w~n", [Text]), % ~w shows the raw term io:format("~s ~s~n", [Text, Binary]).
The single biggest trap on this page: double quotes make a charlist in Erlang — [104,101,108,108,111] — while Elixir’s string is the binary <<"hello">>. Older Erlang APIs traffic in charlists, newer ones in binaries; Elixir’s ~c sigil exists exactly for this boundary.
No interpolation — format directives
name = "Ada" IO.puts("hello #{name}") IO.inspect(%{name: name}, label: "debug")
Name = <<"Ada">>, io:format("hello ~s~n", [Name]), io:format("debug: ~p~n", [#{name => Name}]).
There is no #{} interpolation — io:format takes a format string and an argument list. The directives to know: ~s (string), ~p (pretty-print any term — the workhorse), ~w (raw term), ~n (newline).
<> is binary construction
name = "Ada" greeting = "hello " <> name IO.puts(greeting) IO.inspect(byte_size(greeting))
Name = <<"Ada">>, Greeting = <<"hello ", Name/binary>>, io:format("~s~n", [Greeting]), io:format("~p~n", [byte_size(Greeting)]).
Elixir’s <> concatenation is sugar over Erlang’s binary-construction syntax: <<"hello ", Name/binary>> splices one binary into another, with /binary declaring the segment type. byte_size is the same auto-imported BIF in both.
Maps, Tuples & Records
Maps — the same maps
person = %{name: "Ada", age: 36} %{name: name} = person IO.puts(name) IO.inspect(%{person | age: 37})
Person = #{name => "Ada", age => 36}, #{name := Name} = Person, io:format("~s~n", [Name]), io:format("~p~n", [Person#{age := 37}]).
Elixir maps are Erlang maps — one VM type. %{key => value} is #{key => value}; := in a pattern or update means "this key must already exist" (the update behavior of Elixir’s %{map | key: value}); and Person#{…} is the update syntax.
{ok, _} — the convention came from here
result = {:ok, 42} case result do {:ok, value} -> IO.puts("got #{value}") {:error, reason} -> IO.inspect(reason) end
Result = {ok, 42}, case Result of {ok, Value} -> io:format("got ~p~n", [Value]); {error, Reason} -> io:format("~p~n", [Reason]) end.
The {:ok, _}/{:error, _} convention is Erlang’s, inherited whole — just drop the colons. Note case … of and the semicolon between clauses; the final clause takes no separator.
Structs → records
defmodule Person do defstruct name: "", age: 0 end person = %Person{name: "Ada", age: 36} IO.puts(person.name)
% A record is a compile-time construct — under the hood it is a % plain tagged tuple {person, "Ada", 36}, with the -record % attribute giving names to positions: -record(person, {name = "", age = 0}). birthday() -> Person = #person{name = "Ada", age = 36}, io:format("~s~n", [Person#person.name]).
Erlang’s record is what Elixir’s struct replaced: a preprocessor-era construct that names tuple positions at compile time (a struct, by contrast, is a real map at runtime). Records require module context, so this cell is display-only on this page’s shell evaluator. Modern Erlang increasingly just uses maps.
Functions
defmodule → -module
defmodule Geometry do def area(width, height), do: width * height end IO.puts(Geometry.area(3, 4))
-module(geometry). -export([area/2]). area(Width, Height) -> Width * Height.
A module file opens with attributes: -module (which must match the filename) and -export, the public-function list — Erlang’s version of the def/defp split. Function heads use -> and end with a period. Module definitions cannot run at the shell-expression level this page evaluates, so this cell is display-only.
fn → fun (and no dot to call)
double = fn number -> number * 2 end IO.puts(double.(21)) classify = fn number when number < 0 -> :negative 0 -> :zero _ -> :positive end IO.inspect(classify.(-5))
Double = fun(Number) -> Number * 2 end, io:format("~p~n", [Double(21)]), Classify = fun(Number) when Number < 0 -> negative; (0) -> zero; (_) -> positive end, io:format("~p~n", [Classify(-5)]).
fun … end is fn … end, with semicolons between clauses — and calling a variable-held fun needs no dot: Double(21), not double.(21). (Elixir’s dot exists because its variables and named functions share a flat namespace; Erlang keeps them apart.)
&Module.function/1 → fun module:function/1
IO.inspect(Enum.map([1, 2, 3], &(&1 * 10))) IO.inspect(Enum.map([-1, -2, -3], &abs/1))
io:format("~p~n", [lists:map(fun(Number) -> Number * 10 end, [1, 2, 3])]), io:format("~p~n", [lists:map(fun erlang:abs/1, [-1, -2, -3])]).
Elixir’s capture operator came straight from Erlang’s fun module:function/arity. The shorthand &(&1 * 10) has no Erlang equivalent — a full fun is always written out.
lists ≈ Enum (Arguments Flipped)
Enum → lists, arguments flipped
numbers = [1, 2, 3, 4] IO.inspect(Enum.filter(numbers, fn number -> rem(number, 2) == 0 end)) IO.inspect(Enum.map(numbers, fn number -> number * number end))
Numbers = [1, 2, 3, 4], io:format("~p~n", [lists:filter(fun(Number) -> Number rem 2 =:= 0 end, Numbers)]), io:format("~p~n", [lists:map(fun(Number) -> Number * Number end, Numbers)]).
The lists module is Enum for lists — but the argument order flips: the fun comes FIRST, the list last. Elixir reversed it so the collection could ride the pipe; Erlang has no pipe, so nested calls or intermediate variables do that job. rem is an infix operator here, not a function.
reduce → foldl
total = Enum.reduce([1, 2, 3, 4], 0, fn number, sum -> sum + number end) IO.inspect(total)
Total = lists:foldl(fun(Number, Sum) -> Sum + Number end, 0, [1, 2, 3, 4]), io:format("~p~n", [Total]).
Enum.reduce/3 is lists:foldl/3 (fold from the left; foldr exists too), with the order Fun, Accumulator, List. The callback’s parameters match Elixir’s: element first, accumulator second.
for → [Expr || Gen, Filter]
squares = for number <- 1..5, rem(number, 2) == 1, do: number * number IO.inspect(squares)
Squares = [Number * Number || Number <- lists:seq(1, 5), Number rem 2 =:= 1], io:format("~p~n", [Squares]).
Elixir’s for is Erlang’s list comprehension with the parts rearranged — [Expression || Generator, Filter]. One real gap: Erlang has no range type, so 1..5 becomes lists:seq(1, 5).
Control Flow
case — nearly identical
number = 15 description = case number do 0 -> "zero" value when rem(value, 5) == 0 -> "multiple of five" _ -> "ordinary" end IO.puts(description)
Number = 15, Description = case Number of 0 -> "zero"; Value when Value rem 5 =:= 0 -> "multiple of five"; _ -> "ordinary" end, io:format("~s~n", [Description]).
The construct Elixir kept nearly unchanged: case … of with pattern clauses, when guards, and a value in every branch. Only the punctuation differs — semicolons between clauses, no do.
Erlang’s if IS Elixir’s cond
temperature = 30 description = cond do temperature > 25 -> "hot" temperature > 15 -> "mild" true -> "cold" end IO.puts(description)
Temperature = 30, Description = if Temperature > 25 -> "hot"; Temperature > 15 -> "mild"; true -> "cold" end, io:format("~s~n", [Description]).
Erlang’s if is a guard sequence with a true catch-all — exactly Elixir’s cond, which is why Elixir’s cond looks the way it does. Two constraints to remember: the conditions must be guard expressions (no arbitrary function calls), and there is no with, unless, or two-branch if/else sugar.
Operators & Equality
=== → =:=
IO.inspect(1 == 1.0) # true — numeric comparison IO.inspect(1 === 1.0) # false — exact equality IO.inspect(1 != 2) IO.inspect(1 !== 1.0)
io:format("~p~n", [1 == 1.0]), % true — numeric comparison io:format("~p~n", [1 =:= 1.0]), % false — exact term equality io:format("~p~n", [1 /= 2]), io:format("~p~n", [1 =/= 1.0]).
Same two-tier equality, different spellings: === is =:=, !== is =/=, and != is /=. The semantics carried over to Elixir unchanged, so nothing new to learn — only new punctuation to read.
and/or → andalso/orelse
IO.inspect(true and false) IO.inspect(1 < 2 or 3 < 2) IO.inspect(nil || "default") # || accepts any value (truthiness)
io:format("~p~n", [true andalso false]), % short-circuit io:format("~p~n", [(1 < 2) orelse (3 < 2)]), io:format("~p~n", [true and false]). % and/or evaluate BOTH sides
The short-circuit pair is andalso/orelse; Erlang’s bare and/or evaluate both operands. Nothing corresponds to ||/&& because Erlang has no truthiness at all — only true and false are booleans, and there is no nil (the bare atom undefined is the convention).
Error Handling
rescue → catch Class:Reason
result = try do raise "boom" rescue error in RuntimeError -> "rescued: #{error.message}" end IO.puts(result)
Result = try error(boom) catch error:boom -> "caught error:boom" end, io:format("~s~n", [Result]).
Erlang exposes the raw machinery Elixir’s rescue sugars over: three error classes — throw:, error:, exit: — caught by Class:Reason patterns (a third position, Class:Reason:Stacktrace, binds the trace). Elixir’s raise is an error carrying an exception struct.
No with — the staircase
parse = fn text -> try do {:ok, String.to_integer(text)} rescue ArgumentError -> {:error, :not_a_number} end end with {:ok, value} <- parse.("42") do IO.puts(value) end
Parse = fun(Text) -> try {ok, list_to_integer(Text)} catch error:badarg -> {error, not_a_number} end end, case Parse("42") of {ok, Value} -> io:format("~p~n", [Value]); {error, Reason} -> io:format("~p~n", [Reason]) end.
Erlang has no with: chaining fallible steps means nested case expressions — the "staircase" that with was invented to flatten. (Erlang/OTP 25 finally added its own answer, the maybe expression, but the staircase still dominates existing code.)
Processes — Shared Ground
send → the ! operator
parent = self() spawn(fn -> send(parent, {:result, 6 * 7}) end) receive do {:result, value} -> IO.puts("got #{value}") end
Parent = self(), spawn(fun() -> Parent ! {result, 6 * 7} end), receive {result, Value} -> io:format("got ~p~n", [Value]) end.
Identical model, same VM, same guarantees — this is the shared ground everything else stands on. The only translation: Elixir’s send(pid, message) is Erlang’s Pid ! Message operator, and receive drops the do.
Process.monitor → monitor/2
worker = spawn(fn -> receive do :crash -> exit(:boom) end end) reference = Process.monitor(worker) send(worker, :crash) receive do {:DOWN, ^reference, :process, _pid, reason} -> IO.inspect(reason) end
Worker = spawn(fun() -> receive crash -> exit(boom) end end), Reference = monitor(process, Worker), Worker ! crash, receive {'DOWN', Reference, process, _Pid, Reason} -> io:format("~p~n", [Reason]) end.
Process.monitor/1 is a thin wrapper over monitor(process, Pid), and the DOWN message has the same five-tuple shape. One reading note: 'DOWN' needs single quotes in Erlang, because an unquoted capitalized word would be a variable. Links, trap_exit, and everything OTP builds from them translate the same way.
Macros & OTP Contracts
defmacro → -define (a preprocessor)
defmodule Answers do defmacro double(expression) do quote do unquote(expression) * 2 end end end # Callers `require Answers` and get an AST transformation, # with the full language available at compile time.
-define(DOUBLE(Expression), ((Expression) * 2)). usage() -> io:format("~p~n", [?DOUBLE(21)]).
Erlang’s -define is C-style textual substitution?MACRO pastes tokens, with all the parenthesization hazards that implies. Elixir macros are a different species: quote/unquote AST transformation with the whole language available, the machinery Elixir itself is built from (if, |>, and def are macros). Both cells need module context, so this row is display-only.
use GenServer → gen_server callbacks
defmodule Counter do use GenServer def init(initial), do: {:ok, initial} def handle_call(:value, _from, state) do {:reply, state, state} end end {:ok, process} = GenServer.start_link(Counter, 41) IO.inspect(GenServer.call(process, :value))
-module(counter). -behavior(gen_server). -export([init/1, handle_call/3]). init(Initial) -> {ok, Initial}. handle_call(value, _From, State) -> {reply, State, State}.
OTP is the shared inheritance: GenServer is gen_server, and the callbacks have the same names and return shapes because they are the same contract, declared with the attribute shown (whose canonical OTP spelling ends in -our; the compiler accepts the American spelling too). Elixir’s use GenServer additionally injects default callback implementations — Erlang modules write out each one. Module definitions are display-only on this page’s shell evaluator.
Stdlib & Tooling
You already call this stdlib
IO.inspect(:lists.reverse([1, 2, 3])) IO.inspect(:maps.keys(%{name: "Ada"})) IO.inspect(:erlang.tuple_size({:ok, 1, 2}))
io:format("~p~n", [lists:reverse([1, 2, 3])]), io:format("~p~n", [maps:keys(#{name => <<"Ada">>})]), io:format("~p~n", [tuple_size({ok, 1, 2})]).
Every :module.function call in Elixir is a plain Erlang call — the colon-atom prefix is the entire interop story, with zero overhead. Reading Erlang’s stdlib documentation pays off immediately in Elixir; the reverse direction works too (an Elixir module is the atom 'Elixir.Enum' from Erlang’s side).
iex → erl
# $ iex # iex(1)> greeting = "hello" # "hello" # iex(2)> String.upcase(greeting) # "HELLO" # iex(3)> h String.upcase # docs in the shell
% $ erl % 1> Greeting = "hello". % note the PERIOD to evaluate % "hello" % 2> string:uppercase(Greeting). % "HELLO" % 3> f(Greeting). % f/1 "forgets" a binding % 4> q(). % quit — also a function call
The Erlang shell wants a period to evaluate anything — the classic first-day stumble. Since variables cannot rebind, f(Variable) explicitly forgets bindings during experimentation, and even quitting is a function call, q(). Both cells are terminal transcripts, shown display-only.
mix → rebar3
# mix.exs defp deps do [ {:jason, "~> 1.4"}, {:telemetry, "~> 1.2"} ] end # $ mix deps.get && mix test
%% rebar.config {deps, [ {jsx, "3.1.0"}, {telemetry, "1.2.1"} ]}. %% $ rebar3 compile && rebar3 eunit
rebar3 is the mix of the Erlang world — rebar.config is Erlang terms rather than Elixir code, and both pull from the same package registry: Hex serves both communities. Both cells are configuration fragments, shown display-only.