PONY λ M2 Modula-2

Elixir.CodeCompared.To/Haskell

An interactive executable cheatsheet comparing Elixir and Haskell

Elixir 1.17 GHC 9.12
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.