Shared Ground, One Difference
Hello, World
IO.puts("Hello, World!") module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
text "Hello, World!"
Even hello world reveals the stance: an Elm program never prints —
main is a value (here, an Html value) that the runtime renders. There is no IO.puts to call, because no Elm function performs I/O at all (see Enforced Purity).Immutable — and no rebinding either
count = 1
count = count + 1 # rebinding the NAME is ordinary Elixir
IO.puts(count) module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
let
count =
1
nextCount =
count + 1
in
text (String.fromInt nextCount)
Both languages never mutate data — but Elm goes one step further than Elixir and forbids rebinding and shadowing too: every value needs a fresh name.
let … in is the local-binding expression, and the indentation-sensitive layout is part of the syntax.Static Types, Real Inference
@spec → annotations the compiler enforces
defmodule Doubler do
@spec double(integer()) :: integer()
def double(number), do: number * 2
end
IO.puts(Doubler.double(21)) module Main exposing (main)
import Html exposing (Html, text)
double : Int -> Int
double number =
number * 2
main : Html msg
main =
text (String.fromInt (double 21))
double : Int -> Int reads like a @spec — but where a spec is documentation checked (partially, later, optionally) by Dialyzer, this signature is enforced by the compiler on every build. And it is optional: full Hindley–Milner inference means Elm knows every type with zero annotations; writing them is convention, not necessity.badarith moves to compile time
# This compiles fine — the mistake is found only when it RUNS:
try do
IO.puts(1 + "two")
rescue
ArithmeticError -> IO.puts("badarith — discovered at runtime")
end module Main exposing (main)
import Html exposing (Html, text)
-- 1 + "two" does not COMPILE:
--
-- The (+) operator only works with Int and Float values.
--
-- The mistake cannot ship, so there is nothing to rescue.
main : Html msg
main =
text (String.fromInt (1 + 2))
The category shift of the whole page: entire error classes — bad arithmetic, wrong-shaped data, missing case clauses — move from runtime (something to
rescue, or a process crash to supervise) to compile time (something that never builds). Elm’s compiler messages are famously written as helpful prose, with the reputation of a pair programmer.No atoms — declare the constructors
status = :shipped
IO.puts(status == :shipped)
# :shippde # a typo just makes a NEW atom, silently module Main exposing (main)
import Html exposing (Html, text)
type Status
= Pending
| Shipped
status : Status
status =
Shipped
main : Html msg
main =
text (Debug.toString (status == Shipped))
There are no atoms. The ad-hoc symbolic values Elixir conjures on demand become declared constructors of a custom type — a closed set, so the typo
Shippde is a compile error where :shippde silently mints a fresh atom. Debug.toString plays IO.inspect for turning any value into text.Pipes & Currying
The pipe survives — but flips
"hello elixir world"
|> String.split(" ")
|> Enum.map(&String.capitalize/1)
|> Enum.join(" ")
|> IO.puts() module Main exposing (main)
import Html exposing (Html, text)
capitalize : String -> String
capitalize word =
String.toUpper (String.left 1 word) ++ String.dropLeft 1 word
main : Html msg
main =
"hello elm world"
|> String.split " "
|> List.map capitalize
|> String.join " "
|> text
|> is here and it means the same thing — but the data slots into the last argument, not the first. String.split " " with one argument is already a function waiting for its final one, which is what makes last-position piping work (next row). One stdlib gap en route: Elm has no String.capitalize, so the helper builds it.Currying: partial application is free
add = fn first, second -> first + second end
increment = &add.(1, &1) # partial application is manual capture
IO.puts(increment.(41)) module Main exposing (main)
import Html exposing (Html, text)
add : Int -> Int -> Int
add first second =
first + second
increment : Int -> Int
increment =
add 1
main : Html msg
main =
text (String.fromInt (increment 41))
Read
Int -> Int -> Int literally: add takes an Int and returns a function Int -> Int. So add 1 is partial application with no ceremony — where Elixir’s fixed-arity functions need an explicit capture like &add.(1, &1). This is also why Elm functions take arguments separated by spaces, not tuples of parentheses.fn → \ (lambda)
doubled = Enum.map([1, 2, 3], fn number -> number * 2 end)
IO.inspect(doubled) module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
[ 1, 2, 3 ]
|> List.map (\number -> number * 2)
|> Debug.toString
|> text
fn number -> … end compresses to \number -> … — the backslash is meant to look like a lambda (λ). No end, no .() call syntax: anonymous and named functions are called identically, because they are the same kind of thing.nil → Maybe
There is no nil
inventory = %{apples: 5}
IO.inspect(inventory[:pears]) # nil — silently
IO.inspect(Map.get(inventory, :pears, 0)) module Main exposing (main)
import Dict
import Html exposing (Html, text)
main : Html msg
main =
let
inventory =
Dict.fromList [ ( "apples", 5 ) ]
in
text (Debug.toString (Dict.get "pears" inventory))
Dict.get returns Maybe Int — the value Nothing here, Just 5 for a present key — and the type system forces every caller to handle both shapes. Elixir’s bracket access returning nil silently is precisely the behavior Maybe deletes: absence can never masquerade as a value.|| 0 → Maybe.withDefault
inventory = %{apples: 5}
quantity = inventory[:pears] || 0
IO.puts(quantity) module Main exposing (main)
import Dict
import Html exposing (Html, text)
main : Html msg
main =
Dict.fromList [ ( "apples", 5 ) ]
|> Dict.get "pears"
|> Maybe.withDefault 0
|> String.fromInt
|> text
Maybe.withDefault is the || fallback idiom, typed: the fallback must match the Just contents. It is also immune to the classic truthiness bug — in Elixir, false || default and nil || default are indistinguishable; in Elm there is no truthiness at all, so Just False keeps its False.with → Maybe.map / andThen
users = %{1 => %{name: "Ada"}}
with %{name: name} <- Map.get(users, 1, :missing) do
IO.puts(String.upcase(name))
end module Main exposing (main)
import Dict
import Html exposing (Html, text)
main : Html msg
main =
let
users =
Dict.fromList [ ( 1, { name = "Ada" } ) ]
shouted =
Dict.get 1 users
|> Maybe.map (\user -> String.toUpper user.name)
|> Maybe.withDefault "missing"
in
text shouted
Maybe.map and Maybe.andThen chain fallible steps the way with chains matches: the pipeline flows on Just and short-circuits on Nothing, with the fallback supplied once at the end instead of an else block.Collections
Enum → List (data-last)
numbers = [1, 2, 3, 4]
IO.inspect(Enum.filter(numbers, fn number -> rem(number, 2) == 0 end))
IO.inspect(Enum.sum(numbers)) module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
let
numbers =
[ 1, 2, 3, 4 ]
evens =
List.filter (\number -> modBy 2 number == 0) numbers
in
text (Debug.toString ( evens, List.sum numbers ))
List is Enum for lists, with the collection in last position (the currying dividend again). rem/2 becomes modBy — note its argument order, modBy 2 number: the divisor first, so modBy 2 partially applies into a reusable "is even" building block.for comprehensions → pipelines
squares = for number <- 1..5, rem(number, 2) == 1, do: number * number
IO.inspect(squares) module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
List.range 1 5
|> List.filter (\number -> modBy 2 number == 1)
|> List.map (\number -> number * number)
|> Debug.toString
|> text
No comprehension syntax and no range literal —
List.range 1 5 plays 1..5, and the filter/map pipeline plays the generator and filter clauses. The pipe makes it read like the comprehension anyway, one clause per line.Map → Dict (homogeneous)
stock = %{"apples" => 5, "pears" => 2}
updated = Map.put(stock, "plums", 7)
IO.inspect(Map.keys(updated) |> Enum.sort()) module Main exposing (main)
import Dict
import Html exposing (Html, text)
main : Html msg
main =
Dict.fromList [ ( "apples", 5 ), ( "pears", 2 ) ]
|> Dict.insert "plums" 7
|> Dict.keys
|> Debug.toString
|> text
Dict covers the dynamic-keyed map — but it is homogeneous: one comparable key type, one value type, stated in Dict String Int. The anything-goes heterogeneous Elixir map does not exist; data with a fixed shape belongs in a record (next section). Dict.keys comes back already sorted.Maps → Records
Atom-keyed maps → records
person = %{name: "Ada", age: 36}
IO.puts(person.name)
IO.inspect(Map.put(person, :verified, true)) # grows at will module Main exposing (main)
import Html exposing (Html, text)
type alias Person =
{ name : String, age : Int }
person : Person
person =
{ name = "Ada", age = 36 }
main : Html msg
main =
text person.name
Records look like atom-keyed maps, but the shape is a compile-time contract: no adding fields at runtime, and
person.name on a record without name refuses to build (instead of returning nil — this is the struct made total). The accessor .name is itself a function you can pass to List.map.Update syntax — a convergence
person = %{name: "Ada", age: 36}
older = %{person | age: 37}
IO.inspect(older) module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
let
person =
{ name = "Ada", age = 36 }
older =
{ person | age = 37 }
in
text (Debug.toString older)
A rare, satisfying convergence:
{ person | age = 37 } and %{person | age: 37} are nearly the same characters, with the same restriction — existing fields only. Both return a new value and leave the original untouched, of course.Tagged Tuples → Custom Types
Tagged tuples, declared
shape = {:circle, 2.0}
area = case shape do
{:circle, radius} -> 3.14159 * radius * radius
{:rectangle, width, height} -> width * height
end
IO.inspect(area) module Main exposing (main)
import Html exposing (Html, text)
type Shape
= Circle Float
| Rectangle Float Float
area : Shape -> Float
area shape =
case shape of
Circle radius ->
pi * radius * radius
Rectangle width height ->
width * height
main : Html msg
main =
text (String.fromFloat (area (Circle 2)))
A custom type is the tagged-tuple idiom promoted to a language feature:
Circle Float is {:circle, radius}, except the compiler knows every variant and every payload shape. Constructing Circle "two" or matching a three-field Rectangle with two names cannot build.FunctionClauseError cannot ship
# Forgetting a clause compiles — and crashes at runtime:
describe = fn
:pending -> "waiting"
:shipped -> "on the way"
end
IO.puts(describe.(:shipped))
# describe.(:delivered) would raise FunctionClauseError module Main exposing (main)
import Html exposing (Html, text)
type Status
= Pending
| Shipped
| Delivered
describe : Status -> String
describe status =
case status of
Pending ->
"waiting"
Shipped ->
"on the way"
Delivered ->
"at the door"
main : Html msg
main =
text (describe Shipped)
case over a custom type must be exhaustive — delete the Delivered branch and the program refuses to build, naming the missing pattern. The runtime crash Elixir supervises (FunctionClauseError, CaseClauseError) becomes a compile-time conversation instead.Patterns transfer; guards do not
point = {3, -3}
message = case point do
{0, 0} -> "origin"
{x, y} when x == -y -> "anti-diagonal"
{x, _y} -> "x is #{x}"
end
IO.puts(message) module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
let
point =
( 3, -3 )
message =
case point of
( 0, 0 ) ->
"origin"
( x, y ) ->
if x == -y then
"anti-diagonal"
else
"x is " ++ String.fromInt x
in
text message
Tuple patterns, wildcards, and literal patterns all transfer — but Elm has no guards: there is no
when, so the condition moves into an if/else expression inside the branch. (List destructuring exists too, spelled with cons: first :: rest for [head | tail].)Errors Without Exceptions
{:ok, _} / {:error, _} → Result
divide = fn
_numerator, 0 -> {:error, :division_by_zero}
numerator, divisor -> {:ok, div(numerator, divisor)}
end
case divide.(10, 2) do
{:ok, value} -> IO.puts("got #{value}")
{:error, reason} -> IO.inspect(reason)
end module Main exposing (main)
import Html exposing (Html, text)
divide : Int -> Int -> Result String Int
divide numerator divisor =
if divisor == 0 then
Err "division by zero"
else
Ok (numerator // divisor)
main : Html msg
main =
case divide 10 2 of
Ok value ->
text ("got " ++ String.fromInt value)
Err reason ->
text reason
Result error value is the tagged-tuple convention formalized as a type — Ok/Err for {:ok, _}/{:error, _} — and consuming one forces both branches, everywhere, at compile time. Result.map/Result.andThen chain like the Maybe versions. Integer division is //.No raise, no rescue, no crashes
result = try do
String.to_integer("many")
rescue
ArgumentError -> :not_a_number
end
IO.inspect(result) module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
-- There is no try/rescue — nothing throws. Fallible
-- operations return Maybe or Result up front:
case String.toInt "many" of
Just value ->
text (String.fromInt value)
Nothing ->
text "not a number"
Elm’s famous claim — no runtime exceptions in practice — holds because failure is a value from the start:
String.toInt returns Maybe Int rather than raising. "Let it crash" has no role here, because there is no crash to let happen and no supervisor needed to recover from one.Enforced Purity
No function can sneak in I/O
defmodule Greeter do
def greet(name) do
IO.puts("a side effect, mid-function") # any function may do I/O
"hello #{name}"
end
end
IO.puts(Greeter.greet("Ada")) module Main exposing (main)
import Html exposing (Html, text)
-- No Elm function can perform I/O. Effects are DATA — Cmd
-- values returned to the runtime, which executes them:
--
-- update : Msg -> Model -> ( Model, Cmd Msg )
--
-- An HTTP call is a value DESCRIBING the request; the runtime
-- performs it and delivers the outcome back as a Msg.
greet : String -> String
greet name =
"hello " ++ name
main : Html msg
main =
text (greet "Ada")
In Elixir, purity is a discipline — any function may quietly log, message a process, or write a file. In Elm it is enforced: a function’s type says everything it can do, and effects exist only as
Cmd values the runtime executes. The payoff is that every function is testable and cacheable by construction.No processes — one update loop
defmodule Counter do
use GenServer
def init(initial), do: {:ok, initial}
def handle_cast(:increment, state), do: {:noreply, state + 1}
def handle_call(:value, _from, state), do: {:reply, state, state}
end
{:ok, counter} = GenServer.start_link(Counter, 0)
GenServer.cast(counter, :increment)
IO.inspect(GenServer.call(counter, :value)) module Main exposing (main)
import Html exposing (Html, text)
-- The Elm Architecture is one gen_server for the whole app:
-- init ≈ init/1
-- Msg ≈ the messages in the mailbox
-- update msg model ≈ handle_cast(msg, state)
type Msg
= Increment
update : Msg -> Int -> Int
update msg model =
case msg of
Increment ->
model + 1
main : Html msg
main =
text (String.fromInt (update Increment 0))
There is no
spawn, no mailbox API, no OTP — the browser gives Elm one thread, and The Elm Architecture makes the whole application a single gen_server-shaped loop: state in, message in, new state out. The runtime plays supervisor, but with nothing to restart, since the update function cannot crash.Rendering HTML
Templates → Html values
The Elm column renders its output live in a preview pane; the Elixir column prints the HTML string a browser would render (Phoenix would pass it through an HEEx template instead).
name = "Ada Lovelace"
role = "Founder"
IO.puts("""
<div style="padding:16px;border-radius:10px;background:#4B275F;color:white;font-family:system-ui,sans-serif">
<h2 style="margin:0 0 4px">#{name}</h2>
<p style="margin:0;opacity:0.85">#{role}</p>
</div>
""") module Main exposing (main)
import Html exposing (Html, div, h2, p, text)
import Html.Attributes exposing (style)
main : Html msg
main =
div
[ style "padding" "16px"
, style "border-radius" "10px"
, style "background" "#1293D8"
, style "color" "white"
, style "font-family" "system-ui, sans-serif"
]
[ h2 [ style "margin" "0 0 4px" ] [ text "Ada Lovelace" ]
, p [ style "margin" "0", style "opacity" "0.85" ] [ text "Founder" ]
]
Elm has no template language at all:
div [ attributes ] [ children ] is an ordinary function call producing a typed Html value. Where HEEx checks its interpolations inside a string template, Elm dissolves the template entirely — building markup is just building nested lists of values, with the compiler checking every attribute and child.Iteration is the template
posts = ["Shipping Popcorn", "Why no nil", "The update loop"]
items = Enum.map(posts, fn post -> "<li>#{post}</li>" end)
IO.puts("<ul style=\"font-family:system-ui,sans-serif\">#{Enum.join(items, "")}</ul>") module Main exposing (main)
import Html exposing (Html, li, text, ul)
import Html.Attributes exposing (style)
posts : List String
posts =
[ "Shipping Popcorn", "Why no nil", "The update loop" ]
main : Html msg
main =
ul [ style "font-family" "system-ui, sans-serif" ]
(List.map (\post -> li [] [ text post ]) posts)
Both columns map over the data — but the Elixir side maps to strings and joins, while Elm maps to
li values that become the ul’s child list directly. Building markup and building a list are the same operation, so List.map is the entire templating story.GenServer → The Elm Architecture
LiveView → Browser.sandbox
defmodule CounterLive do
use Phoenix.LiveView
def mount(_params, _session, socket) do
{:ok, assign(socket, clicks: 0)}
end
def handle_event("click", _params, socket) do
{:noreply, update(socket, :clicks, &(&1 + 1))}
end
def render(assigns) do
~H"""
<button phx-click="click">Click me</button>
<span><%= @clicks %> clicks</span>
"""
end
end module Main exposing (main)
import Browser
import Html exposing (Html, button, div, span, text)
import Html.Events exposing (onClick)
type alias Model =
{ clicks : Int }
type Msg
= Clicked
update : Msg -> Model -> Model
update msg model =
case msg of
Clicked ->
{ model | clicks = model.clicks + 1 }
view : Model -> Html Msg
view model =
div []
[ button [ onClick Clicked ] [ text "Click me" ]
, span [] [ text (String.fromInt model.clicks ++ " clicks") ]
]
main : Program () Model Msg
main =
Browser.sandbox { init = { clicks = 0 }, update = update, view = view }
The shapes map one-to-one:
mount ≈ init, handle_event ≈ update, render ≈ view — LiveView is The Elm Architecture run server-side over a socket, and Elm keeps the same loop entirely in the browser with no round trip and no process. The deepest difference is what cannot happen: an Elm update cannot crash, so there is no supervisor because none is needed. Both cells are display-only sketches.