Compiled, Pure & Typed
Hello, World
Every Haskell cell on this page is a complete program: a
main with the type signature IO (), which the runtime calls once. There is no fragment wrapper here — what you see is the whole file.IO.puts("Hello, World!") main :: IO ()
main = putStrLn "Hello, World!" The
:: line is a type signature, and reading IO () as "an action that performs input/output and returns nothing useful" is the right instinct. Signatures are optional almost everywhere — inference is thorough enough to do without them — but writing them on top-level definitions is universal practice, because they document and constrain at the same time.@spec → a signature that is enforced
Elixir lets you describe types with
@spec, but the compiler ignores them; only a separate Dialyzer run looks. In Haskell the signature is checked at compile time, every time.defmodule Adder do
@spec add(integer(), integer()) :: integer()
def add(left, right), do: left + right
end
IO.inspect(Adder.add(1, 2))
try do
Adder.add("one", "two")
rescue
ArithmeticError -> IO.puts("ArithmeticError — the @spec did not stop it")
end add :: Int -> Int -> Int
add left right = left + right
main :: IO ()
main = do
print (add 1 2)
-- add "one" "two" -- Couldn't match type '[Char]' with 'Int'
putStrLn "the second call cannot be written at all" The trade will be familiar if you have used Dialyzer and wished it were mandatory. What you may not expect is how little annotation the strictness costs: type inference is complete enough that a whole module can compile with no signatures, and most people write them anyway because a signature is the most useful documentation a function can carry.
Rebinding → a recursive definition (a real trap)
Elixir lets you rebind a name, which looks like mutation and is not. Haskell has no rebinding at all — and the line you would reach for by reflex means something else entirely, which is worth learning before it costs you an afternoon.
counter = 1
counter = counter + 1
IO.puts(counter) main :: IO ()
main = do
let counter = 1 :: Int
-- let counter = counter + 1
-- ^ NOT rebinding. `let` is recursive, so this defines counter
-- in terms of itself and hangs forever when forced.
let incremented = counter + 1
print incremented A
let in Haskell introduces a definition that can refer to itself — that is what makes the infinite lists in the next section possible. The cost is that the shadowing idiom you use without thinking in Elixir becomes an infinite loop rather than an error, and it will not be reported at compile time. Give the new value a new name.Local bindings and where
Haskell offers two ways to name an intermediate value:
let before use, and where after, attached to the whole definition. The where form is the idiomatic one, and it can see the function's arguments.defmodule Circle do
def describe(radius) do
area = 3 * radius * radius
"radius #{radius} has area #{area}"
end
end
IO.puts(Circle.describe(2)) describe :: Int -> String
describe radius = "radius " ++ show radius ++ " has area " ++ show area
where area = 3 * radius * radius
main :: IO ()
main = putStrLn (describe 2) Two habits to pick up here.
++ concatenates lists, and a String is a list, so it is also string concatenation — Elixir's <>. And show is the explicit conversion to text that interpolation was doing for you invisibly; there is no #{}, so every non-string value gets a show.Stream Is the Default
Stream → every expression
In Elixir you choose
Enum or Stream depending on whether the intermediate lists should be built. In Haskell that choice does not exist: nothing is evaluated until something needs its value.result =
1..1_000_000
|> Stream.map(fn number -> number * 2 end)
|> Enum.take(5)
IO.inspect(result) main :: IO ()
main = print (take 5 (map (* 2) [1 .. 1000000 :: Int])) Only five elements are ever produced, exactly as with
Stream — but you did not have to ask, and there is no second module to remember. The corresponding loss is that you can no longer look at a line and know what it costs: Enum versus Stream was an explicit decision, and here the decision is made for you everywhere.Infinite lists as ordinary values
[1 ..] is a list with no end, and it is a perfectly normal value you can pass around. The second definition is the classic: a list defined in terms of itself, which works only because nothing is evaluated before it is needed.naturals = Stream.iterate(1, fn number -> number + 1 end)
IO.inspect(Enum.take(naturals, 5))
fibonacci =
Stream.unfold({0, 1}, fn {current, next} -> {current, {next, current + next}} end)
IO.inspect(Enum.take(fibonacci, 10)) main :: IO ()
main = do
print (take 5 [1 :: Int ..])
let fibonacci = 0 : 1 : zipWith (+) fibonacci (tail fibonacci)
print (take 10 (fibonacci :: [Int])) That
fibonacci definition is the single best advertisement laziness has: the list is built out of itself, each element becoming available exactly when the zipWith reaches it. Elixir can express the same idea, but only by threading state through Stream.unfold — which is the mechanism made visible, where Haskell lets you write the recurrence directly.The bill for laziness: thunks and foldl
Laziness is not free, and this is the first place it charges you. A lazy left fold builds a tower of unevaluated additions and forces them all at the end; past a few hundred thousand elements that is a stack overflow rather than a number. Both cells stay at ten thousand so they finish promptly in the browser — the hazard is about the shape of the fold, not the size of this list.
total = Enum.reduce(1..10_000, 0, fn number, accumulator -> accumulator + number end)
IO.puts(total) import Data.List (foldl')
main :: IO ()
main = do
-- foldl builds ((((0+1)+2)+3)+...) as unevaluated thunks, and at a
-- large enough size that tower overflows the stack when it is forced.
-- foldl' forces the accumulator at every step. Use this one.
print (foldl' (+) 0 [1 .. 10000 :: Int]) The apostrophe is not decoration —
foldl' is a different function, and choosing it is a decision you must make. This is the shape of most Haskell performance work: the code is correct either way and the memory profile is not, which is a category of bug that simply does not exist on the BEAM, where Enum.reduce is strict and that is that.Arguments are not evaluated until used
Elixir evaluates every argument before the call, so an argument that raises takes the program down whether or not the function looks at it. Haskell passes the unevaluated expression and forces it only on demand.
ignore = fn _value -> "never used" end
try do
IO.puts(ignore.(raise "boom"))
rescue
RuntimeError -> IO.puts("Elixir evaluated the argument and raised")
end main :: IO ()
main = do
let ignore _ = "never used"
putStrLn (ignore undefined) undefined is a value that throws when forced, and nothing here forces it. This is what makes user-defined control structures possible without macros — a function taking an unevaluated argument is just a function — and it is also why reasoning about when an exception surfaces is much harder than on the BEAM, where the answer is always "at the call".Types, Records & ADTs
Tagged tuples → a data declaration
You already model alternatives as atoms and tagged tuples. Haskell's
data declares that set once, closed, with each variant carrying its own payload — and the function clauses underneath look very much like the ones you would write in Elixir.defmodule Membership do
def describe({:active, days}), do: "active for #{days} days"
def describe(:suspended), do: "suspended"
def describe({:closed, reason}), do: "closed: #{reason}"
end
IO.puts(Membership.describe({:active, 30}))
IO.puts(Membership.describe(:suspended))
IO.puts(Membership.describe({:closed, "fraud"})) data Membership
= Active Int
| Suspended
| Closed String
describe :: Membership -> String
describe (Active days) = "active for " ++ show days ++ " days"
describe Suspended = "suspended"
describe (Closed reason) = "closed: " ++ reason
main :: IO ()
main = do
putStrLn (describe (Active 30))
putStrLn (describe Suspended)
putStrLn (describe (Closed "fraud")) Add a fourth state and the compiler walks you to every function that must handle it. In Elixir a new atom flows silently through the system until some clause somewhere fails to match, usually in production and usually not where the change was made. That difference — refactoring you can trust — is what a closed type buys.
defstruct → a record
Haskell record syntax gives named fields, accessor functions, and an update form. The update form is close enough to Elixir's that it is easy to forget which language you are in.
defmodule Person do
defstruct name: "", age: 0
end
person = %Person{name: "Ada", age: 36}
IO.puts(person.name)
older = %{person | age: 37}
IO.inspect(older) data Person = Person { name :: String, age :: Int }
deriving (Show)
main :: IO ()
main = do
let person = Person { name = "Ada", age = 36 }
putStrLn (name person)
let older = person { age = 37 }
print older One difference to watch:
name is a top-level function, not a field selector scoped to the type, so two records in the same module cannot both have a field called name without an extension. Coming from Elixir, where person.name is scoped to the struct, this is the record system's most-complained-about wart.Two integers the compiler keeps apart
In Elixir a user id and an order id are both just integers, and passing one where the other belongs is a bug you find at runtime or never. A
newtype gives each its own type at zero runtime cost — the wrapper is erased during compilation.user_id = 42
order_id = 42
# Nothing prevents this comparison, or passing one where the other belongs.
IO.inspect(user_id == order_id) newtype UserId = UserId Int deriving (Show, Eq)
newtype OrderId = OrderId Int deriving (Show, Eq)
main :: IO ()
main = do
let userId = UserId 42
let orderId = OrderId 42
print userId
print orderId
-- print (userId == orderId) -- Couldn't match OrderId with UserId
putStrLn "the compiler will not let those two be compared" This is the cheapest safety in the language and the habit worth stealing first. Wrapping every identifier, currency amount, and unit of measure in its own
newtype costs one line and eliminates an entire family of argument-order bugs — the kind that Elixir, where everything is a term, cannot see at all.Universal inspect → derived instances
Every BEAM term can be inspected and compared because every term shares one universal representation. Haskell has no such representation, so printing and comparison are generated per type — by asking for them in a
deriving clause.defmodule Color do
defstruct red: 0, green: 0, blue: 0
end
color = %Color{red: 255}
IO.inspect(color)
IO.inspect(color == %Color{red: 255}) data Color = Color { red :: Int, green :: Int, blue :: Int }
deriving (Show, Eq)
main :: IO ()
main = do
let color = Color { red = 255, green = 0, blue = 0 }
print color
print (color == Color { red = 255, green = 0, blue = 0 }) Forgetting
Show and then trying to print the value is a rite of passage, and the error names the fix. The compensation for opting in: comparing two values of a type with no sensible notion of equality is a compile error, where Elixir's == will compare any two terms whatsoever and hand back a confident, meaningless answer.Pattern Matching & Guards
Multiple clauses, matched top to bottom
This is the most direct correspondence on the page. Haskell dispatches on argument patterns across several equations for the same function, tried in order — the construct Elixir took from the same ML lineage.
defmodule Summing do
def total([]), do: 0
def total([first | rest]), do: first + total(rest)
end
IO.puts(Summing.total([1, 2, 3, 4])) total :: [Int] -> Int
total [] = 0
total (first : rest) = first + total rest
main :: IO ()
main = print (total [1, 2, 3, 4]) Even the cons pattern lines up —
[first | rest] becomes (first : rest), and both destructure the same singly-linked list. Where Elixir writes a recursive function like this and reaches for Enum in practice, Haskell does the same and reaches for foldr; the hand-written version is the teaching form in both.when → guard bars
Guards attach to a clause and are tried in order, with
otherwise as the catch-all. The only real change is punctuation: a pipe per alternative rather than a when per clause head.defmodule Classify do
def classify(number) when number < 0, do: "negative"
def classify(0), do: "zero"
def classify(number) when number < 100, do: "small"
def classify(_), do: "large"
end
IO.puts(Classify.classify(42))
IO.puts(Classify.classify(-1)) classify :: Int -> String
classify number
| number < 0 = "negative"
| number == 0 = "zero"
| number < 100 = "small"
| otherwise = "large"
main :: IO ()
main = do
putStrLn (classify 42)
putStrLn (classify (-1)) Haskell guards can call any function, where Elixir restricts guards to a fixed whitelist of BIFs — no user function may appear in a
when. That restriction exists so the BEAM can guarantee a guard cannot fail or block; Haskell has no such requirement, so guards are ordinary expressions and considerably more useful.case → case of
The inline form is a
case … of expression whose alternatives are separated by layout rather than -> arms with commas. Note that the arrow points the same way and does the same job.value = {:ok, 42}
message =
case value do
{:ok, number} -> "got #{number}"
{:error, reason} -> "failed: #{reason}"
end
IO.puts(message) main :: IO ()
main = do
let value = Right 42 :: Either String Int
let message = case value of
Right number -> "got " ++ show number
Left reason -> "failed: " ++ reason
putStrLn message The tagged tuple has become
Either String Int — a type that says, in the signature, exactly which two shapes can arrive. That is the recurring theme of this page: the conventions you hold in your head on the BEAM are written down here, and the compiler holds them for you.The pin operator has no equivalent
In an Elixir pattern a bare name binds and
^name matches against what the variable already holds. In a Haskell pattern a bare name always binds, so matching against an existing value requires a guard.expected = :ok
result = :ok
case result do
^expected -> IO.puts("matched the pinned atom")
_ -> IO.puts("no match")
end main :: IO ()
main = do
let expected = "ok"
let result = "ok"
-- case result of expected -> ...
-- ^ binds a NEW name and matches everything, silently.
case result of
value | value == expected -> putStrLn "matched via a guard"
_ -> putStrLn "no match" GHC will warn about the shadowing with
-Wall, and the following alternative becomes unreachable — which is the clue if you meet this in the wild. Constructors are the exception: Nothing and Right are capitalized, and capitalization is how the parser tells a pattern that matches from a name that binds.nil → Maybe, Tuples → Either
nil → Maybe
nil is a value any expression can produce and every caller must remember to check. Maybe a is a different type from a, so the compiler will not let you reach the value without saying what happens when it is absent.config = [host: "localhost"]
IO.inspect(Keyword.get(config, :port))
IO.inspect(Keyword.get(config, :port, 4000)) import Data.Maybe (fromMaybe)
main :: IO ()
main = do
let config = [("host", "localhost")]
print (lookup "port" config)
putStrLn (fromMaybe "4000" (lookup "port" config)) There is no
nil in Haskell at all — no universal absent value that can appear in any position. fromMaybe is the || you would reach for, and where you would write a case on nil you write a case on Nothing that the compiler checks for completeness.{:ok, _} / {:error, _} → Either
The convention you follow by hand is a type here, and by convention
Left carries the failure while Right carries the success — the mnemonic being that Right is also the right answer.defmodule Divider do
def divide(_numerator, 0), do: {:error, "division by zero"}
def divide(numerator, denominator), do: {:ok, div(numerator, denominator)}
end
IO.inspect(Divider.divide(10, 2))
IO.inspect(Divider.divide(10, 0)) divide :: Int -> Int -> Either String Int
divide _ 0 = Left "division by zero"
divide numerator denominator = Right (numerator `div` denominator)
main :: IO ()
main = do
print (divide 10 2)
print (divide 10 0) The backticks around
div turn a two-argument function into an infix operator, which is a small piece of syntax you will see constantly. More important: because the return type names the failure, a caller cannot forget the error case the way a bare {:ok, value} match quietly does.Chaining lookups that may fail
Two lookups where either may come up empty. Elixir threads it through
get_in or a with; Haskell uses >>=, which feeds the value on to the next step and short-circuits on Nothing.config = %{database: %{host: "localhost"}}
IO.inspect(get_in(config, [:database, :host]))
IO.inspect(get_in(config, [:cache, :host])) main :: IO ()
main = do
let config = [("database", [("host", "localhost")])]
print (lookup "database" config >>= lookup "host")
print (lookup "cache" config >>= lookup "host") That
>>= is the whole of what people mean by "monad" in practice: a way to sequence steps where the plumbing between them — here, giving up early on Nothing — is handled once rather than at every step. The Monads section shows the same operator wearing do notation, which is where it starts to look like with.try/rescue → Control.Exception
Haskell does have exceptions, but they are reserved for genuinely exceptional conditions and can only be caught in
IO. Expected failure belongs in Maybe or Either, which is a sharper split than Elixir draws.result =
try do
div(1, 0)
rescue
error in ArithmeticError -> "rescued: #{Exception.message(error)}"
end
IO.puts(result) import Control.Exception
main :: IO ()
main = do
outcome <- try (evaluate (1 `div` (0 :: Int))) :: IO (Either ArithException Int)
case outcome of
Left problem -> putStrLn ("caught: " ++ show problem)
Right value -> print value The
evaluate is doing real work: laziness means the division has not happened yet when try runs, so without forcing it the exception would escape later, outside the handler. That interaction — exceptions plus laziness making the timing of a failure hard to pin down — is the strongest argument for keeping expected failures in the type instead.Currying & Composition
Every function is curried
A Haskell function of two arguments is really a function of one that returns a function of one. That is not a trick to be invoked — it is what the arrows in
Int -> Int -> Int already mean, so partial application needs no wrapper.add = fn left, right -> left + right end
add_ten = fn number -> add.(10, number) end
IO.puts(add.(1, 2))
IO.puts(add_ten.(5)) add :: Int -> Int -> Int
add left right = left + right
main :: IO ()
main = do
print (add 1 2)
let addTen = add 10 -- already the function you wanted
print (addTen 5) Elixir has arity as a hard part of a function's identity —
add/2 and add/1 are different functions — so partial application must be written out as a new closure, or built with Function.curry-style helpers. Here it is the default, and it is why so much Haskell code reads as a chain of one-argument transformations.The pipe → (&) and (.)
Haskell has both directions.
& from Data.Function is the pipe you know, reading left to right; . composes functions right to left without mentioning the value at all.result =
"olleh"
|> String.reverse()
|> String.upcase()
IO.puts(result) import Data.Char (toUpper)
import Data.Function ((&))
main :: IO ()
main = do
let shout = map toUpper . reverse
putStrLn (shout "olleh")
putStrLn ("olleh" & reverse & map toUpper) The composed form is the idiomatic one, and it is worth sitting with the direction:
map toUpper . reverse reverses first, because composition reads like mathematics rather than like a pipeline. & exists precisely for people who find that backwards, and reaching for it is not a mark against you.Capture syntax → operator sections
Elixir's
&(&1 * 2) has a direct counterpart: wrap an operator with one side missing and you have a function. This is the most common way to write a small lambda in Haskell.IO.inspect(Enum.map([1, 2, 3], fn number -> number * 2 end))
IO.inspect(Enum.map([1, 2, 3], &(&1 * 2)))
IO.inspect(Enum.filter([1, 2, 3, 4], &(&1 > 2))) main :: IO ()
main = do
print (map (\number -> number * 2) [1, 2, 3 :: Int])
print (map (* 2) [1, 2, 3 :: Int])
print (filter (> 2) [1, 2, 3, 4 :: Int]) The one asymmetry worth memorizing is subtraction:
(- 2) is negative two, not "subtract two", because the minus sign is also unary. The function you meant is subtract 2. Everyone trips on this once.Returning a function
Building a function and handing it back works in both languages. What is different is that in Haskell the two signatures below are the same signature — which is currying stated plainly.
defmodule Multiplier do
def build(factor), do: fn number -> number * factor end
end
triple = Multiplier.build(3)
IO.puts(triple.(7)) build :: Int -> (Int -> Int)
build factor = \number -> number * factor
main :: IO ()
main = do
let triple = build 3
print (triple 7)
-- Int -> (Int -> Int) IS Int -> Int -> Int, so this works too:
print (build 3 7) Note also that a returned function is called exactly like any other — no
.(). Elixir needs that dot because a variable holding a function lives in a different namespace from a named function; Haskell has one namespace, so there is nothing to disambiguate.Protocols → Type Classes
defprotocol → class
Both let you declare an interface and supply implementations for types separately from where those types were defined. The declarations line up almost term for term.
defprotocol Sizeable do
def size(value)
end
defimpl Sizeable, for: BitString do
def size(text), do: byte_size(text)
end
defimpl Sizeable, for: List do
def size(list), do: length(list)
end
IO.puts(Sizeable.size("hello"))
IO.puts(Sizeable.size([1, 2, 3])) data Box = Box [Int]
data Label = Label String
class Sizeable a where
size :: a -> Int
instance Sizeable Box where
size (Box items) = length items
instance Sizeable Label where
size (Label text) = length text
main :: IO ()
main = do
print (size (Box [1, 2, 3]))
print (size (Label "hello")) The instance is chosen at compile time from the argument's type rather than looked up at runtime from the term's tag, so there is no dispatch cost and no consolidation step. In exchange, Haskell enforces coherence: exactly one instance of a class for a type may exist in a program, where Elixir lets any application
defimpl anything. (The Elixir cell is display-only here only because AtomVM compiles a protocol with two implementations too slowly to run in the browser; the dispatch itself is fine.)Dispatch on the return type
Here is the thing type classes do that protocols cannot do at any price. An Elixir protocol dispatches on its first argument. A type class can choose an implementation from the type the caller expects back — even when there are no arguments at all.
# A protocol dispatches on the first argument, so parsing needs a
# differently named function for every result type.
IO.inspect(Integer.parse("42"))
IO.inspect(Float.parse("42")) main :: IO ()
main = do
print (read "42" :: Int)
print (read "42" :: Double)
-- minBound takes no arguments at all, and still dispatches:
print (minBound :: Int)
print (minBound :: Char) Nothing about
minBound has an argument to dispatch on, and it still resolves — the type annotation alone selects the implementation. This is genuinely foreign coming from the BEAM, where dispatch always follows a value, and it is the mechanism behind a great deal of Haskell's ability to make generic code read like ordinary code.One map for every container
Elixir needs a different traversal for each shape —
Enum.map for lists, a case for a tagged tuple, something else again for a map. fmap is one function that works on anything that can be mapped over.IO.inspect(Enum.map([1, 2, 3], fn number -> number * 2 end))
doubled =
case {:ok, 21} do
{:ok, value} -> {:ok, value * 2}
other -> other
end
IO.inspect(doubled) main :: IO ()
main = do
print (fmap (* 2) [1, 2, 3 :: Int])
print (fmap (* 2) (Just 21 :: Maybe Int))
print (fmap (* 2) (Right 21 :: Either String Int)) The same three characters handle a list, an optional value, and a result that might be an error, because each of those types declares how to be mapped over. This is the payoff of return-type dispatch compounding: abstractions like
Functor, Foldable and Traversable apply to types nobody had written when the abstraction was designed.Ordering, enumeration and bounds for free
Declaring the constructors in order and asking for a few instances gives you comparison, ranges, and the complete list of values — none of which Elixir's atoms can offer, since atoms have no declared order or membership.
# Atoms sort alphabetically, which is rarely the order you meant.
IO.inspect(Enum.sort([:medium, :low, :high]))
# The full set of valid values has to be written out by hand.
priorities = [:low, :medium, :high]
IO.inspect(priorities) data Priority = Low | Medium | High
deriving (Show, Eq, Ord, Enum, Bounded)
main :: IO ()
main = do
print (compare Low High)
print (maximum [Medium, Low, High])
print ([minBound .. maxBound] :: [Priority]) Ordering follows declaration order, which is almost always the order you meant, and
[minBound .. maxBound] is the exhaustive list the compiler maintains for you. Coming from atoms, where :high < :low is true because h precedes l, this is a small but constant relief.Enum → List Functions
Enum.map / filter / sum
The vocabulary is largely shared, and the Prelude supplies these unqualified — no module prefix, because there is one list type and these are its functions.
result =
1..10
|> Enum.filter(fn number -> rem(number, 2) == 0 end)
|> Enum.map(fn number -> number * number end)
|> Enum.sum()
IO.puts(result) main :: IO ()
main = print (sum (map (^ 2) (filter even [1 .. 10 :: Int]))) Read it right to left and it is the same pipeline. The absence of a module prefix is worth noticing: Elixir separates
Enum, List, Map and Stream because they operate on different things, while here map and filter are list functions and fmap generalizes them to everything else.for → list comprehension
This is the closest syntactic match on the entire page, and not by accident — Elixir's
for comes from the same tradition. Generators on the right, the expression on the left, filters mixed in.pairs = for first <- 1..3, second <- 1..3, first < second, do: {first, second}
IO.inspect(pairs) main :: IO ()
main = print [(first, second) | first <- [1 .. 3 :: Int], second <- [1 .. 3], first < second] The only real differences are that the result expression moves to the front and the ranges are bracketed. Both languages produce the pairs in the same order, because both iterate the rightmost generator fastest — which is the behavior that makes nested comprehensions predictable.
Enum.zip and iterating two lists
zip pairs two lists and stops at the shorter one, exactly as Elixir's does. The iteration underneath is mapM_, which runs an action for every element and throws the results away.names = ["Ada", "Grace", "Alan"]
years = [1815, 1906, 1912]
IO.inspect(Enum.zip(names, years))
for {name, year} <- Enum.zip(names, years) do
IO.puts("#{name}: #{year}")
end main :: IO ()
main = do
let names = ["Ada", "Grace", "Alan"]
let years = [1815, 1906, 1912 :: Int]
print (zip names years)
mapM_ (\(name, year) -> putStrLn (name ++ ": " ++ show year)) (zip names years) The trailing underscore is a convention worth learning early:
mapM collects the results into a list, mapM_ discards them. You will see the same underscore on forM_, when_-style helpers and elsewhere, and it always means "for the effects, not the values".Map → Data.Map
A key-value store is not in the Prelude; it comes from
containers, a library that ships with the compiler, and is conventionally imported qualified so its names do not collide with the list functions.inventory = %{"apples" => 3, "pears" => 5}
inventory = Map.put(inventory, "plums", 2)
IO.inspect(Map.get(inventory, "apples"))
IO.inspect(inventory |> Map.to_list() |> Enum.sort()) import qualified Data.Map as Map
main :: IO ()
main = do
let inventory = Map.fromList [("apples", 3 :: Int), ("pears", 5)]
print (Map.lookup "apples" inventory)
print (Map.toList (Map.insert "plums" 2 inventory)) Map.lookup returns a Maybe, so a missing key is handled at the type level rather than by returning nil and failing a step later. Data.Map is a balanced binary tree and its toList is therefore sorted by key — Elixir's map iteration order is unspecified, which is why the anchor cell sorts explicitly.Binaries → String and Text
A String is a list of Char
An Elixir binary is a packed sequence of bytes. A Haskell
String is literally [Char] — a linked list — so every list function in the language works on it, for better and for worse.text = "hello"
IO.puts(byte_size(text))
IO.inspect(String.graphemes(text))
IO.puts(String.upcase(text)) import Data.Char (toUpper)
main :: IO ()
main = do
let text = "hello"
print (length text)
print text
putStrLn (map toUpper text) Uppercasing is
map toUpper because a string is a list and map is what you do to lists — there is no separate string module to learn. The price is in the representation: one cons cell and one boxed character per letter, so length is a traversal and memory use is several times what a binary would need.Interpolation → ++ and show (or printf)
There is no
#{}. You concatenate with ++ and convert non-strings with show, or reach for printf, which is in base and takes a format string.name = "Ada"
year = 1843
IO.puts("#{name} wrote the first algorithm in #{year}") import Text.Printf (printf)
main :: IO ()
main = do
let name = "Ada"
let year = 1843 :: Int
putStrLn (name ++ " wrote the first algorithm in " ++ show year)
printf "%s wrote it in %d\n" name year This is one of the places Haskell is plainly less pleasant than Elixir day to day, and everyone notices it. The usual production answer is the
interpolate or string-interpolate package, which brings back #{}-style syntax through a quasiquoter — but it is a dependency and a language extension rather than something the language does for you.String, Text or ByteString
Real Haskell code rarely uses
String for anything large. The two replacements are Data.Text (packed Unicode, the default choice) and Data.ByteString (raw bytes). Both come from Hackage, so this cell is display-only here.# One type covers every case: a binary is packed, Unicode-aware
# through the String module, and indexable by byte.
text = "hello world"
IO.puts(byte_size(text))
IO.puts(String.slice(text, 0, 5))
IO.inspect(:binary.part(text, 0, 5)) {-# LANGUAGE OverloadedStrings #-}
import qualified Data.Text as Text
import qualified Data.Text.IO as TextIO
main :: IO ()
main = do
let text = "hello world" :: Text.Text
print (Text.length text)
TextIO.putStrLn (Text.take 5 text)
TextIO.putStrLn (Text.toUpper text) That an ordinary program must choose between three string types, and that library signatures disagree about which one they want, is the most-cited practical complaint about the language. Elixir made the opposite call — one binary type, always packed — and this is the row where the BEAM's design is simply more comfortable.
with IS do-notation
with → do (the row this page exists for)
Read these two side by side.
with is a sequence of steps, each of which may fail, where the first failure abandons the rest. That is precisely what a do block over Either does — and Elixir built a bespoke construct for the one case Haskell gets from a general mechanism.defmodule Checkout do
def run(quantity, price) do
with true <- quantity > 0,
true <- price > 0 do
{:ok, quantity * price}
else
_ -> {:error, :invalid}
end
end
end
IO.inspect(Checkout.run(3, 100))
IO.inspect(Checkout.run(0, 100)) checkout :: Int -> Int -> Either String Int
checkout quantity price = do
validQuantity <- if quantity > 0 then Right quantity else Left "invalid quantity"
validPrice <- if price > 0 then Right price else Left "invalid price"
return (validQuantity * validPrice)
main :: IO ()
main = do
print (checkout 3 100)
print (checkout 0 100) Note what the Haskell version gets for free that
with makes awkward: each failure carries its own message, because the Left value propagates as itself. Elixir's else block receives whatever did not match, having lost track of which clause produced it — the well-known complaint about with, and the reason people end up tagging every clause by hand.The same do block, a different monad
This is the part with no Elixir counterpart at all. The block below is written exactly like the last one, but it runs over
Maybe rather than Either — the sequencing behavior comes from the type, not from the syntax.config = %{database: %{host: "localhost"}}
result =
with database when is_map(database) <- Map.get(config, :database),
host when is_binary(host) <- Map.get(database, :host) do
{:ok, host}
else
_ -> :error
end
IO.inspect(result) main :: IO ()
main = do
let config = [("database", [("host", "localhost")])]
let found = do
database <- lookup "database" config
lookup "host" database
print found One notation, and the meaning of "and then" is supplied by whichever type you are working in — give up on
Nothing for Maybe, carry the error for Either, sequence effects for IO, try every combination for lists. Elixir has with for the first two cases and nothing that generalizes, because generalizing over "types that support sequencing" requires the type system this page has been building toward.do is sugar for >>=
Nothing magical is happening. A
do block rewrites mechanically into >>= and lambdas, in the same way Elixir's with expands into nested case expressions.# This is essentially what `with` expands to.
result =
case Integer.parse("21") do
{number, rest} when rest == "" ->
case number > 0 do
true -> {:ok, number * 2}
false -> {:error, :not_positive}
end
_ ->
{:error, :not_a_number}
end
IO.inspect(result) main :: IO ()
main = do
let config = [("a", 1 :: Int)]
-- These two lines are the same program.
print (lookup "a" config >>= \value -> Just (value * 2))
print (do value <- lookup "a" config; Just (value * 2)) The difference in leverage is the point. Elixir's expansion is fixed —
with knows about pattern matching and nothing else — while >>= is an ordinary method of an ordinary class, so any type can opt into do notation by defining it. Parsers, state, logging and non-determinism all reuse the same syntax on that basis.The IO Boundary
Effects appear in the signature
Two functions, one that computes and one that prints. In Elixir nothing about them tells you which is which; in Haskell the type does, and the compiler enforces it.
defmodule Greeting do
def build(name), do: "Hello, #{name}!"
def announce(name), do: IO.puts(build(name))
end
IO.puts(Greeting.build("Ada"))
Greeting.announce("Grace") build :: String -> String
build name = "Hello, " ++ name ++ "!"
announce :: String -> IO ()
announce name = putStrLn (build name)
main :: IO ()
main = do
putStrLn (build "Ada")
announce "Grace" String -> String is a proof that build cannot print, write a file, read the clock, or launch a process — not a convention, not a naming scheme, a guarantee checked at compile time. Reviewing Haskell code, the signature tells you where the effects are before you read a line of the body.You cannot sneak an effect in
The Elixir function below looks pure from its name and its call site, and is not. Adding the same line to the Haskell function is not a style violation — it does not compile.
defmodule Sneaky do
def double(number) do
IO.puts("(a side effect nobody expected)")
number * 2
end
end
IO.puts(Sneaky.double(21)) double :: Int -> Int
double number = number * 2
-- Adding `putStrLn "..."` here fails:
-- Couldn't match expected type 'Int' with actual type 'IO ()'
main :: IO ()
main = print (double 21) The practical consequence is that a pure function can be moved, cached, reordered, or run twice with no change in behavior — which is what makes aggressive optimization and property-based testing so effective here. It is also the constraint people find hardest: adding one debug print to a pure function means changing its type and every type above it.
State in a process → IORef in IO
On the BEAM, mutable state must live inside a process and be reached by message. Haskell has genuine mutable references — they are simply confined to
IO, so the type of any function touching one says so.defmodule Tally do
use GenServer
def init(count), do: {:ok, count}
def handle_call(:increment, _from, count), do: {:reply, count + 1, count + 1}
end
{:ok, pid} = GenServer.start_link(Tally, 0)
GenServer.call(pid, :increment)
IO.puts(GenServer.call(pid, :increment)) import Data.IORef
main :: IO ()
main = do
counter <- newIORef (0 :: Int)
modifyIORef counter (+ 1)
modifyIORef counter (+ 1)
value <- readIORef counter
print value This is a real memory cell being written twice — no process, no message, no copy. The BEAM makes you build a process for this because a process is the only thing that can hold changing state; Haskell makes you accept an
IO in your type instead. Both are ways of making the mutation visible, and the Haskell one is considerably cheaper.Green Threads, Shared Heap
spawn → forkIO
GHC's runtime is the closest thing to the BEAM outside the BEAM: green threads multiplexed over an M:N scheduler, cheap enough to make millions of them.
forkIO starts one, and an MVar is the one-slot box used to get a value back.parent = self()
spawn(fn -> send(parent, {:result, 6 * 7}) end)
receive do
{:result, value} -> IO.puts(value)
end import Control.Concurrent
main :: IO ()
main = do
result <- newEmptyMVar
_ <- forkIO (putMVar result (6 * 7 :: Int))
value <- takeMVar result
print value Unlike Go's goroutines or Rust's OS threads,
forkIO hands back a ThreadId you can hold on to, which makes the next few rows possible. The cost profile is genuinely comparable to a process — a couple of hundred bytes, and the scheduler will preempt on allocation — so the instinct to spawn freely transfers here in a way it does not to most languages.A thousand of them, cheaply
Both runtimes are built for this. A
Chan is an unbounded FIFO queue, which is the closest thing to a mailbox — except that it is a separate object rather than something a thread is born with.parent = self()
for number <- 1..1000 do
spawn(fn -> send(parent, number) end)
end
total =
Enum.reduce(1..1000, 0, fn _, accumulator ->
receive do
number -> accumulator + number
end
end)
IO.puts(total) import Control.Concurrent
import Control.Monad (forM_, replicateM)
main :: IO ()
main = do
results <- newChan
forM_ [1 .. 1000 :: Int] (\number -> forkIO (writeChan results number))
values <- replicateM 1000 (readChan results)
print (sum values) The important structural difference is addressing. A process's mailbox comes with the process and is reached through its pid, so anything holding the pid can write to it; a
Chan is a value you create and hand out deliberately. That makes the communication graph explicit — an improvement — at the cost of the pid being a universal address, which is what makes OTP's registry and supervision possible.Shared mutable state, guarded by an MVar
Here is the inversion. On the BEAM there is no shared state to protect, so a lock has nothing to do. Threads here share one heap, so a mutable cell needs something to serialize access — and an
MVar is both the cell and the lock.defmodule Ledger do
use GenServer
def init(balance), do: {:ok, balance}
def handle_call({:deposit, amount}, _from, balance) do
{:reply, balance + amount, balance + amount}
end
end
{:ok, pid} = GenServer.start_link(Ledger, 0)
GenServer.call(pid, {:deposit, 100})
IO.puts(GenServer.call(pid, {:deposit, 50})) import Control.Concurrent.MVar
import Control.Monad (forM_)
main :: IO ()
main = do
balance <- newMVar (0 :: Int)
forM_ [100, 50 :: Int] (\amount ->
modifyMVar_ balance (\current -> return (current + amount)))
final <- readMVar balance
print final An
MVar is either full or empty, and takeMVar blocks until it is full — so it is a mutex, a one-place channel and a condition variable at once. All the classic hazards come back with it: two threads taking two MVars in opposite orders will deadlock, and no supervisor will notice. The next section is the better answer.A thread dies, and nothing restarts it
Both cells stage a worker that fails and let the parent find out. Watch what the parent receives, and what is expected to happen next.
pid = spawn(fn -> raise "worker failed" end)
reference = Process.monitor(pid)
receive do
{:DOWN, ^reference, :process, ^pid, _reason} ->
IO.puts("worker died — and I was told about it")
end import Control.Concurrent
import Control.Exception
main :: IO ()
main = do
done <- newEmptyMVar
_ <- forkFinally (evaluate (error "worker failed" :: Int)) (putMVar done)
outcome <- takeMVar done
case outcome of
Left problem -> putStrLn ("worker died: " ++ show problem)
Right value -> print value forkFinally is as close as the base library comes to a monitor, and it is close: you learn that the thread ended and whether it ended badly. What does not exist is everything above that — no supervisor, no restart strategy, no link graph taking down the siblings that depended on it. An unhandled exception in a plain forkIO thread prints to stderr and is otherwise silent, which is the real trap.Process.exit → killThread
You can address one thread and stop it. This is worth calling out because most languages with lightweight concurrency cannot do it at all — a goroutine has no handle and no kill.
pid = spawn(fn -> Process.sleep(60_000) end)
Process.exit(pid, :kill)
Process.sleep(50)
IO.inspect(Process.alive?(pid)) import Control.Concurrent
main :: IO ()
main = do
worker <- forkIO (threadDelay 60000000)
killThread worker
threadDelay 50000
putStrLn "worker killed — killThread throws an async exception into it" The mechanism is different in a way that matters:
killThread raises an asynchronous exception inside the target, so the thread can catch it and clean up — and can also mask it and refuse to die. Process.exit(pid, :kill) is untrappable and immediate. Asynchronous exceptions are widely considered the hardest corner of Haskell concurrency to get right, and this is why.STM — What the BEAM Lacks
Composable atomic transactions
Software transactional memory has no BEAM equivalent, and it is the clearest case on this page of the other language simply having something you do not. Reads and writes inside
atomically either all happen or none do.defmodule Accounts do
use GenServer
def init(state), do: {:ok, state}
def handle_call({:transfer, amount}, _from, {source, destination}) do
moved = {source - amount, destination + amount}
{:reply, moved, moved}
end
end
{:ok, pid} = GenServer.start_link(Accounts, {100, 0})
IO.inspect(GenServer.call(pid, {:transfer, 30})) import Control.Concurrent.STM
main :: IO ()
main = do
source <- newTVarIO (100 :: Int)
destination <- newTVarIO (0 :: Int)
atomically (do
modifyTVar source (subtract 30)
modifyTVar destination (+ 30))
sourceBalance <- readTVarIO source
destinationBalance <- readTVarIO destination
print (sourceBalance, destinationBalance) The Elixir version works only because both balances were deliberately put inside one process. Once they live in two processes there is no primitive that moves money between them atomically — you write a coordinator, or a two-phase protocol, by hand. Here the two
TVars were never related until this transaction said so, and the runtime handles the rest.retry: blocking as a first-class operation
retry abandons the transaction and re-runs it when one of the variables it read has changed. Expressing "wait until the balance is sufficient" takes one word, and composes with everything else in the transaction.# The BEAM equivalent is a receive loop inside the owning process,
# re-checking the condition each time a message arrives.
defmodule Vault do
use GenServer
def init(balance), do: {:ok, balance}
def handle_call({:withdraw, amount}, _from, balance) when amount <= balance do
{:reply, {:ok, balance - amount}, balance - amount}
end
def handle_call({:withdraw, _amount}, _from, balance) do
{:reply, {:error, :insufficient}, balance}
end
end
{:ok, pid} = GenServer.start_link(Vault, 100)
IO.inspect(GenServer.call(pid, {:withdraw, 30}))
IO.inspect(GenServer.call(pid, {:withdraw, 500})) import Control.Concurrent.STM
withdraw :: TVar Int -> Int -> STM ()
withdraw account amount = do
balance <- readTVar account
if balance < amount
then retry
else writeTVar account (balance - amount)
main :: IO ()
main = do
account <- newTVarIO (100 :: Int)
atomically (withdraw account 30)
balance <- readTVarIO account
print balance The composability is the real prize. Two
STM actions combine into a larger atomic action just by sequencing them, and orElse picks whichever can proceed — so "take from either queue, whichever is ready" is an expression rather than a design. The type system is what makes this safe: an STM action cannot perform IO, so the runtime is free to re-run it as often as it likes.quote/unquote → Template Haskell
defmacro → Template Haskell
Both languages can generate code at compile time. The difference is how routine it is: Elixir macros are everyday tooling, while Template Haskell is a specialist instrument reached for rarely. The Haskell cell cannot run here because a splice must be compiled in a different module from the one that uses it.
defmodule Logging do
defmacro log(expression) do
quote do
value = unquote(expression)
IO.puts("value is #{inspect(value)}")
value
end
end
end
require Logging
Logging.log(6 * 7) {-# LANGUAGE TemplateHaskell #-}
module Generate (makeGreeting) where
import Language.Haskell.TH
-- A splice must live in a module compiled BEFORE the one that uses it,
-- so this cannot be a single file the way the Elixir cell can.
makeGreeting :: String -> Q [Dec]
makeGreeting person =
[d| greeting :: String
greeting = $(litE (stringL ("Hello, " ++ person))) |] The staging restriction is the whole story. An Elixir macro is an ordinary function over AST that
require makes available immediately, so a project can define and use one in the same breath; Template Haskell needs a separate compilation stage, which pushes it toward library authors and away from everyday code. Most of what you would reach for a macro to do here is instead done by type classes and deriving.Module attributes → ordinary top-level values
Elixir computes a module attribute once at compile time and inlines it, which is the usual reason to reach for compile-time evaluation at all. Laziness gives Haskell the same benefit without any special mechanism.
defmodule Squares do
@table for number <- 1..5, do: number * number
def table, do: @table
end
IO.inspect(Squares.table()) squares :: [Int]
squares = map (^ 2) [1 .. 5]
main :: IO ()
main = do
print squares
print (sum squares) A top-level value with no arguments is evaluated at most once and the result is retained for the life of the program — a constant applicative form. So the table is computed on first use and never again, which is what the module attribute bought you, without a compile-time phase. This is a large part of why Haskell needs macros so much less than Elixir does.
Mix → Cabal
mix → cabal
Haskell has two build tools in common use — Cabal and Stack — where Elixir settled on one early. These are shell commands, so neither cell runs on this page.
mix new my_app
mix deps.get
mix test
mix format
mix docs cabal init --non-interactive
cabal build
cabal test
cabal haddock
# formatting is a separate tool: ormolu or fourmolu Coming from Mix, the two-tool situation is the first thing to get used to: Stack pins a curated package set for reproducibility, Cabal resolves versions itself, and projects pick one. Formatting is also not bundled — there is no
mix format equivalent that ships with the compiler, and teams choose between Ormolu, Fourmolu and stylish-haskell.mix.exs → the .cabal file
Both declare dependencies in a manifest at the project root. The Cabal file is a declarative format rather than code, so unlike
mix.exs it cannot compute anything.# mix.exs
defp deps do
[
{:jason, "~> 1.4"},
{:ecto, "~> 3.12"}
]
end -- my-app.cabal
build-depends:
base >= 4.17 && < 5
, aeson >= 2.2 && < 2.3
, text >= 2.0 The version syntax is more explicit and more verbose: where Hex's
~> 1.4 compresses a range into an operator, Cabal wants both bounds spelled out, and the community argues continually about how tight they should be. The base bound is the one every package carries — it pins which compiler releases the package claims to support.