Output & Running
Hello, World
There is no
io:format, and nothing that behaves like it. main is not a function that runs and emits output — it is a value, of type Html, that the runtime renders.io:format("Hello, World!~n").module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
text "Hello, World!"
That is the first sign of the change this page is about.
io:format/1 is a side effect available anywhere in any Erlang function, and nothing in a function's shape says whether it performs one. In Elm no function can perform an effect at all, so there is nothing to call — the program produces a description and hands it over. The msg in Html msg is a type variable meaning "this markup emits no messages", which is right for a page with no interaction.There is no ~p
Erlang's
~p renders any term for a human with nothing declared and nothing derived, which is one of the quietly great things about working in it. Elm has Debug.toString, and it comes with a catch.Value = {ok, [1, 2, 3]},
io:format("~p~n", [Value]).module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
text (Debug.toString ( "ok", [ 1, 2, 3 ] ))
The catch is that
Debug.toString is a development tool: an optimized production build refuses to compile if it appears anywhere. That is deliberate — showing a value to a human is a design decision the compiler wants you to make explicitly, so real code writes a function producing a String for each type it displays. Every text example on this page uses text with a hand-built string for that reason, and reaches for Debug.toString only where the shape itself is the point. The nearest thing to ~p that survives a production build is the one you wrote.The punctuation is gone
Erlang's comma, semicolon and period each mean something different and getting them wrong is most of a beginner's syntax errors. Elm has none of the three: expressions are separated by layout, and that is all.
% Comma between expressions, period at the end.
Name = "Ada",
Greeting = "Hello",
io:format("~s, ~s!~n", [Greeting, Name]).module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
let
name =
"Ada"
greeting =
"Hello"
in
text (greeting ++ ", " ++ name ++ "!")
Two conventions worth adopting on day one, because
elm-format will impose them anyway: two blank lines between top-level definitions, and a body on its own line indented four spaces. Nearly every Elm project runs elm-format on save, so the community has no style debates at all — a considerable relief coming from a language where ; versus , is a real decision. Note also that lowercase names are values here and uppercase names are types or constructors, which is the exact reverse of Erlang's rule that uppercase is a variable and lowercase is an atom.What Carries Over Unchanged
Single assignment, in both
This is the row that should make an Erlang developer relax. Single assignment is not something Elm is going to teach you — you already write in a language where a name is bound once and
Count1, Count2 is a familiar smell.Count = 1,
NextCount = Count + 1,
% Count = 2 would be a badmatch — the name is bound.
io:format("~p ~p~n", [Count, NextCount]).module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
let
count =
1
nextCount =
count + 1
in
-- count = 2 would not compile — the name is bound.
text (String.fromInt count ++ " " ++ String.fromInt nextCount)
The difference is only in when you are told. Rebinding in Erlang is a
badmatch at run time, because = is the match operator and Count = 2 is a perfectly legal assertion that happens to be false. In Elm it is a compile error, because = in a let is a definition and defining a name twice is not a thing you can write. Elm also forbids shadowing outright — an inner scope may not reuse an outer name — which Erlang permits with a warning.Destructuring, and the tagged tuple habit
The
{ok, Value} convention is Erlang's most-used idiom and it transfers directly — but where Erlang builds it out of an atom and a tuple, Elm declares it as a type with named constructors.Result = {ok, 42},
{ok, Value} = Result,
io:format("~p~n", [Value]).module Main exposing (main)
import Html exposing (Html, text)
type Outcome
= Ok_ Int
| Failed String
main : Html msg
main =
let
result =
Ok_ 42
in
case result of
Ok_ value ->
text (String.fromInt value)
Failed reason ->
text reason
Notice what the declaration buys. The Erlang match
{ok, Value} = Result is an assertion that fails at run time if the result was {error, Reason}, so the error branch is something you have to remember to write. The Elm case will not compile unless every constructor is handled, so forgetting the failure branch is impossible. Elm ships this exact shape as Result error value with constructors Ok and Err, used throughout the Errors section below — the type is hand-rolled here only so the parallel with the tuple is visible.Everything is an expression already
Erlang's
if is already an expression producing a value, so binding the whole construct rather than assigning inside each branch is a habit you have. Elm works the same way.Temperature = 18,
Advice = if
Temperature > 25 -> "wear shorts";
Temperature > 15 -> "a jacket will do";
true -> "wear a coat"
end,
io:format("~s~n", [Advice]).module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
let
temperature =
18
advice =
if temperature > 25 then
"wear shorts"
else if temperature > 15 then
"a jacket will do"
else
"wear a coat"
in
text advice
The two differences are small and both are improvements. Erlang's
if takes guards rather than arbitrary expressions, which is why the fall-through branch is spelled true -> and why an if that matches nothing raises if_clause — Elm requires an else, so there is no such case. And both branches of an Elm if must have the same type, which Erlang has no way to check. Erlang's case is closer to what you will actually use, and it maps to Elm's case almost exactly, minus guards.The Compiler Erlang Never Had
A bad call stops being a run-time event
This is the reframing the whole page turns on. Erlang has no compile-time type checking, so
Double("nonsense") is a perfectly valid program that happens to raise badarith when that line executes.% No type declaration anywhere. This is fine until it RUNS:
Double = fun(X) -> X * 2 end,
io:format("~p~n", [Double(21)]),
try Double("nonsense") of
Result -> io:format("~p~n", [Result])
catch
error:badarith -> io:format("badarith, at run time~n")
end.module Main exposing (main)
import Html exposing (Html, text)
double : Int -> Int
double value =
value * 2
main : Html msg
main =
text (String.fromInt (double 21))
-- double "nonsense" would not COMPILE. There is no program
-- in which that call exists to be caught.
The Elm version is not a caught error — it is a build failure, so the program containing that call does not exist. Erlang reaches reliability from the other direction, with supervision restarting whatever fails, and that is a genuinely different and genuinely good answer for a long-running distributed system. For a front end there is nothing to supervise: a JavaScript exception in a browser tab does not restart anything, it just stops the page working. The trade Elm makes is to eliminate the failures beforehand instead, which is why "let it crash" has no counterpart here — and why the Errors section below has no
catch in it.A spec that is checked, not merely consulted
An Erlang
-spec looks like an Elm annotation, and the difference is not cosmetic. A spec is optional documentation that Dialyzer reads; an Elm annotation is part of the type system whether you write it or not.% -spec double(integer()) -> integer().
% A spec lives in a module, is optional, and is read by Dialyzer —
% which reports only what it can PROVE wrong. At expression level
% there is nowhere to put one at all:
Double = fun(X) -> X * 2 end,
io:format("~p~n", [Double(21)]).module Main exposing (main)
import Html exposing (Html, text)
double : Int -> Int
double value =
value * 2
-- The annotation is OPTIONAL: Elm infers Int -> Int on its own.
-- Writing it is convention, and it is checked either way.
main : Html msg
main =
text (String.fromInt (double 21))
Dialyzer's design goal is no false positives: it reports discrepancies it can prove, and stays quiet about everything it cannot, so a clean Dialyzer run is not a statement that the code is type-correct. Elm's inference goes the other way — every expression has a type whether annotated or not, and anything that does not fit is rejected. The practical difference shows up during a change: alter a function's signature in Elm and the compiler lists every call that no longer fits, which is refactoring with a checklist. In Erlang the same change is found by grep, tests, and eventually production.
A shape with a name
An Erlang map with atom keys is the everyday way to carry a record-shaped value, and its shape exists only in your head and in the code that reads it.
type alias writes it down where the compiler can see.% A map's shape is a convention; nothing enforces it.
Person = #{name => "Ada", age => 36},
io:format("~s is ~p~n", [maps:get(name, Person), maps:get(age, Person)]).module Main exposing (main)
import Html exposing (Html, text)
type alias Person =
{ name : String
, age : Int
}
describe : Person -> String
describe person =
person.name ++ " is " ++ String.fromInt person.age
main : Html msg
main =
text (describe { name = "Ada", age = 36 })
There is no class here and no constructor — an alias is only a name for a shape, and any record with those exact fields is a
Person. Two consequences. maps:get(nam, Person) raises badkey when it runs, while person.nam does not compile, naming the field and listing the ones that exist. And the alias doubles as a constructor function: Person "Ada" 36 builds one positionally. Erlang's -record is the closer analogue, and it is compile-time-checked too — but records are a preprocessor trick that does not survive to expression level, which is why the anchor column here uses a map.Atoms Become Custom Types
A typo is a new atom, and a valid one
An atom springs into existence the moment you type it, which is what makes Erlang so quick to write and is also the hole this row is about:
susppended is not an error, it is a different atom.Describe = fun
(active) -> "in good standing";
(suspended) -> "temporarily blocked";
(closed) -> "gone"
end,
io:format("~s~n", [Describe(suspended)]),
% Describe(susppended) compiles fine and raises function_clause at run time.
try Describe(susppended) of
Text -> io:format("~s~n", [Text])
catch
error:function_clause -> io:format("function_clause, at run time~n")
end.module Main exposing (main)
import Html exposing (Html, text)
type Status
= Active
| Suspended
| Closed
describe : Status -> String
describe status =
case status of
Active ->
"in good standing"
Suspended ->
"temporarily blocked"
Closed ->
"gone"
main : Html msg
main =
text (describe Suspended)
Elm has no atoms. The replacement is a custom type whose constructors are declared, so the set is closed:
Susppended is not a value, it is an unknown name, and the compiler says so with a suggestion. Two more things follow from the set being closed. A case that misses a constructor does not compile, so function_clause has no counterpart. And adding a fourth Status immediately lists every case in the codebase that no longer covers it — the change Erlang finds when a request happens to hit the gap.A constructor can carry data, with declared types
The tagged tuple carrying different payloads per tag is an Erlang idiom you already write. A custom type is the same idea with the tags declared and the payload types written down.
Area = fun
({square, Side}) -> Side * Side;
({rect, Width, Height}) -> Width * Height
end,
io:format("~p ~p~n", [Area({square, 3}), Area({rect, 3, 4})]).module Main exposing (main)
import Html exposing (Html, text)
type Shape
= Square Float
| Rect Float Float
area : Shape -> Float
area shape =
case shape of
Square side ->
side * side
Rect width height ->
width * height
main : Html msg
main =
text (String.fromFloat (area (Square 3)) ++ " " ++ String.fromFloat (area (Rect 3 4)))
The Erlang version has three unchecked assumptions: that only these two tags exist, that a
square tuple has exactly two elements, and that the second is a number. Nothing verifies any of them, and Area({square, "3"}) gets as far as badarith. The Elm declaration states all three, so the case is exhaustive, the arities are fixed, and Square "3" does not compile. This is where custom types stop resembling an enum and start replacing what other languages give to a class hierarchy — a closed set of alternatives with different fields each.true and false stop being atoms
Erlang's
true and false are atoms, and its term ordering is total — any two terms can be compared, ordered number < atom < reference < fun < port < pid < tuple < map < list < bitstring. Elm has a real Bool type and no cross-type ordering at all.% In Erlang these are ordinary atoms, and comparison is total:
io:format("~p~n", [is_atom(true)]),
io:format("~p~n", [1 < a]),
io:format("~p~n", [[] < 1]).module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
-- Bool is its own type, and comparison needs matching types.
-- 1 < "a" does not compile.
text
(Debug.toString (1 < 2)
++ " "
++ Debug.toString (compare 1 2)
)
Total ordering is what makes
lists:sort/1 work on a mixed list, and it is also a trap: 1 < a is true rather than an error, so a comparison bug returns a plausible boolean instead of complaining. Elm's < requires both sides to be the same comparable type, so the same mistake does not compile. The narrower rule costs you the mixed-list sort, which is rarely what you wanted, and buys the guarantee that a comparison compared what you thought.undefined Becomes Maybe
undefined is a convention; Maybe is a type
Erlang signals absence with the atom
undefined, by convention — it is an ordinary atom, so nothing distinguishes "no user" from a user actually named undefined, and nothing stops it being passed onward.FindUser = fun
(1) -> "Ada";
(_) -> undefined
end,
Name = FindUser(2),
% Nothing stops you passing undefined to something expecting a string:
case Name of
undefined -> io:format("nobody~n");
Found -> io:format("~s~n", [string:uppercase(Found)])
end.module Main exposing (main)
import Html exposing (Html, text)
findUser : Int -> Maybe String
findUser id =
if id == 1 then
Just "Ada"
else
Nothing
main : Html msg
main =
text (String.toUpper (Maybe.withDefault "nobody" (findUser 2)))
Maybe String is not a String, so no function expecting a String will take it and the compiler forces the absent case to be handled before the value can be used. Compare what the Erlang column has to do: remember to case on undefined, and remember it again at every call site, with nothing checking that you did — string:uppercase(undefined) is a run-time badarg. This is the same shape as the {ok, Value} convention you already use, promoted from convention to type. Maybe.withDefault is the unwrap at the edge.Working inside a Maybe
The Erlang idiom for "operate on a value that might be
undefined" is a case that passes undefined through. Maybe.map is that pattern given a name.FindAge = fun
("Ada") -> 36;
(_) -> undefined
end,
NextYear = case FindAge("Ada") of
undefined -> undefined;
Age -> Age + 1
end,
io:format("~p ~p~n", [NextYear, FindAge("Grace")]).module Main exposing (main)
import Html exposing (Html, text)
findAge : String -> Maybe Int
findAge name =
if name == "Ada" then
Just 36
else
Nothing
main : Html msg
main =
let
nextYear =
Maybe.map (\age -> age + 1) (findAge "Ada")
in
text
(Maybe.withDefault "unknown" (Maybe.map String.fromInt nextYear)
++ " "
++ Maybe.withDefault "unknown" (Maybe.map String.fromInt (findAge "Grace"))
)
The habit worth building is to stay inside the
Maybe and unwrap once, at the edge, rather than threading an undefined check through every function. Maybe.andThen is the version for a step that itself returns a Maybe, which is where a nested case would otherwise appear — the same relationship Result.andThen has in the Errors section. Note that Erlang's version has to repeat undefined in two roles, as the thing matched and the thing returned, with nothing connecting them.Pattern Matching, Minus Guards
case transfers almost exactly
This row should feel like home.
case matches patterns, binds the payload while choosing the branch, and is an expression producing a value — all three exactly as in Erlang.Describe = fun(Outcome) ->
case Outcome of
{ok, Value} -> io_lib:format("got ~p", [Value]);
{error, Reason} -> io_lib:format("failed: ~s", [Reason])
end
end,
io:format("~s / ~s~n", [Describe({ok, 42}), Describe({error, "timed out"})]).module Main exposing (main)
import Html exposing (Html, text)
type Outcome
= Success Int
| Failure String
describe : Outcome -> String
describe outcome =
case outcome of
Success value ->
"got " ++ String.fromInt value
Failure reason ->
"failed: " ++ reason
main : Html msg
main =
text (describe (Success 42) ++ " / " ++ describe (Failure "timed out"))
Three differences, all small. There is no
; between branches and no end, because layout does that work. Patterns match declared constructors rather than arbitrary terms, so the branch set is checkable — the compiler rejects a case that misses one, where Erlang raises case_clause at run time. And the branches must all return the same type, which is what makes describe's signature possible at all.There is no when
Elm has no guard clause. There is no
when, and a case branch is a pattern and nothing else — so a condition that is not structural moves into an if inside the branch, or replaces the case entirely.Classify = fun(Value) ->
case Value of
N when is_integer(N), N < 0 -> "a negative integer";
N when is_integer(N) -> "an integer";
S when is_list(S), length(S) > 5 -> "a long string";
_ -> "something else"
end
end,
io:format("~s / ~s / ~s~n",
[Classify(-3), Classify(7), Classify("elephant")]).module Main exposing (main)
import Html exposing (Html, text)
classify : Int -> String
classify value =
if value < 0 then
"a negative integer"
else if value > 100 then
"a large integer"
else
"an integer"
main : Html msg
main =
text (classify -3 ++ " / " ++ classify 7 ++ " / " ++ classify 200)
This is the one place where an Erlang developer gives something up and gets nothing directly back, and it is worth naming rather than glossing. What softens it is that half of what Erlang guards do is type dispatch —
is_integer, is_list, is_atom — and in Elm the type is already known, so those guards have nothing to test. The Erlang column here classifies a value of unknown type, which is a function Elm cannot express at all: classify takes an Int, because a function that accepts anything and behaves differently per type is what custom types exist to replace.A missing branch is a compile error
This is the payoff of a closed constructor set, and it is the single thing an Erlang developer is most likely to miss when going back. A
case that does not cover every constructor does not compile.Describe = fun(Status) ->
case Status of
active -> "in good standing";
suspended -> "temporarily blocked"
% closed is not handled — nothing says so until it arrives
end
end,
io:format("~s~n", [Describe(active)]),
try Describe(closed) of
Text -> io:format("~s~n", [Text])
catch
error:{case_clause, _} -> io:format("case_clause, at run time~n")
end.module Main exposing (main)
import Html exposing (Html, text)
type Status
= Active
| Suspended
| Closed
describe : Status -> String
describe status =
case status of
Active ->
"in good standing"
Suspended ->
"temporarily blocked"
Closed ->
-- Removing this branch is a compile error naming Closed.
"gone"
main : Html msg
main =
text (describe Closed)
Erlang cannot offer this and it is not an oversight: an atom set is open by construction, so there is no "every" for a compiler to check against. The result is that
case_clause is a normal Erlang production error, usually arriving as a crash report about a value nobody expected. In Elm the compiler names the missing constructor before the build finishes. The habit this changes is defensive catch-all branches — _ -> exists in Elm too, and using it throws the guarantee away, so idiomatic code lists the cases and lets the compiler maintain the list.Funs, Currying & the Pipe
A function is a top-level definition
A call needs no parentheses and no commas:
greet "Hello" "Ada" is the whole thing. Coming from Erlang, where every call is Name(Arg, Arg), this is the syntax change that takes longest to stop noticing.Greet = fun(Greeting, Name) ->
Greeting ++ ", " ++ Name ++ "!"
end,
io:format("~s / ~s~n", [Greet("Hello", "Ada"), Greet("Welcome", "Ada")]).module Main exposing (main)
import Html exposing (Html, text)
greet : String -> String -> String
greet greeting name =
greeting ++ ", " ++ name ++ "!"
main : Html msg
main =
text (greet "Hello" "Ada" ++ " / " ++ greet "Welcome" "Ada")
The parentheses come back the moment an argument is itself a call —
String.fromInt (double 21) needs them, since String.fromInt double 21 would mean passing two arguments to String.fromInt. Note also that ++ here is string concatenation, where in Erlang ++ joins two lists and works on strings only because an Erlang string is a list. That difference is the subject of a Gotchas row below, and it is a bigger one than it looks.One function, one body
Erlang's multi-clause function head is one of its best features, and Elm does not have it. A function has one parameter list and one body; dispatching on the argument means a
case or an if inside.% Multiple clauses with patterns in the head:
Factorial = fun F(0) -> 1;
F(N) when N > 0 -> N * F(N - 1)
end,
io:format("~p~n", [Factorial(5)]).module Main exposing (main)
import Html exposing (Html, text)
factorial : Int -> Int
factorial n =
if n <= 0 then
1
else
n * factorial (n - 1)
main : Html msg
main =
text (String.fromInt (factorial 5))
This is the second real loss on this page, after guards, and the two compound: an Erlang clause head can pattern-match and guard, so
F(0) -> 1; F(N) when N > 0 -> … says a great deal in two lines. The Elm equivalent is longer and, for a recursive function over a custom type, usually a case on the constructor — which does read well. What you gain is that there is one place a function's behavior is decided, so there is no equivalent of adding a clause above an existing one and silently changing what an old call does. Note that Elm functions are recursive by name with no fun F ceremony needed.Every function takes exactly one argument
Read the signature again:
String -> String -> String, with no grouping, because there are no two-argument functions in Elm. greet takes one String and returns a function that takes the next.Greet = fun(Greeting, Name) ->
Greeting ++ ", " ++ Name ++ "!"
end,
% Pre-filling an argument means writing a wrapper fun:
SayHello = fun(Name) -> Greet("Hello", Name) end,
io:format("~s / ~s~n", [SayHello("Ada"), SayHello("Grace")]).module Main exposing (main)
import Html exposing (Html, text)
greet : String -> String -> String
greet greeting name =
greeting ++ ", " ++ name ++ "!"
sayHello : String -> String
sayHello =
greet "Hello"
main : Html msg
main =
text (sayHello "Ada" ++ " / " ++ sayHello "Grace")
So
greet "Hello" is a complete expression of type String -> String, and calling a function with fewer arguments than it appears to want is ordinary rather than an arity error. Erlang is strict about arity — Greet("Hello") is a badarity — so pre-filling means the wrapper fun in the anchor column. This is also why sayHello can be defined with no parameter at all, which looks wrong until the currying clicks. The payoff shows up in List.map (greet "Hi") names, where the partially applied function is exactly what map wants.There is a pipe, and it feeds the LAST argument
Erlang has no pipe operator at all, so a chain of transformations nests inside out and is read from the middle. Elm has
|>, and the trap is which slot it fills: the value goes into the last argument position.% Erlang has no pipe operator; calls nest inside out.
Names = [" ada ", " grace "],
Result = lists:map(fun string:uppercase/1,
lists:map(fun string:trim/1, Names)),
io:format("~s~n", [lists:join(", ", Result)]).module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
[ " ada ", " grace " ]
|> List.map String.trim
|> List.map String.toUpper
|> String.join ", "
|> text
That is the opposite of most pipes, and it follows directly from currying.
List.map String.trim is a partially applied function still waiting for its list, so piping a list into it completes the call — which works only because the list is the last parameter. The whole standard library is ordered that way: List.map f list, String.join separator list, Dict.get key dict. Erlang's convention is the reverse (lists:map(Fun, List) takes the data last, but string:trim(String) takes it first), which is exactly why a pipe never took hold there. Put the data last in your own Elm functions or your pipelines will not compose.Lists, Maps & Records
Lists, and the one-type rule
The cons pattern transfers with a change of spelling: Erlang's
[Head | Tail] is Elm's head :: tail. The restriction is that every element of an Elm List has the same type.% An Erlang list holds anything, in any mixture:
Mixed = [1, atom, "string", {tuple, 2}],
io:format("~p~n", [length(Mixed)]),
Numbers = [1, 2, 3],
[Head | Tail] = Numbers,
io:format("~p ~p~n", [Head, Tail]).module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
let
numbers =
[ 1, 2, 3 ]
in
-- [ 1, "two" ] would not compile: a List holds ONE type.
case numbers of
head :: tail ->
text (String.fromInt head ++ " " ++ Debug.toString tail)
[] ->
text "empty"
That one rule is what makes
List.sum and the rest safe with no run-time checks. When you genuinely need a mixture, the Elm answer is a custom type naming the alternatives, which forces every consumer to handle each case — where an Erlang mixed list is discovered by whatever function chokes on it first. Note the second Elm branch: matching a list means handling the empty case too, so [] is not optional, which is the exhaustiveness rule applied to lists. Erlang would raise badmatch on [H|T] = [] at run time.lists: becomes List.
The three workhorses map straight across, with one ordering change that matters: Elm puts the collection last, so the functions are pipeable, while Erlang's
lists:foldl(Fun, Acc, List) already does and lists:map(Fun, List) does too.Numbers = [1, 2, 3, 4, 5, 6],
Doubled = lists:map(fun(N) -> N * 2 end, Numbers),
Evens = lists:filter(fun(N) -> N rem 2 =:= 0 end, Numbers),
Total = lists:foldl(fun(N, Acc) -> Acc + N end, 0, Numbers),
io:format("~p ~p ~p~n", [Doubled, Evens, Total]).module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
let
numbers =
[ 1, 2, 3, 4, 5, 6 ]
doubled =
List.map (\number -> number * 2) numbers
evens =
List.filter (\number -> modBy 2 number == 0) numbers
total =
List.foldl (+) 0 numbers
in
text (Debug.toString doubled ++ " " ++ Debug.toString evens ++ " " ++ String.fromInt total)
A lambda is
\parameter -> body, which is the Erlang fun(X) -> ... end with the ceremony removed. List.foldl (+) 0 shows an operator used as a function by wrapping it in parentheses — Erlang has no equivalent, since + is not a fun and fun erlang:'+'/2 is not a thing you write. Note modBy 2 number rather than an infix rem: the argument order is deliberate so that modBy 2 partially applies to "is even", which is the currying rule showing up in the standard library's design.List comprehensions
Erlang's comprehension does the filtering and the mapping in one bracket, with the pattern on the left of
<- doing double duty. Elm has no comprehension syntax at all — the pipeline is the replacement.People = [{"ada", 36}, {"grace", 45}, {"alan", 41}],
Names = [Name || {Name, Age} <- People, Age > 40],
io:format("~s~n", [lists:join(", ", Names)]).module Main exposing (main)
import Html exposing (Html, text)
type alias Person =
{ name : String
, age : Int
}
people : List Person
people =
[ { name = "ada", age = 36 }
, { name = "grace", age = 45 }
, { name = "alan", age = 41 }
]
main : Html msg
main =
people
|> List.filter (\person -> person.age > 40)
|> List.map .name
|> String.join ", "
|> text
This is a fair trade rather than a loss: the comprehension is more compact, and the pipeline is easier to extend, because adding a sort or a limit is one more stage rather than a rewrite.
.name on its own is worth pointing out — a record field accessor is an ordinary function of type { a | name : b } -> b, so List.map .name needs no lambda. Note also that the Erlang generator pattern {Name, Age} <- People silently skips elements that do not match, which is the one place a failed match is not an error; Elm has nothing that does that, and filtering is always explicit.One map type becomes two things
An Erlang map covers two different jobs: a value with a known fixed shape, and a lookup table with keys you learn at run time. Elm splits them, and the split is the interesting part.
% A map does both jobs: fixed shape, and arbitrary keys.
Person = #{name => "Ada", age => 36},
Older = Person#{age := 37},
Counts = #{"apple" => 3, "pear" => 2},
io:format("~p ~p ~p~n",
[maps:get(name, Person), maps:get(age, Older), maps:get("apple", Counts)]).module Main exposing (main)
import Dict
import Html exposing (Html, text)
main : Html msg
main =
let
person =
{ name = "Ada", age = 36 }
older =
{ person | age = 37 }
counts =
Dict.fromList [ ( "apple", 3 ), ( "pear", 2 ) ]
in
text
(person.name
++ " "
++ String.fromInt older.age
++ " "
++ Debug.toString (Dict.get "apple" counts)
)
A record has a fixed field set fixed by its type, so
person.age is checked and { person | nickname = "A" } does not compile — the update syntax cannot add a field, only change one, which is exactly Erlang's := against => distinction promoted to a compile-time check. A Dict has keys of one type and values of one type, and Dict.get returns a Maybe because the key might be absent, where maps:get/2 raises badkey. Choosing between them is the useful discipline: if you can list the fields, it is a record.Nothing to Crash
There is no throw, no catch, and nothing to crash
Erlang already prefers the
{ok, _} / {error, _} tuple to exceptions, so Result will feel familiar. What is new is that in Elm there is no alternative: no throw, no error/1, no exit/1, and no try.ParseAge = fun(Input) ->
try list_to_integer(Input) of
Age -> {ok, Age}
catch
error:badarg -> {error, "not a number: " ++ Input}
end
end,
Describe = fun
({ok, Age}) -> integer_to_list(Age);
({error, Reason}) -> "rejected: " ++ Reason
end,
io:format("~s / ~s~n", [Describe(ParseAge("36")), Describe(ParseAge("abc"))]).module Main exposing (main)
import Html exposing (Html, text)
parseAge : String -> Result String Int
parseAge input =
case String.toInt input of
Just age ->
Ok age
Nothing ->
Err ("not a number: " ++ input)
describe : Result String Int -> String
describe result =
case result of
Ok age ->
String.fromInt age
Err reason ->
"rejected: " ++ reason
main : Html msg
main =
text (describe (parseAge "36") ++ " / " ++ describe (parseAge "abc"))
So a function can only report failure by returning it, and the type says so —
Result String Int is visible to every caller. Note what the Erlang column had to do: list_to_integer/1 raises rather than returning a tuple, so the tagged-tuple convention has to be added by hand with a try. Elm's String.toInt returns a Maybe to begin with, because a standard library with no exceptions cannot do otherwise. That is the general shape: the convention you follow by discipline in Erlang is the only thing available here.Chaining steps that can each fail
When several steps can each fail, Erlang either nests
case expressions or leans on a clause chain like the one here. Result.andThen runs the next step only if the previous succeeded and passes any Err straight through.ParsePositive = fun(Input) ->
case catch list_to_integer(Input) of
{'EXIT', _} -> {error, "not a number"};
Value when Value > 0 -> {ok, Value};
_ -> {error, "not positive"}
end
end,
Describe = fun
({ok, Value}) -> integer_to_list(Value);
({error, Reason}) -> Reason
end,
io:format("~s / ~s / ~s~n",
[Describe(ParsePositive("12")),
Describe(ParsePositive("0")),
Describe(ParsePositive("abc"))]).module Main exposing (main)
import Html exposing (Html, text)
parsePositive : String -> Result String Int
parsePositive input =
String.toInt input
|> Result.fromMaybe "not a number"
|> Result.andThen
(\value ->
if value > 0 then
Ok value
else
Err "not positive"
)
describe : Result String Int -> String
describe result =
case result of
Ok value ->
String.fromInt value
Err reason ->
reason
main : Html msg
main =
text
(describe (parsePositive "12")
++ " / "
++ describe (parsePositive "0")
++ " / "
++ describe (parsePositive "abc")
)
The Elm pipeline lists the steps in order and handles failure once, at the end.
Result.fromMaybe is the adapter that turns "there was nothing" into "there was nothing, and here is why", which is the everyday job of attaching a reason to a Maybe; Result.map is the version for a step that cannot itself fail. Erlang's maybe expression, added in OTP 25, is the closest thing the language has to this and is worth knowing if you have not met it — it exists for exactly the same reason.Let it crash has no counterpart
This is the philosophical center of the comparison. "Let it crash" is Erlang's answer to unexpected input: do not write defensive code, let the process die, and have a supervisor restart it from a state you trust.
% The Erlang answer to an unexpected value: do not defend, just crash.
% A supervisor restarts the process from a known-good state.
Parent = self(),
spawn(fun() ->
Parent ! started,
error(deliberate_failure)
end),
receive
started -> io:format("the worker started and then died~n")
after 1000 -> io:format("timed out~n")
end.module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
-- There is no process to die, no supervisor to restart it, and no
-- way to raise. The failures "let it crash" absorbs are the ones
-- the compiler has already eliminated: no badmatch, no case_clause,
-- no function_clause, no badarg, no undefined reaching a callee.
text "nothing here can crash"
Elm has neither half of that. There are no processes, so nothing can die in isolation, and no supervisors, so nothing would restart it — and in a browser tab there is nothing meaningful to restart anyway. What replaces it is the other end of the same problem: the failures a supervisor exists to absorb are largely
badmatch, case_clause, function_clause and badarg, and every one of those has been made unrepresentable by the type system. Neither approach is the better one in the abstract; they are answers to different deployment shapes, and the Erlang one is unarguably better for a system that must stay up for years.No Processes At All
No spawn, no send, no receive
The three primitives an Erlang developer thinks in —
spawn, ! and receive — have no counterpart. Elm is single-threaded, and there is no way to create a unit of concurrency.Parent = self(),
spawn(fun() ->
receive
{ping, From} -> From ! {pong, "PING"}
end
end) ! {ping, Parent},
receive
{pong, Reply} -> io:format("got ~s~n", [Reply])
after 1000 -> io:format("timed out~n")
end.module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
-- There is no spawn, no !, no receive, and no self().
-- Elm is single-threaded in a browser tab, and concurrency is
-- expressed as effects the runtime performs, not as processes.
text "one thread, and it is not yours to schedule"
What replaces them is narrower and worth understanding on its own terms. Concurrency in Elm means asking the runtime to do several things and receiving each answer as a message into
update — an HTTP request is a Cmd value the runtime performs, and its response arrives as a Msg. That is a mailbox with exactly one process reading it, and the process is the application. Genuine parallelism means a Web Worker on the JavaScript side, reached through a port. For a front end this is rarely a real constraint; for anything resembling what you use Erlang for, it is a wall.The Elm Architecture is one gen_server
You already know this pattern. State held in a loop, a closed set of messages, a function computing the next state from the current one and a message — The Elm Architecture is a
gen_server with a view attached. The Elm column is interactive; press the buttons.% The gen_server shape, written with a named fun since the shell
% has no modules: state as an argument, a receive loop, a message
% per thing that can happen.
Loop = fun L(State) ->
receive
{increment, From} -> From ! State + 1, L(State + 1);
{decrement, From} -> From ! State - 1, L(State - 1)
end
end,
Counter = spawn(fun() -> Loop(0) end),
Counter ! {increment, self()},
receive V1 -> io:format("~p ", [V1]) after 1000 -> ok end,
Counter ! {increment, self()},
receive V2 -> io:format("~p~n", [V2]) after 1000 -> ok end.module Main exposing (main)
import Browser
import Html exposing (Html, button, div, span, text)
import Html.Attributes exposing (style)
import Html.Events exposing (onClick)
type alias Model =
{ count : Int }
type Msg
= Increment
| Decrement
update : Msg -> Model -> Model
update message model =
case message of
Increment ->
{ model | count = model.count + 1 }
Decrement ->
{ model | count = model.count - 1 }
view : Model -> Html Msg
view model =
div [ style "font-family" "system-ui, sans-serif", style "font-size" "20px" ]
[ button [ onClick Decrement ] [ text "-" ]
, span [ style "padding" "0 12px" ] [ text (String.fromInt model.count) ]
, button [ onClick Increment ] [ text "+" ]
]
main : Program () Model Msg
main =
Browser.sandbox { init = { count = 0 }, update = update, view = view }
Read the correspondence directly:
Model is the loop state, Msg is the set of messages the server accepts, and update is handle_call — with the crucial difference that Msg -> Model -> Model is a complete description, so update cannot perform an effect the way a handle_call can. When an effect is needed the signature becomes Msg -> Model -> ( Model, Cmd Msg ) and the Cmd is a value the runtime executes, with the reply arriving as another Msg. That is a gen_server whose side effects are data. And because Msg is a closed custom type, there is no equivalent of an unmatched handle_info quietly accumulating in a mailbox.Effects are values the runtime performs
Any Erlang function may send a message, write a file, hit a database or log a line, and nothing in
LogAndDouble's shape reveals it. An Elm signature is a complete description of what a function does.% An Erlang function may do anything, and its shape says nothing:
LogAndDouble = fun(Value) ->
io:format("doubling ~p~n", [Value]),
Value * 2
end,
io:format("~p~n", [LogAndDouble(21)]).module Main exposing (main)
import Html exposing (Html, text)
double : Int -> Int
double value =
-- There is nowhere to put a log line, a file write or an HTTP call.
-- The signature says Int -> Int, and that is exhaustively what it does.
value * 2
main : Html msg
main =
text (String.fromInt (double 21))
Effects still happen — they are
Cmd values returned from update and performed by the runtime, so an HTTP request is a value describing a request rather than a call that makes one. For someone maintaining code the consequence is large: a function of type Int -> Int cannot have surprised you, so reviewing it means reading it, and testing it needs no mocks and no started applications. The cost is that a quick io:format dropped in to debug something is not available; Debug.log exists for exactly that and is stripped from optimized builds.Rendering HTML
Markup is a typed value
The Elm column renders live in the preview pane; the Erlang column prints the iodata it built. Elm has no template language —
div [ attributes ] [ children ] is an ordinary function call producing a typed Html value.% Erlang builds markup as iodata and hopes it is well-formed.
Name = "Ada Lovelace",
Role = "Founder",
Html = ["<div style=\"padding:16px;background:#A90533;color:white\">",
"<h2 style=\"margin:0 0 4px\">", Name, "</h2>",
"<p style=\"margin:0;opacity:0.85\">", Role, "</p>",
"</div>"],
io:format("~s~n", [Html]).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" "#A90533"
, 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" ]
]
Erlang's iodata list is a genuinely good way to assemble output — nothing is copied until it is written — and it is also just a nested list of bytes, so an unclosed tag is a runtime-invisible bug and an interpolated value containing
<script> is markup. In Elm the structure is well-formed by construction because you are calling functions, and text produces a text node, so a string is never parsed as HTML and there is no escaping step to remember. What you give up is the ability to hand a template file to somebody who does not write Elm.A loop over markup is List.map
The children of an Elm element are a
List Html, so building them is List.map and nothing new has to be learned. The Erlang comprehension does the same job producing nested iodata.Posts = ["Shipping the release", "Why no undefined", "The update loop"],
Items = [["<li>", Post, "</li>"] || Post <- Posts],
io:format("~s~n", [["<ul style=\"font-family:system-ui\">", Items, "</ul>"]]).module Main exposing (main)
import Html exposing (Html, li, text, ul)
import Html.Attributes exposing (style)
posts : List String
posts =
[ "Shipping the release", "Why no undefined", "The update loop" ]
main : Html msg
main =
ul [ style "font-family" "system-ui, sans-serif" ]
(List.map (\post -> li [] [ text post ]) posts)
That is the whole templating story, which is why Elm has no template syntax at all. Every tool that works on lists works on markup:
List.filter to hide items, List.sortBy to order them, ++ to append a footer row, and an ordinary function returning Html msg to factor out a partial — with a type signature, so calling it wrongly does not build. The Erlang version is close in spirit and differs in exactly one way that matters: its result is bytes, so nothing downstream can check it.Decoding JSON
A decoder states what you expect
A JSON library in Erlang hands you a term shaped like whatever arrived — binaries as keys, and no promise that a field is present or numeric. An Elm decoder is a description of what you expect, and running it either produces your type or explains what was wrong.
% jsx or jsone hands back a term shaped like the payload.
% The shell has no library loaded, so this is what you would get:
Decoded = #{<<"name">> => <<"Ada">>, <<"age">> => 36},
Name = maps:get(<<"name">>, Decoded),
Age = maps:get(<<"age">>, Decoded),
io:format("~s is ~p~n", [Name, Age]).module Main exposing (main)
import Html exposing (Html, text)
import Json.Decode as Decode
type alias Person =
{ name : String
, age : Int
}
personDecoder : Decode.Decoder Person
personDecoder =
Decode.map2 Person
(Decode.field "name" Decode.string)
(Decode.field "age" Decode.int)
main : Html msg
main =
case Decode.decodeString personDecoder "{\"name\": \"Ada\", \"age\": 36}" of
Ok person ->
text (person.name ++ " is " ++ String.fromInt person.age)
Err error ->
text ("bad payload: " ++ Decode.errorToString error)
The cost is real: a decoder is more code than
jsx:decode/1, and for three fields it feels like ceremony. What it buys is that the boundary is the only place a shape can surprise you. A server that renames age to years makes maps:get/2 raise badkey somewhere downstream, on whichever request first hits that path; here the decode fails immediately with Expecting an OBJECT with a field named `age`, and Ok/Err forces you to decide what happens. Past that boundary every field is guaranteed present and correctly typed for the rest of the program.An optional field says so in the type
Optionality has to be declared in two places that agree:
Maybe String in the type, and Decode.maybe in the decoder. Neither is inferred from what happened to arrive.Decoded = #{<<"name">> => <<"Ada">>},
Nickname = maps:get(<<"nickname">>, Decoded, undefined),
case Nickname of
undefined -> io:format("~s (no nickname)~n", [maps:get(<<"name">>, Decoded)]);
Value -> io:format("~s (~s)~n", [maps:get(<<"name">>, Decoded), Value])
end.module Main exposing (main)
import Html exposing (Html, text)
import Json.Decode as Decode
type alias Person =
{ name : String
, nickname : Maybe String
}
personDecoder : Decode.Decoder Person
personDecoder =
Decode.map2 Person
(Decode.field "name" Decode.string)
(Decode.maybe (Decode.field "nickname" Decode.string))
main : Html msg
main =
case Decode.decodeString personDecoder "{\"name\": \"Ada\"}" of
Ok person ->
text (person.name ++ " (" ++ Maybe.withDefault "no nickname" person.nickname ++ ")")
Err error ->
text (Decode.errorToString error)
That is the difference between "this field was missing this time" and "this field is optional". Erlang's three-argument
maps:get/3 supplies the default at the point of use, so the same field can be treated as required in one place and optional in another with nothing noticing — and a required field that is absent looks exactly like a genuinely optional one. Here the type carries the fact everywhere the value goes. Decode.oneOf handles a field arriving in more than one shape, and Decode.map3 through map8 extend this to larger records; beyond that the community uses NoRedInk/elm-json-decode-pipeline.⚠ Gotchas for Erlang Developers
There is one string type, and it is not a list
Erlang has two string representations and a well-known cost to choosing between them: a double-quoted literal is a list of code points, and a binary literal is bytes. Elm has one
String type, and it is neither.List = "abc",
Binary = <<"abc">>,
io:format("~p~n", [is_list(List)]),
io:format("~p~n", [length(List)]),
io:format("~p~n", [hd(List)]),
io:format("~p~n", [byte_size(Binary)]).module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
-- One String type. It is not a List, so there is no head,
-- no cons pattern, and no [$a, $b, $c] underneath.
text
(String.fromInt (String.length "abc")
++ " "
++ Debug.toString (String.toList "abc")
++ " "
++ Debug.toString (String.uncons "abc")
)
The practical relief is large — no
list_to_binary, no unicode:characters_to_list, no deciding which representation an API wants, and no ~s versus ~p confusion when a "string" turns out to be a list of small integers. The practical loss is that String is opaque: you cannot pattern-match a string the way you match a list, so String.uncons is the explicit way to get the first character and the rest, returning a Maybe ( Char, String ). String.toList gives you a List Char when you genuinely want to work character by character, and String.fromList goes back.Integers and floats do not mix
Erlang's
/ always produces a float and div is integer division, which is the same split Elm makes with / and //. The difference is that Erlang will happily add an integer to a float and Elm will not.io:format("~p~n", [10 / 4]),
io:format("~p~n", [10 div 4]),
io:format("~p~n", [10 rem 4]),
io:format("~p~n", [1 + 1.5]).module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
-- 1 + 1.5 does not compile: Int and Float are different types.
text
(String.fromFloat (10 / 4)
++ " "
++ String.fromInt (10 // 4)
++ " "
++ String.fromInt (modBy 4 10)
++ " "
++ String.fromFloat (toFloat 1 + 1.5)
)
Elm has no numeric promotion:
Int and Float are separate types, and mixing them needs toFloat or round/floor/truncate going the other way. That is stricter than Erlang and stricter than almost anything else, and it catches a real class of bug — the average that came out as an integer because both operands happened to be. Note the argument order of modBy: it is modBy divisor value, the reverse of rem's reading order, deliberately so that modBy 2 partially applies. Elm also has a number type variable, so a literal 10 can be either until context decides.No hot code loading, and no dynamic anything
Erlang can call a function whose module is decided at run time, ask whether a function exists, and load a new version of a module into a running system without dropping a connection. Elm can do none of it.
% Erlang can load a new version of a module into a running system,
% and a fully qualified call crosses to the new version:
io:format("~p~n", [erlang:function_exported(lists, map, 2)]),
Module = lists,
io:format("~p~n", [Module:reverse([1, 2, 3])]).module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
-- There is no apply/3, no Module:Function(Args), no code:load_file,
-- and no way to name a function at run time. Every call is resolved
-- and checked when the program is built.
text "every call site is known at compile time"
This is the capability gap that runs furthest in Erlang's favor, and it is not a small one — hot code loading is why Erlang systems are measured in years of uptime. Nothing in Elm corresponds, because the entire call graph is resolved at compile time; that is also precisely what makes the dead-code elimination so effective and the type guarantees whole-program rather than per-module. During development, the Elm debugger and
elm-live give you a fast reload with the model preserved across it, which covers the everyday case and none of the operational one.Enforced versioning, and no NIFs
Two ecosystem rules, and they are the pair that decides whether Elm fits a project. The version number is computed rather than promised, and no package may contain JavaScript.
% rebar.config declares ranges, and you trust the maintainer.
% A NIF or a port driver can reach anything the machine can do —
% and can also take the whole VM down with it.
io:format("dependency management by convention~n").module Main exposing (main)
import Html exposing (Html, text)
main : Html msg
main =
-- elm install elm/http
--
-- The registry DIFFS the public API of the new version against the
-- old and refuses a version number that understates the change.
-- And no package may contain JavaScript: interop goes through a
-- port, so nothing a package does can throw into your program.
text "elm install elm/http"
When you publish an Elm package the tooling compares the new public API against the previous release and computes the bump itself — add a function and it must be minor, change a type signature and it must be major. You cannot publish a patch release containing a breaking change, because the tool will not let the number be that. The second rule is the restrictive one: reaching JavaScript means a port, where you send a value out and write the JavaScript side yourself, and there is no equivalent of a NIF to escape into. That is what makes the no-runtime-exceptions guarantee hold for the whole program — and it is also why the ecosystem is thousands of packages rather than hundreds of thousands.