Output & Running
Hello, World
io:format("Hello, World!~n"). main :: IO ()
main = putStrLn "Hello, World!" Erlang prints with
io:format/1 and the ~n directive for the newline. Every Haskell program's entry point is main :: IO () — a type signature declaring that main performs side effects and produces no meaningful value — and putStrLn appends the newline automatically.Printing a value for inspection
Value = {ok, [1, 2, 3]},
io:format("~p~n", [Value]). main :: IO ()
main = print (Just [1, 2, 3]) Erlang's
~p ("pretty print") directive renders any term for humans. Haskell's print does the same job, but through the Show typeclass — it only works on values whose type has derived or implemented Show, which is almost everything built-in.Dynamic vs. Static Types
Erlang trusts the pattern match; Haskell checks first
% No type declaration anywhere — this only fails when it actually RUNS:
Add = fun(X, Y) -> X + Y end,
io:format("~p~n", [Add(2, 3)]). add :: Int -> Int -> Int
add x y = x + y
main :: IO ()
main = print (add 2 3) This is the single biggest adjustment for an Erlang developer. Erlang has no compile-time type checking at all —
Add("two", 3) would compile just fine and only blow up when that line actually executes. Haskell's type signature Int -> Int -> Int means add("two", 3) is rejected by the compiler before the program ever runs, not discovered in production.Types without writing them out
% Erlang never needs — or has — type annotations:
Double = fun(X) -> X * 2 end,
io:format("~p~n", [Double(21)]). double x = x * 2
main :: IO ()
main = print (double 21) Haskell does not actually require you to write every type signature — its Hindley-Milner type inference deduces
double :: Num a => a -> a on its own, the same way you never had to specify a type for Double in Erlang. The difference is what happens after inference: Haskell locks that inferred type in and enforces it everywhere the function is used.Optional checking vs. mandatory checking
% Erlang's Dialyzer can catch SOME type errors, but only if you
% run it separately and add -spec annotations — it is opt-in, not enforced:
% -spec add(integer(), integer()) -> integer().
Result = 2 + 3,
io:format("~p~n", [Result]). add :: Int -> Int -> Int
add x y = x + y
main :: IO ()
main = print (add 2 3) Erlang's closest thing to Haskell's type checker is Dialyzer, a separate static-analysis tool that reads optional
-spec annotations — but it is advisory, runs as its own build step, and a project can ship with zero specs. In Haskell, type checking is not a linter you can skip; the compiler refuses to produce a program at all if the types do not line up.Pattern Matching
Multiple clauses, chosen by matching
Describe = fun
Describe(0) -> "zero";
Describe(N) when N > 0 -> "positive";
Describe(_) -> "negative"
end,
io:format("~s~n", [Describe(-5)]). describe :: Int -> String
describe 0 = "zero"
describe n
| n > 0 = "positive"
| otherwise = "negative"
main :: IO ()
main = putStrLn (describe (-5)) Both languages pick which "clause" (Erlang) or "equation" (Haskell) runs by matching the call against each in order, top to bottom. The shape is so close that an Erlang developer can read Haskell equations on sight — the only new idea is the
| guard syntax, covered next.Destructuring a tuple
Result = {ok, 42},
{ok, Value} = Result,
io:format("~p~n", [Value]). main :: IO ()
main = do
let result = (True, 42)
let (_, value) = result
print value Both languages let you destructure a tuple directly in a binding. Where they differ: Erlang tuples are heterogeneous and untyped, so
{ok, 42} and {ok, "hi"} are both perfectly ordinary 2-tuples; Haskell tuples are typed by position, so (Bool, Int) and (Bool, String) are genuinely different types that cannot be swapped in by accident.A `case` expression
Classify = fun(N) ->
case N of
0 -> zero;
N when N > 0 -> positive;
_ -> negative
end
end,
io:format("~p~n", [Classify(5)]). classify :: Int -> String
classify n = case n of
0 -> "zero"
_ | n > 0 -> "positive"
| otherwise -> "negative"
main :: IO ()
main = putStrLn (classify 5) Erlang's
case Expr of Pattern -> Body end and Haskell's case Expr of Pattern -> Body are close enough to be nearly interchangeable reading material — both let you attach a guard to an individual pattern arm rather than defining a whole new function clause.Guards
`when` becomes `|`
CanVote = fun(Age) when Age >= 18 -> true;
(_) -> false
end,
io:format("~p~n", [CanVote(20)]). canVote :: Int -> Bool
canVote age
| age >= 18 = True
| otherwise = False
main :: IO ()
main = print (canVote 20) A direct rename: Erlang's
when Condition after a clause head becomes Haskell's | Condition = ... after an equation head. otherwise in Haskell plays exactly the role of Erlang's catch-all clause with no guard — it is simply defined as the atom True so it always matches last.Chaining several guard conditions
Grade = fun(Score) when Score >= 90 -> $A;
(Score) when Score >= 80 -> $B;
(_) -> $C
end,
io:format("~s~n", [[Grade(85)]]). grade :: Int -> Char
grade score
| score >= 90 = 'A'
| score >= 80 = 'B'
| otherwise = 'C'
main :: IO ()
main = print (grade 85) Both read top-to-bottom as a chain of "if this, then that; otherwise keep checking" — Erlang guards attached to separate clauses, Haskell guards attached to one equation. The visual rhythm (right-aligned conditions, right-aligned results) is nearly identical between the two.
Guards inside a `case`
Describe = fun(N) ->
case N of
X when X rem 2 =:= 0 -> even;
_ -> odd
end
end,
io:format("~p~n", [Describe(7)]). describe :: Int -> String
describe n = case n of
x | even x -> "even"
| otherwise -> "odd"
where even x = x `mod` 2 == 0
main :: IO ()
main = putStrLn (describe 7) Erlang can guard a
case arm with when exactly the way it guards a function clause. Haskell mirrors this with a guard on the case alternative — and this example shows Haskell's backtick syntax, x `mod` 2, for calling a two-argument function as an infix operator, which has no Erlang equivalent (Erlang's rem/div are already infix keywords).Tagged Tuples vs. Maybe/Either
`{ok, X}` becomes `Just x`
Lookup = fun(Key, List) ->
case lists:keyfind(Key, 1, List) of
{Key, Value} -> {ok, Value};
false -> error
end
end,
io:format("~p~n", [Lookup(b, [{a, 1}, {b, 2}])]). import qualified Data.Map as Map
lookupValue :: String -> Map.Map String Int -> Maybe Int
lookupValue key = Map.lookup key
main :: IO ()
main = print (lookupValue "b" (Map.fromList [("a", 1), ("b", 2)])) Erlang's convention of returning
{ok, Value} on success and a bare error (or {error, Reason}) on failure is exactly what Haskell's built-in Maybe type formalizes: Just value is {ok, Value}, and Nothing is the failure case. The difference is that Haskell's type system requires the caller to handle both cases — there is no way to accidentally treat a Maybe Int as a bare Int the way Erlang code can accidentally forget to check for error.`{error, Reason}` becomes `Left reason`
Divide = fun(_, 0) -> {error, divide_by_zero};
(X, Y) -> {ok, X div Y}
end,
io:format("~p~n", [Divide(10, 0)]). divide :: Int -> Int -> Either String Int
divide _ 0 = Left "divide by zero"
divide x y = Right (x `div` y)
main :: IO ()
main = print (divide 10 0) This is the same idea one level richer: where
Maybe only says "did it work," Either carries a reason on failure — Left reason corresponds directly to Erlang's {error, Reason}, and Right value corresponds to {ok, Value}. By convention Left is always the failure case in Haskell, the same way error/error tuples are always the failure case in idiomatic Erlang.Handling both cases is not optional
Divide = fun(_, 0) -> {error, divide_by_zero};
(X, Y) -> {ok, X div Y}
end,
% Nothing stops you from skipping the error branch — this crashes with
% a badmatch if Divide ever returns {error, _} instead of {ok, _}:
{ok, Value} = Divide(10, 2),
io:format("~p~n", [Value]). divide :: Int -> Int -> Either String Int
divide _ 0 = Left "divide by zero"
divide x y = Right (x `div` y)
main :: IO ()
main = case divide 10 2 of
Right value -> print value
Left reason -> putStrLn ("Error: " ++ reason) Nothing in Erlang forces you to handle the
error branch — skipping it just means a crash shows up later, at the exact unlucky moment a bad input reaches that assumption. Haskell's compiler will warn (and with the right settings, refuse to compile) if a case over an Either or Maybe does not cover every constructor — the "handle both outcomes" habit a careful Erlang developer already has becomes something the compiler double-checks for you.Lists
List literals and cons
[Head | Tail] = [1, 2, 3],
io:format("~p and ~p~n", [Head, Tail]). main :: IO ()
main = do
let (headValue : tailValues) = [1, 2, 3]
putStrLn (show headValue ++ " and " ++ show tailValues) The bracket-and-comma literal
[1, 2, 3] and the cons pattern are conceptually the same linked-list model in both languages — Erlang spells cons as [Head | Tail], Haskell as (headValue : tailValues), using : instead of |. One real difference: Haskell lists are homogeneous — every element must share one type — while Erlang lists happily mix atoms, numbers, and tuples.Map and filter
Doubled = lists:map(fun(N) -> N * 2 end, [1, 2, 3, 4, 5]),
Evens = lists:filter(fun(N) -> N rem 2 =:= 0 end, Doubled),
io:format("~p~n", [Evens]). main :: IO ()
main = do
let doubled = map (* 2) [1, 2, 3, 4, 5]
let evens = filter even doubled
print evens Both languages call these
map and filter, and both apply cleanly in a pipeline. Haskell's (* 2) is a "section" — a partially applied operator that reads as "multiply by 2" without needing an explicit lambda, and even is a built-in predicate where Erlang needs to spell out N rem 2 =:= 0.Folding a list
Total = lists:foldl(fun(N, Accumulator) -> N + Accumulator end, 0, [1, 2, 3, 4, 5]),
io:format("~p~n", [Total]). main :: IO ()
main = do
let total = foldl (+) 0 [1, 2, 3, 4, 5]
print total Erlang's
lists:foldl/3 and Haskell's foldl share the same name and the same argument order (function, initial accumulator, list) — a rare case where the standard library vocabulary lines up exactly. Haskell also offers the stricter, more efficient foldl' for large lists, avoiding the lazy thunk buildup foldl can accumulate.List Comprehensions
The closest syntax match on the whole page
Squares = [X * X || X <- [1, 2, 3, 4, 5]],
io:format("~p~n", [Squares]). main :: IO ()
main = do
let squares = [x * x | x <- [1, 2, 3, 4, 5]]
print squares This is the single closest syntactic match between the two languages anywhere in this comparison. Erlang's list comprehension syntax,
[Expr || Generator], is a direct descendant of Haskell's [expr | generator] — same reading order, same generator arrow, differing only in || versus |. An Erlang developer can read Haskell comprehensions with essentially no translation.Adding a filter condition
Evens = [X || X <- lists:seq(1, 10), X rem 2 =:= 0],
io:format("~p~n", [Evens]). main :: IO ()
main = do
let evens = [x | x <- [1 .. 10], x `mod` 2 == 0]
print evens Both put the filter condition after a comma, following the same generator. The range syntax differs slightly — Erlang's
lists:seq(1, 10) is a function call, while Haskell's [1 .. 10] is built-in range syntax — but the comprehension itself reads almost identically.Multiple generators (a cartesian join)
Pairs = [{X, Y} || X <- [1, 2], Y <- [a, b]],
io:format("~p~n", [Pairs]). main :: IO ()
main = do
let pairs = [(x, y) | x <- [1, 2], y <- "ab"]
print pairs Multiple generators, separated by commas, produce every combination in both languages — the second generator varies fastest, exactly the nested-loop order you would expect. This is the pattern behind the classic Prolog-style "generate every pair" idiom, expressed natively in both Erlang and Haskell without reaching for an external library.
Recursion
Factorial
Factorial = fun
Factorial(0) -> 1;
Factorial(N) when N > 0 -> N * Factorial(N - 1)
end,
io:format("~p~n", [Factorial(5)]). factorial :: Integer -> Integer
factorial 0 = 1
factorial n = n * factorial (n - 1)
main :: IO ()
main = print (factorial 5) A base-case clause and a recursive clause, in the same order, doing the same thing — this could nearly be a mechanical translation. Haskell's
Integer (as opposed to fixed-width Int) gives arbitrary precision the same way Erlang's integers do by default, so factorial 30 does not silently overflow in either language.Computing length by hand
Length = fun
Length([]) -> 0;
Length([_ | Tail]) -> 1 + Length(Tail)
end,
io:format("~p~n", [Length([a, b, c, d])]). myLength :: [a] -> Int
myLength [] = 0
myLength (_ : tail) = 1 + myLength tail
main :: IO ()
main = print (myLength "abcd") The base case for the empty list and the recursive case peeling off the head are identical in structure. Note the Haskell type signature
[a] -> Int: the lowercase a is a type variable, meaning this function works on a list of any single type — Erlang gets the equivalent generality for free simply by having no type system to restrict it.Tail recursion with an accumulator
SumList = fun
SumList([], Accumulator) -> Accumulator;
SumList([Head | Tail], Accumulator) -> SumList(Tail, Head + Accumulator)
end,
io:format("~p~n", [SumList([1, 2, 3, 4, 5], 0)]). sumList :: [Int] -> Int -> Int
sumList [] accumulator = accumulator
sumList (headValue : tailValues) accumulator = sumList tailValues (headValue + accumulator)
main :: IO ()
main = print (sumList [1, 2, 3, 4, 5] 0) The accumulator-passing style every Erlang developer already uses for stack-safe recursion transfers unchanged. The one asterisk: because Haskell is lazy by default, a naive accumulator can build up a chain of unevaluated additions (a "thunk") instead of a running total — real Haskell code often reaches for
foldl' (the strict fold) rather than hand-rolling this exact pattern, which the "Laziness" section covers.Higher-Order Functions & Currying
Functions as values
Apply = fun(Function, Value) -> Function(Value) end,
Increment = fun(X) -> X + 1 end,
io:format("~p~n", [Apply(Increment, 41)]). apply' :: (a -> b) -> a -> b
apply' function value = function value
increment :: Int -> Int
increment x = x + 1
main :: IO ()
main = print (apply' increment 41) Both languages treat functions as ordinary values that can be passed around, stored, and called through a variable — Erlang
funs and Haskell functions are equally first-class. The type signature (a -> b) -> a -> b is Haskell being explicit about something Erlang leaves implicit: the first argument must itself be a function from some type to another.Every function is curried — automatically
% Erlang funs have a fixed arity — there is no automatic partial application:
Add = fun(X, Y) -> X + Y end,
AddFive = fun(Y) -> Add(5, Y) end,
io:format("~p~n", [AddFive(10)]). add :: Int -> Int -> Int
add x y = x + y
main :: IO ()
main = do
let addFive = add 5
print (addFive 10) Here the languages genuinely diverge. Erlang's
Add(5, Y) IS a two-argument call — to get a one-argument function you must wrap it in a new fun by hand, as shown. In Haskell, add 5 alone is already valid: every function is secretly a chain of one-argument functions (Int -> (Int -> Int)), so supplying fewer arguments than expected just returns another function, no wrapping required.Composing functions
Compose = fun(F, G) -> fun(X) -> F(G(X)) end end,
AddOneThenDouble = Compose(fun(X) -> X * 2 end, fun(X) -> X + 1 end),
io:format("~p~n", [AddOneThenDouble(5)]). addOneThenDouble :: Int -> Int
addOneThenDouble = (* 2) . (+ 1)
main :: IO ()
main = print (addOneThenDouble 5) Erlang has no built-in composition operator, so combining two functions means writing the wrapping
fun yourself. Haskell's . operator does exactly that composition natively — f . g means "apply g, then apply f to the result" — turning composition into a first-class piece of syntax rather than a pattern you build by hand.Algebraic Data Types & Records
Records vs. a proper data type
% Erlang records are compile-time sugar for tagged tuples — no runtime
% checking beyond field-name shape:
Point = {point, 3, 4},
{point, X, Y} = Point,
io:format("~p and ~p~n", [X, Y]). data Point = Point { x :: Int, y :: Int } deriving (Show)
main :: IO ()
main = do
let point = Point { x = 3, y = 4 }
print (x point, y point) Erlang's
-record(point, {x, y}). desugars to a tagged tuple at compile time — it is purely a naming convenience, and nothing stops a stray tuple of the right shape from being mistaken for a record elsewhere in the code. Haskell's data Point = Point { x :: Int, y :: Int } introduces a genuinely distinct type: a Point can never be confused with an unrelated two-field structure, even one that happens to also hold two Ints.Tagged tuples vs. a sum type
Shapes = [{circle, 5}, {rectangle, 3, 4}],
Areas = lists:map(fun
({circle, Radius}) -> 3.14159 * Radius * Radius;
({rectangle, Width, Height}) -> Width * Height
end, Shapes),
io:format("~p~n", [Areas]). data Shape = Circle Double | Rectangle Double Double
area :: Shape -> Double
area (Circle radius) = 3.14159 * radius * radius
area (Rectangle width height) = width * height
main :: IO ()
main = print (map area [Circle 5, Rectangle 3 4]) Erlang models "one of several shapes" as a list of differently-shaped tagged tuples, distinguished at pattern-match time by their first element and arity. Haskell's
data Shape = Circle Double | Rectangle Double Double makes this a genuine sum type — the compiler knows there are exactly two constructors, and if a match on Shape ever misses one, it can warn you at compile time rather than crashing on an unmatched Erlang tuple shape at runtime.Atoms vs. nullary constructors
Status = ok,
io:format("~p~n", [Status]). data Status = Ok | Failed deriving (Show, Eq)
main :: IO ()
main = print Ok An Erlang atom like
ok is an unstructured, untyped constant that could be any of thousands of possible atoms in the whole runtime. Haskell's data Status = Ok | Failed gives you the same "one of a fixed small set of named things" idea, but scoped and typed — a Status value can only ever be Ok or Failed, never an unrelated atom that happened to leak in from somewhere else in the program.Laziness vs. Strict Evaluation
Erlang evaluates eagerly; Haskell does not
% Erlang: both arguments are evaluated immediately, in order,
% before the function body ever runs — so this crashes, caught here
% with try/catch so the crash is visible instead of halting the page:
First = fun(A, _B) -> A end,
try First(42, 1 div 0) of
Result -> io:format("~p~n", [Result])
catch
error:badarith -> io:format("crashed: badarith~n")
end. first :: a -> b -> a
first a _ = a
main :: IO ()
main = print (first 42 (div 1 0)) Erlang's version genuinely crashes: both arguments are computed before
First is even entered, so 1 div 0 raises before the function has a chance to ignore it. Haskell's equivalent succeeds and prints 42 — laziness means div 1 0 is never evaluated at all, because the function body never actually looks at its second argument. This is one of the largest behavioral differences an Erlang developer will encounter.Infinite lists are ordinary values
% Erlang has no lazy lists — this would need an explicit generator
% function, called on demand, rather than an infinite value:
Naturals = lists:seq(1, 5),
io:format("~p~n", [Naturals]). main :: IO ()
main = do
let naturals = [1 ..]
print (take 5 naturals) Erlang's
lists:seq/2 must be given a finite end, because Erlang lists are ordinary eagerly-built data. Haskell's [1 ..] is a genuinely infinite list that costs nothing to write down — only take 5 forces the first five elements to actually be computed. Laziness turns "generate infinitely, consume finitely" into a completely ordinary pattern, where Erlang would need a dedicated recursive generator process to get the same effect.IO & Purity
Erlang mixes side effects in freely
Greet = fun(Name) ->
io:format("Hello, ~s!~n", [Name]),
ok
end,
Greet("Ada"). greet :: String -> IO ()
greet name = putStrLn ("Hello, " ++ name ++ "!")
main :: IO ()
main = greet "Ada" An Erlang function can print, send a message, or write a file in the middle of otherwise-pure logic, with nothing in its signature warning the caller. Haskell's type signature
String -> IO () makes side effects visible in the type itself — any function that touches the outside world is tagged IO, and the compiler will not let an IO action quietly hide inside code that claims to be pure.`do` notation for sequencing
Greet = fun() ->
io:format("What is your name?~n"),
Name = "Ada", % Erlang has no stdin read in this sandboxed runtime
io:format("Hello, ~s!~n", [Name])
end,
Greet(). main :: IO ()
main = do
putStrLn "What is your name?"
let name = "Ada" -- reading real stdin needs a live terminal, not this sandbox
putStrLn ("Hello, " ++ name ++ "!") Erlang sequences side-effecting expressions with an ordinary comma, the same punctuation as every other expression. Haskell needs a dedicated syntax,
do notation, specifically because ordinary Haskell expressions have no inherent order — do is what lets IO actions read top-to-bottom like an imperative script despite the language being pure everywhere else.A pure helper next to an impure one
% Erlang: nothing distinguishes a "pure" function from one with side effects —
Square = fun(X) -> X * X end, % happens to be pure
PrintSquare = fun(X) -> io:format("~p~n", [Square(X)]) end, % has a side effect
PrintSquare(6). square :: Int -> Int
square x = x * x
printSquare :: Int -> IO ()
printSquare x = print (square x)
main :: IO ()
main = printSquare 6 Both examples do the same work, but only Haskell's type signatures document the distinction:
square :: Int -> Int is guaranteed pure (same input, same output, forever, no exceptions), while printSquare :: Int -> IO () is honest about touching the outside world. Erlang has no equivalent marker — you learn which functions are safe to call repeatedly only by reading their bodies.Concurrency
Actors vs. lightweight threads
% Erlang's concurrency primitive is the process — millions of them,
% isolated, communicating only by message passing:
% Pid = spawn(fun() -> io:format("running in a process~n") end).
io:format("~p~n", [self()]). import Control.Concurrent (forkIO, threadDelay)
main :: IO ()
main = do
_ <- forkIO (putStrLn "running on a lightweight Haskell thread")
threadDelay 100000 Erlang's concurrency story is famously the process — cheap, isolated, sharing nothing, communicating only through message passing, with a supervisor tree to restart failed ones. Haskell's
forkIO gives similarly lightweight green threads, but they are not isolated the way Erlang processes are: Haskell threads share memory by default (using MVars or software transactional memory to coordinate), so the fault-isolation "let it crash" story is not automatic the way it is on the BEAM.Message passing vs. shared, synchronized state
% Erlang: two processes coordinate purely by sending messages —
% no shared memory exists between them at all.
self() ! {greeting, "hello"},
receive
{greeting, Message} -> io:format("~s~n", [Message])
end. import Control.Concurrent.MVar
main :: IO ()
main = do
box <- newMVar "hello"
message <- readMVar box
putStrLn message Erlang processes never share memory — the only way to communicate is
! (send) and receive, and this is true whether the processes are on the same machine or different ones, which is exactly what makes Erlang distribution transparent. Haskell's MVar is a shared, mutable, synchronized box that multiple threads can read and write directly — a fundamentally different (and more familiar-to-most-languages) concurrency model than Erlang's share-nothing actors.Two Roads to Reliability
"Let it crash" needs no type system
% Erlang: don't defend against every possible bad input —
% let the process crash, and let a supervisor restart it clean:
Divide = fun(X, Y) -> X div Y end,
% Divide(10, 0) would crash this process — and that's considered FINE,
% because a supervisor elsewhere restarts it in a known-good state.
io:format("~p~n", [Divide(10, 2)]). divide :: Int -> Int -> Maybe Int
divide _ 0 = Nothing
divide x y = Just (x `div` y)
main :: IO ()
main = print (divide 10 2) Erlang's reliability philosophy does not try to prevent every possible runtime error — it accepts that failures happen, isolates each computation in its own process, and relies on a supervision tree to restart failed processes into a known-good state. This works precisely because Erlang has no static type system standing in the way of writing quick, disposable code that is expected to sometimes fail.
"Make illegal states unrepresentable"
% Erlang: nothing stops a divide-by-zero from being ATTEMPTED —
% the crash is the safety net, caught here with try/catch so it is
% visible instead of halting the page (in real code, a supervisor
% would restart the crashed process instead):
try 10 div 0 of
Result -> io:format("~p~n", [Result])
catch
error:badarith -> io:format("crashed: badarith~n")
end. divide :: Int -> Int -> Maybe Int
divide _ 0 = Nothing
divide x y = Just (x `div` y)
main :: IO ()
main = case divide 10 0 of
Just value -> print value
Nothing -> putStrLn "cannot divide by zero" Haskell's philosophy is the mirror image: design the types so the bad state cannot be constructed in the first place, and let the compiler enforce that every caller handles the case where it might have failed. Neither philosophy is strictly "better" — Erlang trades compile-time guarantees for extreme runtime fault isolation and hot code reloading; Haskell trades development-time friction for eliminating whole categories of bugs before the program ever runs. Both were built by people who took reliability seriously, from opposite ends of the problem.
Gotchas for Erlang Developers
`=` means something different than it looks like
X = 40,
% X = 41 would be a {badmatch,41} error — Erlang variables bind once:
io:format("~p~n", [X]). main :: IO ()
main = do
let x = 40
-- 'let x = 41' below would just shadow the outer x in a NEW scope,
-- not reassign it — Haskell has no mutable variables at all:
print x Erlang variables genuinely cannot be rebound within the same scope — attempting it is a match failure, not silent mutation. Haskell has no assignment operator at all in the imperative sense;
let x = 41 inside a nested scope merely shadows the outer x with a new, unrelated binding, rather than either rebinding it (impossible) or erroring (also different from Erlang). Both languages ultimately guarantee "no mutation," but they enforce it through different mechanisms and fail differently when you fight them.A "string" means something different again
% Erlang: "hello" is a LIST of character codes:
Value = "hello",
io:format("~w~n", [Value]). % shows [104,101,108,108,111] main :: IO ()
main = do
let value = "hello" :: String -- String IS [Char], a list of Char values
print value Here the two languages actually agree more than either agrees with, say, Elixir: Haskell's built-in
String type is defined as [Char], a plain linked list of characters — conceptually the very same representation as Erlang's charlist. The visible difference is only in how each language's default printer renders it: Haskell's Show instance for String prints it back as quoted text, while Erlang's ~w reveals the raw list of codes and only ~p/~s reprint it as readable text.The type system cannot save you from everything
% Both languages can still fail at runtime on a genuinely partial function —
% Erlang: hd([]) raises badarg.
io:format("~p~n", [hd([1, 2, 3])]). main :: IO ()
main = do
-- head [] would still throw at runtime — Haskell's type system
-- does not (by default) track "this list is non-empty":
print (head [1, 2, 3]) It is worth remembering Haskell's guarantees have edges:
head on an empty list crashes at runtime exactly the way Erlang's hd([]) does, because the ordinary list type carries no compile-time proof of non-emptiness. Serious Haskell code reaches for a total alternative (listToMaybe, or a non-empty list type) the same deliberate way careful Erlang code checks for an empty list before calling hd/1 — the type system reduces the surface area of possible bugs, but "reduces" is not "eliminates."