PONY λ M2 Modula-2

Elixir.CodeCompared.To/Elm

An interactive executable cheatsheet comparing Elixir and Elm

Elixir 1.17 Elm 0.19.1
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 printsmain 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: mountinit, handle_eventupdate, renderview — 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.