Basics & Dynamic Typing
Hello, World
Elm renders a pure
main value; Elixir prints with an ordinary side-effecting function call, anywhere it likes.module Main exposing (main)
import Html exposing (text)
main =
text "Hello, World!"
IO.puts("Hello, World!") Elixir will feel familiar at the data level — everything is immutable, like Elm — but effects are not fenced off:
IO.puts runs mid-expression, mid-function, wherever. The discipline Elm's runtime enforces becomes a convention you keep.The type checker stays home
The biggest single change: Elixir is dynamically typed. There are no annotations to satisfy, and type errors arrive at runtime — usually as a pattern-match crash.
module Main exposing (main)
import Html exposing (text)
double : Int -> Int
double number =
number * 2
main =
text (String.fromInt (double 21))
defmodule Math do
@spec double(integer()) :: integer()
def double(number), do: number * 2
end
IO.puts(Math.double(21)) The
@spec line is optional documentation checked by an external tool (Dialyzer), not the compiler — nothing stops a caller passing a string until the multiplication fails at runtime. Elixir's answer to "if it compiles, it works" is different machinery: pattern matching that crashes early, and supervisors that restart what crashed.Pattern Matching & =
= is a pattern match
Elixir's
= is not assignment — it is the match operator, the same idea as an Elm case pattern, usable on any line.module Main exposing (main)
import Html exposing (text)
main =
let
( width, height ) =
( 3, 4 )
in
text (String.fromInt (width * height))
{width, height} = {3, 4}
IO.puts(width * height)
{:ok, value} = {:ok, 42}
IO.puts(value) Anything Elm can destructure in a
let, Elixir destructures with = — and more: matching {:ok, value} against a tagged tuple both asserts the tag and binds the value in one stroke. If the match fails, the process crashes with a MatchError, which is Elixir's runtime version of the exhaustiveness Elm checks at compile time.Head and tail
Elm can only take a list apart inside
case. Elixir's [head | tail] pattern works anywhere a match does.module Main exposing (main)
import Html exposing (text)
describe : List Int -> String
describe numbers =
case numbers of
first :: rest ->
String.fromInt first ++ " then " ++ Debug.toString rest
[] ->
"empty"
main =
text (describe [ 1, 2, 3 ])
[first | rest] = [1, 2, 3]
IO.puts(first)
IO.inspect(rest) The cons cell is spelled
[head | tail] rather than head :: tail, and because = matches anywhere, no case ceremony is needed when you already know the shape. IO.inspect is Elixir's Debug.toString-and-print in one call.Functions & Clauses
Every function lives in a module
module Main exposing (main)
import Html exposing (text)
greet : String -> String
greet name =
"Hello, " ++ name ++ "!"
main =
text (greet "Elm")
defmodule Greeter do
def greet(name) do
"Hello, #{name}!"
end
end
IO.puts(Greeter.greet("Elixir")) Elixir has no free-floating named functions:
def only works inside a defmodule, and calls are Module.function(arguments) with parentheses. The one-line form def greet(name), do: "…" exists too, much like Elm keeping short functions on one line.case moved into the function head
Where Elm writes one function containing a
case, Elixir writes several clauses of the same function, each with its own pattern, tried top to bottom.module Main exposing (main)
import Html exposing (text)
factorial : Int -> Int
factorial number =
case number of
0 ->
1
_ ->
number * factorial (number - 1)
main =
text (String.fromInt (factorial 5))
defmodule Math do
def factorial(0), do: 1
def factorial(number), do: number * factorial(number - 1)
end
IO.puts(Math.factorial(5)) This is the signature Elixir idiom: the patterns that would be
case branches become function heads — factorial(0) and factorial(number). The compiler does not check the clauses are exhaustive (there is no type to be exhaustive over); a value no clause matches raises a FunctionClauseError at runtime.Anonymous functions & the capture operator
Elm has one lambda syntax. Elixir has two: the spelled-out
fn … end and the terse capture operator & with positional &1.module Main exposing (main)
import Html exposing (text)
main =
text (Debug.toString (List.map (\number -> number * 3) [ 1, 2, 3 ]))
IO.inspect(Enum.map([1, 2, 3], fn number -> number * 3 end))
IO.inspect(Enum.map([1, 2, 3], &(&1 * 3)))
triple = fn number -> number * 3 end
IO.puts(triple.(7)) Two things to absorb:
&(&1 * 3) is the shorthand Elm never grew, and calling a function value needs a dot — triple.(7), not triple(7). The dot marks "this name holds a function" versus "this is a module function," a distinction Elm does not have.No currying
In Elm every function is curried and partial application is free. Elixir functions take all their arguments at once — partial application is an explicit capture.
module Main exposing (main)
import Html exposing (text)
add : Int -> Int -> Int
add first second =
first + second
addTen : Int -> Int
addTen =
add 10
main =
text (String.fromInt (addTen 5))
add = fn first, second -> first + second end
add_ten = &add.(10, &1)
IO.puts(add_ten.(5)) Calling
add.(10) is an arity error, not a partially applied function — Elixir identifies functions by name and arity (add/2). The capture &add.(10, &1) builds the one-argument function Elm would have given you for free. This is the deepest habit change for an Elm developer, and it is also why Elixir's pipe passes the first argument (see the next section).The Pipe Operator
The pipe feeds the FIRST argument
Both languages love
|>, but they aim at opposite ends of the argument list: Elm's pipe fills the last argument, Elixir's fills the first.module Main exposing (main)
import Html exposing (text)
main =
[ 1, 2, 3, 4, 5, 6 ]
|> List.filter (\number -> modBy 2 number == 0)
|> List.map (\number -> number * number)
|> List.sum
|> String.fromInt
|> text
[1, 2, 3, 4, 5, 6]
|> Enum.filter(fn number -> rem(number, 2) == 0 end)
|> Enum.map(fn number -> number * number end)
|> Enum.sum()
|> IO.puts() The pipelines read identically, but the mechanics differ:
list |> Enum.map(function) becomes Enum.map(list, function) — subject first — while Elm's list |> List.map function works because List.map function is a curried function still waiting for its last argument. Each design fits its language: data-first suits uncurried functions, data-last suits curried ones.Strings & Interpolation
String interpolation
Elm concatenates with
++ and converts every number by hand. Elixir interpolates any expression with #{…}.module Main exposing (main)
import Html exposing (text)
name : String
name =
"world"
main =
text ("Hello, " ++ name ++ "! " ++ String.fromInt (6 * 7))
name = "world"
IO.puts("Hello, #{name}! #{6 * 7}") Interpolation calls
to_string on the result, so numbers need no String.fromInt chaperone. Elixir's concatenation operator is <> rather than ++ (which Elixir reserves for lists) — a small vocabulary swap.The String module twin
module Main exposing (main)
import Html exposing (text)
greeting : String
greeting =
"hello world"
main =
text (String.toUpper greeting ++ " / " ++ String.fromInt (String.length greeting))
greeting = "hello world"
IO.puts(String.upcase(greeting))
IO.puts(String.length(greeting))
IO.puts(String.replace(greeting, "world", "Elixir")) The module-of-functions style is identical —
String.toUpper answers to String.upcase, and both languages treat strings as UTF-8 with String.length counting graphemes, not bytes. Coming from Elm, the Elixir standard library will feel organized the way you expect.Lists, Tuples & Maps
Lists & cons
module Main exposing (main)
import Html exposing (text)
main =
text (Debug.toString (1 :: [ 2, 3 ]))
IO.inspect([1 | [2, 3]])
IO.inspect([1, 2, 3] ++ [4, 5]) Both are immutable singly-linked lists with the same costs: prepending is cheap, appending walks the list. Elm's
:: is Elixir's [head | tail], and Elixir's ++ concatenates lists (string concatenation moved to <>).List.* becomes Enum.*
Elixir gathers its mapping and filtering into one
Enum module that works on every enumerable — lists, ranges, and maps alike.module Main exposing (main)
import Html exposing (text)
main =
List.range 1 5
|> List.map (\number -> number * 10)
|> Debug.toString
|> text
1..5
|> Enum.map(fn number -> number * 10 end)
|> IO.inspect()
IO.inspect(Enum.reduce([1, 2, 3, 4], 0, fn number, total -> number + total end)) Where Elm has
List.map, Dict.map, and Array.map as separate modules, Elixir has one Enum.map for anything enumerable — here fed by the range literal 1..5. Elm's List.foldl is Enum.reduce, with the accumulator as the lambda's second argument.Comprehensions
Elm expresses filter-then-map as a pipeline. Elixir also has a
for comprehension that does both in one expression.module Main exposing (main)
import Html exposing (text)
main =
List.range 1 10
|> List.filter (\number -> modBy 2 number == 0)
|> List.map (\number -> number * number)
|> Debug.toString
|> text
squares = for number <- 1..10, rem(number, 2) == 0, do: number * number
IO.inspect(squares) The generator
number <- 1..10, the filter rem(number, 2) == 0, and the do: body compress Elm's two pipeline stages into one line — the comprehension syntax Elm (following Haskell's lead but not this far) never adopted. The pipeline version works in Elixir too; the comprehension is just often preferred.Tuples
module Main exposing (main)
import Html exposing (text)
point : ( Int, String )
point =
( 1, "one" )
main =
text (String.fromInt (Tuple.first point) ++ " is " ++ Tuple.second point)
point = {1, "one"}
{number, name} = point
IO.puts("#{number} is #{name}")
IO.puts(elem(point, 0)) Elixir tuples wear curly braces and have no arity limit — Elm stops at three elements and steers you to records beyond that. Destructuring with
= replaces Tuple.first/Tuple.second; elem/2 exists for positional access when a match is overkill.Maps are first-class literals
Elm's
Dict is a library type built with Dict.fromList. Elixir maps have literal syntax, pattern matching, and an update syntax of their own.module Main exposing (main)
import Dict
import Html exposing (text)
main =
let
ages =
Dict.fromList [ ( "Ada", 36 ), ( "Grace", 45 ) ]
in
text (Debug.toString (Dict.get "Ada" ages)) ages = %{"Ada" => 36, "Grace" => 45}
IO.inspect(Map.get(ages, "Ada"))
%{"Ada" => ada_age} = ages
IO.puts(ada_age) The literal
%{key => value} needs no constructor call, and a map pattern — %{"Ada" => ada_age} — pulls a value out by matching, something Dict cannot do. Maps with atom keys get the sweeter %{name: "Ada"} spelling, which structs build on next.Structs vs Records
Structs are named maps
Elm's record with a
type alias becomes a struct: a map with a declared key set and a name, checked at compile time where possible.module Main exposing (main)
import Html exposing (text)
type alias Person =
{ name : String
, age : Int
}
person : Person
person =
{ name = "Ada", age = 36 }
main =
text (person.name ++ " is " ++ String.fromInt person.age)
defmodule Person do
defstruct name: "", age: 0
end
person = %Person{name: "Ada", age: 36}
IO.puts("#{person.name} is #{person.age}") A struct literal
%Person{…} rejects unknown keys at compile time — the closest Elixir gets to Elm's record typing — and dot access works the same. Unlike Elm records, structs carry their name at runtime, so pattern matching can dispatch on %Person{} versus some other struct.The update syntax you already know
Elm writes
{ person | age = 37 }. Elixir writes %{person | age: 37} — the same pipe-in-braces idea, copy-with-changes semantics included.module Main exposing (main)
import Html exposing (text)
type alias Person =
{ name : String
, age : Int
}
person : Person
person =
{ name = "Ada", age = 36 }
older : Person
older =
{ person | age = 37 }
main =
text (older.name ++ " is now " ++ String.fromInt older.age)
defmodule Person do
defstruct name: "", age: 0
end
person = %Person{name: "Ada", age: 36}
older = %{person | age: 37}
IO.puts("#{older.name} is now #{older.age}")
IO.puts("#{person.name} is still #{person.age}") The visual rhyme is no accident — both syntaxes descend from the same functional-record tradition. Both build a new value sharing the unchanged fields; the original is untouched, as the second print shows. The update form also raises if the key does not already exist, mirroring Elm's rule that update cannot add fields.
Atoms & Tagged Tuples
Atoms: constructors without the type
Elixir atoms —
:ok, :error, :north — are constant names that exist without any type declaration, playing the role of Elm's nullary constructors.module Main exposing (main)
import Html exposing (text)
type Direction
= North
| South
describe : Direction -> String
describe direction =
case direction of
North ->
"up the map"
South ->
"down the map"
main =
text (describe North)
describe = fn
:north -> "up the map"
:south -> "down the map"
end
IO.puts(describe.(:north)) No
type Direction exists — the atoms are just values, and nothing but convention stops a caller passing :sideways (which would crash with no matching clause). That is the trade running through this whole page: Elm declares the closed set and the compiler enforces it; Elixir matches on the open set and crashes loudly on surprises.{:ok, value} is Result by convention
Elm's
Result is a declared type. The entire Elixir ecosystem uses the same shape as a convention: {:ok, value} or {:error, reason}.module Main exposing (main)
import Html exposing (text)
safeDivide : Float -> Float -> Result String Float
safeDivide numerator denominator =
if denominator == 0 then
Err "cannot divide by zero"
else
Ok (numerator / denominator)
main =
case safeDivide 10 2 of
Ok quotient ->
text (String.fromFloat quotient)
Err reason ->
text reason
safe_divide = fn
_numerator, 0 -> {:error, "cannot divide by zero"}
numerator, denominator -> {:ok, numerator / denominator}
end
case safe_divide.(10, 2) do
{:ok, quotient} -> IO.puts(quotient)
{:error, reason} -> IO.puts(reason)
end Same shape, different guarantees: nothing forces a function to return only
:ok/:error tuples, and nothing forces the caller to handle both. In exchange, the convention needs zero declarations and composes across every library. Elm developers usually find they miss the checking more than Elixir developers expect to.nil vs Maybe
nil is back
Elm removed
null entirely; absence is always a Maybe. Elixir has nil — but softened: lookups take defaults, and || fills gaps.module Main exposing (main)
import Dict
import Html exposing (text)
main =
let
ages =
Dict.fromList [ ( "Ada", 36 ) ]
age =
Dict.get "Grace" ages
|> Maybe.withDefault 0
in
text (String.fromInt age) ages = %{"Ada" => 36}
IO.inspect(Map.get(ages, "Grace"))
IO.puts(Map.get(ages, "Grace", 0))
IO.puts(Map.get(ages, "Grace") || 0) Map.get returns nil for a missing key — the honest equivalent of Nothing, minus the type that forces you to notice. The three-argument form Map.get(map, key, default) is Maybe.withDefault folded into the lookup, and || 0 works because only nil and false are falsy in Elixir. The compiler will not chase unhandled nils for you; pattern matching usually catches them one function later.case, cond & Guards
case with guards
Elixir's
case is Elm's case plus when guards — the extension Elm deliberately declined.module Main exposing (main)
import Html exposing (text)
describe : Int -> String
describe number =
if number == 0 then
"zero"
else if number < 0 then
"negative"
else if modBy 2 number == 0 then
"positive even"
else
"positive odd"
main =
text (describe (-4))
describe = fn number ->
case number do
0 -> "zero"
value when value < 0 -> "negative"
value when rem(value, 2) == 0 -> "positive even"
_ -> "positive odd"
end
end
IO.puts(describe.(-4)) Guards are limited to a safe subset of functions (comparisons, arithmetic, type checks) so a guard can never have side effects — a small taste of Elm-style discipline inside the dynamically typed world. The same
when clauses work on function heads, where multi-clause definitions make them shine.cond replaces the else-if ladder
module Main exposing (main)
import Html exposing (text)
grade : Int -> String
grade score =
if score >= 90 then
"A"
else if score >= 80 then
"B"
else
"C"
main =
text (grade 85)
score = 85
grade =
cond do
score >= 90 -> "A"
score >= 80 -> "B"
true -> "C"
end
IO.puts(grade) cond evaluates each condition in order and takes the first truthy branch — Elm's else if ladder as a single expression, with true -> as the final else. Like everything in Elixir, it returns a value, so it binds directly to grade.Error Handling
with is Result.andThen, flattened
Elm chains fallible steps with
Result.andThen. Elixir's with expression matches each step against {:ok, …} and short-circuits to else on the first miss.module Main exposing (main)
import Html exposing (text)
half : Float -> Result String Float
half number =
if number == 0 then
Err "zero"
else
Ok (number / 2)
main =
let
result =
half 20
|> Result.andThen half
|> Result.andThen half
in
text (Debug.toString result)
half = fn
0 -> {:error, "zero"}
number -> {:ok, number / 2}
end
result =
with {:ok, first} <- half.(20),
{:ok, second} <- half.(first),
{:ok, third} <- half.(second) do
{:ok, third}
end
IO.inspect(result) Each
<- line must match its pattern for the chain to continue; the first non-matching value (an {:error, reason} tuple, say) becomes the result of the whole expression. It is Result.andThen without the nesting — and because it works on patterns rather than a Result type, it chains any shape of step.Exceptions & "let it crash"
Elm has no exceptions at all. Elixir has them —
raise and try/rescue — but its culture says something Elm never would: often, just crash.module Main exposing (main)
import Html exposing (text)
-- Elm's only failure channel is a value.
parsed : Result String Int
parsed =
String.toInt "not a number"
|> Result.fromMaybe "parse failed"
main =
text (Debug.toString parsed)
result =
try do
raise ArgumentError, message: "boom"
rescue
error in ArgumentError -> "caught: #{error.message}"
end
IO.puts(result) The philosophy is the interesting part. Elm makes crashes impossible by proving totality at compile time. Elixir makes crashes cheap: a process that hits a bad state dies, and a supervisor restarts it clean — so idiomatic code rarely writes
rescue, preferring tagged tuples for expected failure and a crash for the unexpected. Two opposite roads to reliability, both taken seriously.Processes: TEA, But Native
Messages are a language feature
Elm programs already think in messages —
Msg values dispatched to update. On the BEAM, messages are not an architecture, they are a primitive: any process can send, any process can receive.module Main exposing (main)
import Html exposing (text)
type Msg
= Greeting String
update : Msg -> String -> String
update message model =
case message of
Greeting name ->
"Hello, " ++ name ++ "!"
main =
text (update (Greeting "Elm") "")
parent = self()
spawn(fn -> send(parent, {:greeting, "Elixir"}) end)
receive do
{:greeting, name} -> IO.puts("Hello, #{name}!")
end receive pattern-matches on the mailbox exactly the way Elm's update matches on Msg — tagged tuples playing the role of the Msg type. The difference is scale and ownership: an Elm app has one implicit mailbox managed by the runtime; a BEAM system has millions of processes, each with its own, and you spawn them yourself.The Elm Architecture, hand-rolled in ten lines
How does an immutable language hold changing state? Elm's answer is the runtime looping
update over a model. Elixir's answer is the same loop, written by you: a process that recurses with the new state.module Main exposing (main)
import Html exposing (text)
type Msg
= Increment
| Decrement
update : Msg -> Int -> Int
update message model =
case message of
Increment ->
model + 1
Decrement ->
model - 1
main =
text (String.fromInt (List.foldl update 0 [ Increment, Increment, Decrement ]))
defmodule Counter do
def loop(count) do
receive do
:increment -> loop(count + 1)
:decrement -> loop(count - 1)
{:get, caller} ->
send(caller, {:count, count})
loop(count)
end
end
end
counter = spawn(fn -> Counter.loop(0) end)
send(counter, :increment)
send(counter, :increment)
send(counter, :decrement)
send(counter, {:get, self()})
receive do
{:count, value} -> IO.puts(value)
end Look at the two columns side by side: Elm's
update folded over a list of messages IS Counter.loop receiving from its mailbox — new state passed to the next iteration, never mutated. The Elm Architecture is this pattern with the loop hidden in the runtime; OTP's GenServer is this pattern with the loop hidden in a library. An Elm developer already understands BEAM concurrency; they have just never been allowed to spawn a second loop.