| name | convert-elixir-haskell |
| description | Convert Elixir code to idiomatic Haskell. Use when migrating Elixir projects to Haskell, translating BEAM actor patterns to pure functional programming with IO monad, or refactoring dynamic OTP behaviors to static typed equivalents. Extends meta-convert-dev with Elixir-to-Haskell specific patterns. |
Convert Elixir to Haskell
Convert Elixir code to idiomatic Haskell. This skill extends meta-convert-dev with Elixir-to-Haskell specific type mappings, idiom translations, and transformation strategies for moving from BEAM's actor model to pure functional programming with strong static types.
This Skill Extends
meta-convert-dev- Foundational conversion patterns (APTV workflow, testing strategies)
For general concepts like the Analyze → Plan → Transform → Validate workflow, testing strategies, and common pitfalls, see the meta-skill first.
This Skill Adds
- Type mappings: Elixir dynamic types → Haskell static types (Hindley-Milner)
- Idiom translations: Actors/OTP → STM/async, pattern matching nuances, pipe vs composition
- Error handling: Tagged tuples → Maybe/Either, supervision → explicit error handling
- Async patterns: GenServer/Tasks → IO monad, async library, STM
- Evaluation strategy: Strict (Elixir) → Lazy (Haskell) translation
- Effects: Effects anywhere → IO monad boundary, pure core
This Skill Does NOT Cover
- General conversion methodology - see
meta-convert-dev - Elixir language fundamentals - see
lang-elixir-dev - Haskell language fundamentals - see
lang-haskell-dev - Reverse conversion (Haskell → Elixir) - see
convert-haskell-elixir
Quick Reference
| Elixir | Haskell | Notes |
|---|---|---|
{:ok, value} |
Right value |
Either monad for results |
{:error, reason} |
Left reason |
Either monad for errors |
nil |
Nothing |
Maybe monad |
value |
Just value |
Maybe monad |
Enum.map/2 |
fmap or map |
Functor/list operations |
|> |
$ or & or . |
Function application/composition |
def func(arg) |
func :: Type -> Typefunc arg = ... |
Function with type signature |
GenServer |
TVar + STM |
Actor → transactional memory |
Task.async/1 |
async |
Concurrent execution |
receive do ... end |
Pattern match on Chan |
Message passing |
Map.t() |
Map k v |
Hash map |
%{key: value} |
Map.fromList [("key", value)] |
Map construction |
When Converting Code
- Analyze effects first - Identify where side effects occur in Elixir
- Map types explicitly - Create complete type mapping table from dynamic to static
- Separate pure from impure - Pure core with IO boundary
- Translate actors to alternatives - GenServer → STM, supervision → error handling
- Handle laziness - Elixir strict, Haskell lazy by default
- Test equivalence - Property-based testing for invariants
Type System Mapping
Primitive Types
| Elixir | Haskell | Notes |
|---|---|---|
integer() |
Int / Integer |
Int fixed-width, Integer arbitrary precision |
float() |
Double |
64-bit float |
boolean() |
Bool |
True / False |
atom() |
Custom ADT | :ok, :error → data constructors |
binary() / String.t() |
Text / ByteString |
Use Data.Text for UTF-8 |
charlist() |
String |
String is [Char] in Haskell |
pid() |
ThreadId / Async |
Process identifiers |
reference() |
MVar / TVar |
Reference types |
Collection Types
| Elixir | Haskell | Notes |
|---|---|---|
list() |
[a] |
Linked list |
tuple() |
(a, b, ...) |
Fixed-size tuple |
%{} (map) |
Map k v |
Requires Data.Map |
MapSet.t() |
Set a |
Requires Data.Set |
| Keyword list | [(Text, a)] |
List of pairs |
| Range | [a..b] |
List comprehension range |
Composite Types
| Elixir | Haskell | Notes |
|---|---|---|
| Struct | data Type = Type { ... } |
Record syntax |
{:ok, value} |
Right value |
Either String a |
{:error, reason} |
Left reason |
Either String a |
nil |
Nothing |
Maybe a |
| Value | Just value |
Maybe a |
| Union types (spec) | data Type = A | B |
Sum type (ADT) |
| GenServer state | TVar s |
Shared mutable state |
| Protocol | Type class | Polymorphism |
Function Types
| Elixir | Haskell | Notes |
|---|---|---|
(arg1, arg2 -> return) |
arg1 -> arg2 -> return |
Curried by default |
(() -> return) |
IO return |
Side-effecting function |
(a -> b) |
a -> b |
Pure function |
| Anonymous fn | Lambda \x -> ... |
Lambda syntax |
Idiom Translation
Pattern: Tagged Tuples → Either/Maybe
Elixir:
def divide(a, b) when b != 0, do: {:ok, a / b}
def divide(_, 0), do: {:error, :division_by_zero}
case divide(10, 2) do
{:ok, result} -> IO.puts("Result: #{result}")
{:error, reason} -> IO.puts("Error: #{reason}")
end
Haskell:
divide :: Float -> Float -> Either String Float
divide a 0 = Left "division by zero"
divide a b = Right (a / b)
case divide 10 2 of
Right result -> putStrLn $ "Result: " ++ show result
Left reason -> putStrLn $ "Error: " ++ reason
-- Or with do-notation (Either monad)
calculation :: Either String Float
calculation = do
a <- divide 10 2
b <- divide a 5
return (b * 2)
Why this translation:
- Elixir uses tagged tuples
{:ok, value}/{:error, reason}idiomatically - Haskell's
Eithertype encodes the same semantics with stronger type safety - Pattern matching works similarly in both
- Haskell's
Eithermonad allows chaining withdo-notation
Pattern: Pipe Operator → Function Composition
Elixir:
result =
[1, 2, 3, 4]
|> Enum.filter(&(rem(&1, 2) == 0))
|> Enum.map(&(&1 * 2))
|> Enum.sum()
Haskell:
-- Point-free with composition
result = sum . map (*2) . filter even $ [1, 2, 3, 4]
-- Or with ($) for clarity
result = sum $ map (*2) $ filter even [1, 2, 3, 4]
-- Or with (&) for left-to-right (Data.Function)
import Data.Function ((&))
result = [1, 2, 3, 4]
& filter even
& map (*2)
& sum
Why this translation:
- Elixir's
|>passes result forward (left-to-right) - Haskell's
.composes right-to-left:(f . g) x = f (g x) - Use
$for right-to-left with clarity, or&for left-to-right - Point-free style is idiomatic Haskell
Pattern: Pattern Matching with Guards
Elixir:
def classify(n) when n < 0, do: :negative
def classify(0), do: :zero
def classify(n) when n < 10, do: :small
def classify(_), do: :large
Haskell:
-- Using guards
classify :: Int -> String
classify n
| n < 0 = "negative"
| n == 0 = "zero"
| n < 10 = "small"
| otherwise = "large"
-- Or with case
classify' :: Int -> String
classify' n = case n of
0 -> "zero"
_ | n < 0 -> "negative"
| n < 10 -> "small"
| otherwise -> "large"
Why this translation:
- Both languages support guard clauses
- Haskell uses
|for guards instead ofwhen otherwiseis the catch-all (equivalent to Elixir's_)- Pattern matching on literals comes before guards in Haskell
Pattern: Enum Comprehensions → List Comprehensions
Elixir:
result = for x <- [1, 2, 3, 4, 5],
y <- [1, 2, 3],
x * y > 5,
do: {x, y}
Haskell:
result = [(x, y) | x <- [1..5], y <- [1..3], x * y > 5]
Why this translation:
- Syntax is nearly identical
- Haskell's list comprehensions are more concise
- Filters come after generators in both
- Multiple generators work the same way
Pattern: Recursive List Processing
Elixir:
def sum([]), do: 0
def sum([head | tail]), do: head + sum(tail)
def map([], _func), do: []
def map([head | tail], func), do: [func.(head) | map(tail, func)]
Haskell:
sum' :: [Int] -> Int
sum' [] = 0
sum' (x:xs) = x + sum' xs
map' :: (a -> b) -> [a] -> [b]
map' _ [] = []
map' f (x:xs) = f x : map' f xs
Why this translation:
- Both use head/tail pattern matching (
[head | tail]vs(x:xs)) - Base case (empty list) first in both
- Haskell requires type signatures (recommended in Elixir)
- Haskell's cons operator
:is infix
Pattern: With Statement → Do-Notation
Elixir:
def create_user(params) do
with {:ok, validated} <- validate_params(params),
{:ok, user} <- insert_user(validated),
{:ok, email_sent} <- send_email(user) do
{:ok, user}
else
{:error, reason} -> {:error, reason}
end
end
Haskell:
createUser :: Params -> IO (Either String User)
createUser params = runExceptT $ do
validated <- ExceptT $ return $ validateParams params
user <- ExceptT $ insertUser validated
emailSent <- ExceptT $ sendEmail user
return user
-- Or with Either monad directly
createUser' :: Params -> Either String User
createUser' params = do
validated <- validateParams params
user <- insertUser validated
emailSent <- sendEmail user
return user
Why this translation:
- Elixir's
withchains operations that can fail - Haskell's
do-notation forEithermonad achieves the same ExceptTtransformer for mixingIOwithEither- Short-circuits on first
Left(error) automatically
Error Handling
Elixir Error Model → Haskell Error Model
| Elixir Pattern | Haskell Pattern | Notes |
|---|---|---|
{:ok, value} |
Right value |
Success case |
{:error, reason} |
Left reason |
Error case |
nil |
Nothing |
Absence of value |
value |
Just value |
Presence of value |
raise Exception |
error "message" |
Runtime exception (avoid) |
| Supervisor restart | Explicit error handling | No supervision trees |
try...rescue |
catch / try |
Exception handling (rare) |
Pattern: Supervision → Explicit Error Handling
Elixir:
# Supervisor restarts failed processes
defmodule MyApp.Supervisor do
use Supervisor
def start_link(_) do
Supervisor.start_link(__MODULE__, :ok, name: __MODULE__)
end
def init(:ok) do
children = [
{Worker, []}
]
Supervisor.init(children, strategy: :one_for_one)
end
end
Haskell:
-- Explicit retry logic with error handling
import Control.Exception (try, SomeException)
import Control.Concurrent (threadDelay)
retryWithBackoff :: Int -> IO a -> IO (Either SomeException a)
retryWithBackoff 0 action = try action
retryWithBackoff n action = do
result <- try action
case result of
Right val -> return $ Right val
Left _ -> do
threadDelay (1000000 * 2^(5-n)) -- Exponential backoff
retryWithBackoff (n-1) action
-- Worker that can fail and be retried
worker :: IO ()
worker = do
result <- retryWithBackoff 5 dangerousOperation
case result of
Right val -> processSuccess val
Left err -> logError err
Why this translation:
- Elixir: "Let it crash" philosophy with supervisor restart
- Haskell: Explicit error handling with retry logic
- No built-in supervision trees in Haskell
- Must handle failures explicitly or use exception handling
Pattern: Result Propagation
Elixir:
def process_pipeline(input) do
with {:ok, validated} <- validate(input),
{:ok, transformed} <- transform(validated),
{:ok, result} <- store(transformed) do
{:ok, result}
end
end
Haskell:
processPipeline :: Input -> Either String Result
processPipeline input = do
validated <- validate input
transformed <- transform validated
result <- store transformed
return result
-- Or with applicative for independent operations
processPipeline' input =
validate input >>= transform >>= store
Why this translation:
- Both short-circuit on first error
- Haskell's
Eithermonad provides same chaining >>=(bind) chains dependent operations- More concise than nested
casestatements
Concurrency Patterns
Elixir Concurrency → Haskell Concurrency
| Elixir | Haskell | Notes |
|---|---|---|
| Process (lightweight) | ThreadId |
Haskell threads are OS threads |
spawn/1 |
forkIO |
Spawn concurrent thread |
Task.async/1 |
async |
Async computation |
Task.await/1 |
wait |
Wait for async result |
send/2 |
writeChan |
Send to channel |
receive do ... end |
readChan |
Receive from channel |
GenServer |
TVar + STM |
Stateful server |
Agent |
MVar / TVar |
Shared mutable state |
| Supervisor | Manual retry logic | No built-in supervision |
Pattern: GenServer → STM
Elixir:
defmodule Counter do
use GenServer
def start_link(initial) do
GenServer.start_link(__MODULE__, initial, name: __MODULE__)
end
def increment do
GenServer.call(__MODULE__, :increment)
end
def get do
GenServer.call(__MODULE__, :get)
end
# Callbacks
def init(initial), do: {:ok, initial}
def handle_call(:increment, _from, state) do
{:reply, state + 1, state + 1}
end
def handle_call(:get, _from, state) do
{:reply, state, state}
end
end
Haskell:
import Control.Concurrent.STM
type Counter = TVar Int
createCounter :: Int -> IO Counter
createCounter initial = newTVarIO initial
increment :: Counter -> IO Int
increment counter = atomically $ do
current <- readTVar counter
let new = current + 1
writeTVar counter new
return new
getCount :: Counter -> IO Int
getCount counter = readTVarIO counter
-- Usage
main = do
counter <- createCounter 0
result1 <- increment counter
result2 <- increment counter
final <- getCount counter
print final -- 2
Why this translation:
- GenServer: Message-passing actor with state
- STM: Software Transactional Memory for safe concurrent mutations
- Both provide atomicity and state isolation
- STM is compositional (can combine transactions)
- No message queues in STM (direct state access)
Pattern: Task.async → Async
Elixir:
task1 = Task.async(fn -> fetch_user(1) end)
task2 = Task.async(fn -> fetch_user(2) end)
user1 = Task.await(task1)
user2 = Task.await(task2)
Haskell:
import Control.Concurrent.Async
main = do
task1 <- async $ fetchUser 1
task2 <- async $ fetchUser 2
user1 <- wait task1
user2 <- wait task2
-- Or concurrently
main = do
(user1, user2) <- concurrently (fetchUser 1) (fetchUser 2)
-- Map concurrently over list
users <- mapConcurrently fetchUser [1..10]
Why this translation:
Task.asyncspawns concurrent computation, returns handleasynclibrary provides same semanticswaitblocks until result availableconcurrentlyhelper for pairs- Similar error propagation (async throws exceptions)
Pattern: Message Passing → Channels
Elixir:
pid = spawn(fn ->
receive do
{:msg, value} -> IO.puts("Received: #{value}")
end
end)
send(pid, {:msg, "hello"})
Haskell:
import Control.Concurrent
import Control.Concurrent.Chan
main = do
chan <- newChan
forkIO $ do
msg <- readChan chan
putStrLn $ "Received: " ++ msg
writeChan chan "hello"
threadDelay 100000 -- Wait for thread
Why this translation:
- Elixir: Process mailbox with pattern matching
- Haskell: Typed channels (Chan a)
- No pattern matching on messages (type-safe)
- Must use explicit channel types
MVarfor single-value handoff,Chanfor queues
Evaluation Strategy Translation
Strict → Lazy Conversion Patterns
Elixir evaluates strictly (arguments evaluated before function call). Haskell evaluates lazily (arguments evaluated only when needed).
Elixir (strict):
# All elements processed immediately
list = Enum.map([1, 2, 3, 4, 5], fn x -> expensive_computation(x) end)
result = Enum.take(list, 2) # But we only need 2!
Haskell (lazy):
-- Only first 2 elements computed
list = map expensiveComputation [1, 2, 3, 4, 5]
result = take 2 list -- Lazy: only computes first 2
Key Differences:
| Aspect | Elixir (Strict) | Haskell (Lazy) |
|---|---|---|
| Evaluation | Immediate | On-demand |
| Infinite lists | Not possible | Natural |
| Side effects | Predictable order | Deferred (use IO) |
| Performance | Eager memory use | Space leaks possible |
Pattern: Forcing Strictness in Haskell
When you need strict evaluation:
-- Lazy fold can cause stack overflow
badSum = foldl (+) 0 [1..1000000] -- Builds thunks
-- Strict fold
import Data.List (foldl')
goodSum = foldl' (+) 0 [1..1000000] -- Forces evaluation
-- Bang patterns
{-# LANGUAGE BangPatterns #-}
strictFunc !x = x + 1 -- x evaluated immediately
Pattern: Streams in Elixir → Lazy Lists in Haskell
Elixir:
# Stream for lazy evaluation
Stream.iterate(0, &(&1 + 1))
|> Stream.map(&(&1 * 2))
|> Stream.filter(&(rem(&1, 2) == 0))
|> Enum.take(10)
Haskell:
-- Lists are lazy by default
result = take 10
$ filter even
$ map (*2)
$ iterate (+1) 0
Why this translation:
- Elixir: Explicit
Streamfor laziness - Haskell: All lists are lazy
- Both use similar pipeline patterns
- Haskell infinite lists are natural
Effects and IO Boundary
Separating Pure from Impure
Elixir (effects anywhere):
def process_user(id) do
# Mix of pure and impure
user = Repo.get(User, id) # IO: Database
name = String.upcase(user.name) # Pure
Logger.info("Processing #{name}") # IO: Logging
%{user | name: name} # Pure
end
Haskell (pure core with IO boundary):
-- Pure functions
uppercaseName :: User -> User
uppercaseName user = user { userName = T.toUpper (userName user) }
-- IO boundary
processUser :: Int -> IO User
processUser userId = do
user <- getUser userId -- IO: Database
let updated = uppercaseName user -- Pure
logInfo $ "Processing " <> userName updated -- IO: Logging
return updated
-- Type signature shows effects
-- :: Int -> User (pure)
-- :: Int -> IO User (has IO effects)
Why this translation:
- Elixir: Effects can appear anywhere
- Haskell: Type system tracks effects (
IOtype) - Pure functions don't use
IOtype - Easier to reason about effects in Haskell
- Must explicitly lift pure values into
IOwithreturn
Pattern: Database Queries
Elixir (Ecto):
def get_active_users do
from(u in User, where: u.active == true)
|> Repo.all()
end
Haskell (persistent or esqueleto):
import Database.Persist
import Database.Persist.Sql
getActiveUsers :: SqlPersistM [Entity User]
getActiveUsers = selectList [UserActive ==. True] []
-- In IO context
main :: IO ()
main = runSqlite "database.db" $ do
users <- getActiveUsers
liftIO $ mapM_ print users
Why this translation:
- Both use type-safe query builders
- Haskell: Explicit monad for database operations
SqlPersistMis the DB monadliftIOto perform IO in DB context
Common Pitfalls
Forgetting Lazy Evaluation: Haskell lists are lazy. Use strict functions (
foldl') when needed to avoid space leaks.Mixing IO and Pure: In Haskell, functions must declare
IOin type signature. Can't perform IO in pure functions.Pattern Match Exhaustiveness: Haskell compiler warns about non-exhaustive patterns. Elixir allows partial patterns.
Trying to Mutate State: No mutation in Haskell. Use STM/MVar for shared state or pass new state explicitly.
Ignoring Type Inference Limitations: Haskell can't always infer types. Add explicit type signatures at module boundaries.
Translating Supervision Literally: No supervision trees. Use explicit retry logic, exception handling, or libraries like
retry.Assuming Strict Evaluation: List operations are lazy.
mapdoesn't execute until values are forced.
Tooling
| Tool | Purpose | Notes |
|---|---|---|
stack / cabal |
Build tool | Project structure and dependencies |
ghc |
Compiler | Glasgow Haskell Compiler |
ghci |
REPL | Interactive development |
hlint |
Linter | Suggests improvements |
hspec |
Testing | BDD-style testing framework |
QuickCheck |
Property testing | Equivalent to StreamData |
async |
Concurrency | Task-like async operations |
stm |
STM | Transactional memory for concurrency |
aeson |
JSON | JSON encoding/decoding |
Examples
Example 1: Simple - Function with Pattern Matching
Before (Elixir):
defmodule Math do
def factorial(0), do: 1
def factorial(n) when n > 0, do: n * factorial(n - 1)
end
result = Math.factorial(5) # 120
After (Haskell):
module Math where
factorial :: Int -> Int
factorial 0 = 1
factorial n | n > 0 = n * factorial (n - 1)
-- Usage
result = factorial 5 -- 120
Example 2: Medium - Result Types and Error Handling
Before (Elixir):
defmodule UserService do
def create_user(email, age) do
with {:ok, valid_email} <- validate_email(email),
{:ok, valid_age} <- validate_age(age) do
{:ok, %User{email: valid_email, age: valid_age}}
end
end
defp validate_email(email) do
if String.contains?(email, "@") do
{:ok, email}
else
{:error, :invalid_email}
end
end
defp validate_age(age) do
if age >= 18 do
{:ok, age}
else
{:error, :too_young}
end
end
end
After (Haskell):
module UserService where
import Data.Text (Text)
import qualified Data.Text as T
data User = User
{ userEmail :: Text
, userAge :: Int
} deriving (Show)
data UserError
= InvalidEmail
| TooYoung
deriving (Show)
createUser :: Text -> Int -> Either UserError User
createUser email age = do
validEmail <- validateEmail email
validAge <- validateAge age
return $ User validEmail validAge
validateEmail :: Text -> Either UserError Text
validateEmail email
| "@" `T.isInfixOf` email = Right email
| otherwise = Left InvalidEmail
validateAge :: Int -> Either UserError Int
validateAge age
| age >= 18 = Right age
| otherwise = Left TooYoung
Example 3: Complex - GenServer to STM with Concurrent Access
Before (Elixir):
defmodule BankAccount do
use GenServer
# Client API
def start_link(initial_balance) do
GenServer.start_link(__MODULE__, initial_balance)
end
def deposit(pid, amount) do
GenServer.call(pid, {:deposit, amount})
end
def withdraw(pid, amount) do
GenServer.call(pid, {:withdraw, amount})
end
def balance(pid) do
GenServer.call(pid, :balance)
end
# Server Callbacks
def init(initial_balance), do: {:ok, initial_balance}
def handle_call({:deposit, amount}, _from, balance) do
new_balance = balance + amount
{:reply, {:ok, new_balance}, new_balance}
end
def handle_call({:withdraw, amount}, _from, balance) do
if balance >= amount do
new_balance = balance - amount
{:reply, {:ok, new_balance}, new_balance}
else
{:reply, {:error, :insufficient_funds}, balance}
end
end
def handle_call(:balance, _from, balance) do
{:reply, balance, balance}
end
end
# Usage
{:ok, account} = BankAccount.start_link(1000)
{:ok, new_balance} = BankAccount.deposit(account, 500)
{:ok, after_withdrawal} = BankAccount.withdraw(account, 200)
balance = BankAccount.balance(account)
After (Haskell):
module BankAccount where
import Control.Concurrent.STM
import Control.Monad (when)
type Balance = Int
type Account = TVar Balance
data BankError
= InsufficientFunds
deriving (Show, Eq)
createAccount :: Balance -> IO Account
createAccount initial = newTVarIO initial
deposit :: Account -> Balance -> IO Balance
deposit account amount = atomically $ do
current <- readTVar account
let newBalance = current + amount
writeTVar account newBalance
return newBalance
withdraw :: Account -> Balance -> IO (Either BankError Balance)
withdraw account amount = atomically $ do
current <- readTVar account
if current >= amount
then do
let newBalance = current - amount
writeTVar account newBalance
return $ Right newBalance
else
return $ Left InsufficientFunds
getBalance :: Account -> IO Balance
getBalance = readTVarIO
-- Atomic transfer between accounts
transfer :: Account -> Account -> Balance -> STM (Either BankError ())
transfer from to amount = do
fromBalance <- readTVar from
if fromBalance >= amount
then do
modifyTVar from (subtract amount)
modifyTVar to (+ amount)
return $ Right ()
else
return $ Left InsufficientFunds
-- Usage
main :: IO ()
main = do
account <- createAccount 1000
newBalance <- deposit account 500
withdrawResult <- withdraw account 200
balance <- getBalance account
print balance -- 1300
-- Multiple accounts with atomic transfer
account1 <- createAccount 1000
account2 <- createAccount 0
result <- atomically $ transfer account1 account2 500
case result of
Right _ -> putStrLn "Transfer successful"
Left InsufficientFunds -> putStrLn "Insufficient funds"
See Also
For more examples and patterns, see:
meta-convert-dev- Foundational patterns with cross-language examplesconvert-clojure-haskell- Similar dynamic→static, practical→pure transitionconvert-erlang-haskell- BEAM→native, actors→STMlang-elixir-dev- Elixir development patternslang-haskell-dev- Haskell development patterns
Cross-cutting pattern skills:
patterns-concurrency-dev- Actors, STM, async patterns across languagespatterns-serialization-dev- JSON, validation across languagespatterns-metaprogramming-dev- Macros (Elixir) vs Template Haskell