返回 Skill 列表
extension
分类: 开发与工程无需 API Key

convert-haskell-erlang

将Haskell代码转换为惯用的Erlang/OTP代码。在将Haskell项目迁移到Erlang、将纯函数式模式翻译成容错并发系统或将Haskell代码库重构为分布式Erlang应用程序时使用。扩展了meta-convert-dev,增加了从Haskell到Erlang特定模式的支持。

person作者: jakexiaohubgithub

Convert Haskell to Erlang

Convert Haskell code to idiomatic Erlang/OTP. This skill extends meta-convert-dev with Haskell-to-Erlang specific type mappings, idiom translations, and tooling for converting pure functional code to fault-tolerant distributed systems.

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: Haskell types → Erlang terms
  • Idiom translations: Pure functions → Process-based patterns
  • Error handling: Maybe/Either → ok/error tuples
  • Async patterns: Monadic IO → Process message passing
  • Concurrency: STM/Async → OTP behaviors and supervision
  • Evaluation: Lazy → Eager evaluation strategies

This Skill Does NOT Cover

  • General conversion methodology - see meta-convert-dev
  • Haskell language fundamentals - see lang-haskell-dev
  • Erlang language fundamentals - see lang-erlang-dev
  • Reverse conversion (Erlang → Haskell) - see convert-erlang-haskell

Quick Reference

| Haskell | Erlang | Notes | |---------|--------|-------| | Int, Integer | integer() | Arbitrary precision in both | | Float, Double | float() | IEEE 754 floating point | | String | string() / binary() | String is [char()], prefer binaries | | [a] | [term()] | Lists work similarly | | (a, b) | {A, B} | Tuples preserve structure | | Maybe a | undefined \| A or {ok, A} \| error | No Maybe, use atoms/tuples | | Either e a | {ok, A} \| {error, E} | Standard Erlang convention | | data X = ... | -record(x, {...}) or map() | Records or maps | | class TypeClass | -behaviour(...) | OTP behaviors | | IO a | fun() -> A or process | Side effects via processes | | do notation | Pattern matching + case | Sequential operations | | fmap, <$> | lists:map/2 | List transformations | | >>= (bind) | Nested function calls | No monadic bind | | pure / return | Direct value | No lifting needed |

When Converting Code

  1. Analyze purity boundaries - Identify pure vs effectful code
  2. Map types to Erlang terms - Create type equivalence table
  3. Convert laziness to eagerness - Evaluate strictly
  4. Replace monads with processes - IO/State → gen_server
  5. Adopt Erlang idioms - Don't write "Haskell code in Erlang syntax"
  6. Embrace failure - Use supervision trees, not defensive code
  7. Test equivalence - Same inputs → same outputs

Type System Mapping

Primitive Types

| Haskell | Erlang | Notes | |---------|--------|-------| | Int | integer() | Erlang integers are arbitrary precision | | Integer | integer() | No distinction in Erlang | | Float | float() | IEEE 754 double precision | | Double | float() | No separate double type | | Char | char() | Integer 0-1114111 (Unicode) | | Bool | boolean() | true / false atoms | | () (unit) | ok or {} | Often use ok atom |

Collection Types

| Haskell | Erlang | Notes | |---------|--------|-------| | [a] | [A] | Lists are similar, but eager | | String | string() | List of chars: [char()] | | Text | binary() | Prefer UTF-8 binaries | | ByteString | binary() | Direct mapping | | (a, b) | {A, B} | Tuples | | (a, b, c) | {A, B, C} | N-tuples supported | | Map k v | #{K => V} | Erlang maps (17+) | | Set a | sets:set(A) or ordsets:ordset(A) | Standard library modules |

Composite Types

| Haskell | Erlang | Notes | |---------|--------|-------| | data X = X { field :: Type } | -record(x, {field :: type()}) | Record syntax | | data X = X { field :: Type } | #{field => Type} | Or use maps | | newtype UserId = UserId Int | integer() or -type user_id() :: integer() | Type aliases only | | type Alias = Type | -type alias() :: type() | Type aliases | | data X = A \| B | Atoms or tagged tuples | Sum types | | data X = A Int \| B String | {a, integer()} \| {b, string()} | Tagged unions |

Function Types

| Haskell | Erlang | Notes | |---------|--------|-------| | a -> b | fun((A) -> B) | Single argument | | a -> b -> c | fun((A, B) -> C) | Multiple arguments (uncurried) | | IO a | fun(() -> A) | Thunk for lazy eval | | m a (monad) | Process or explicit state | Use processes for effects |

Monadic Types

| Haskell | Erlang | Notes | |---------|--------|-------| | Maybe a | undefined \| A | Or {ok, A} \| error | | Either e a | {ok, A} \| {error, E} | Standard error convention | | IO a | Side effects directly | No IO monad needed | | State s a | gen_server state | Use OTP behavior | | Reader r a | Pass as parameter | Or use process dictionary | | Writer w a | Accumulate in state | Or send messages |


Idiom Translation

Pattern: Maybe to ok/error

Haskell:

findUser :: UserId -> Maybe User
findUser uid = lookup uid users

getUserEmail :: UserId -> String
getUserEmail uid =
    case findUser uid of
        Just user -> email user
        Nothing -> "no-email@example.com"

Erlang:

-spec find_user(user_id()) -> {ok, user()} | error.
find_user(UserId) ->
    case lists:keyfind(UserId, #user.id, users()) of
        false -> error;
        User -> {ok, User}
    end.

-spec get_user_email(user_id()) -> string().
get_user_email(UserId) ->
    case find_user(UserId) of
        {ok, User} -> User#user.email;
        error -> "no-email@example.com"
    end.

Why this translation:

  • Erlang has no Maybe type, uses atoms and tuples instead
  • {ok, Value} | error is the standard Erlang convention
  • Pattern matching works similarly in both languages

Pattern: Either to {ok, Value} | {error, Reason}

Haskell:

validateAge :: Int -> Either String Int
validateAge age
    | age < 0 = Left "Age cannot be negative"
    | age > 150 = Left "Age too high"
    | otherwise = Right age

validateEmail :: String -> Either String String
validateEmail email
    | '@' `elem` email = Right email
    | otherwise = Left "Invalid email"

createUser :: Int -> String -> Either String User
createUser age email = do
    validAge <- validateAge age
    validEmail <- validateEmail email
    return $ User validEmail validAge

Erlang:

-spec validate_age(integer()) -> {ok, integer()} | {error, string()}.
validate_age(Age) when Age < 0 ->
    {error, "Age cannot be negative"};
validate_age(Age) when Age > 150 ->
    {error, "Age too high"};
validate_age(Age) ->
    {ok, Age}.

-spec validate_email(string()) -> {ok, string()} | {error, string()}.
validate_email(Email) ->
    case lists:member($@, Email) of
        true -> {ok, Email};
        false -> {error, "Invalid email"}
    end.

-spec create_user(integer(), string()) -> {ok, user()} | {error, string()}.
create_user(Age, Email) ->
    case validate_age(Age) of
        {ok, ValidAge} ->
            case validate_email(Email) of
                {ok, ValidEmail} ->
                    {ok, #user{email = ValidEmail, age = ValidAge}};
                {error, Reason} ->
                    {error, Reason}
            end;
        {error, Reason} ->
            {error, Reason}
    end.

Why this translation:

  • Either maps naturally to {ok, Value} | {error, Reason}
  • Monadic do-notation becomes nested case statements
  • Early returns via pattern matching replace bind operator

Pattern: List Comprehensions

Haskell:

-- Filter and map
evenSquares :: [Int] -> [Int]
evenSquares xs = [x^2 | x <- xs, even x]

-- Cartesian product
pairs :: [a] -> [b] -> [(a, b)]
pairs xs ys = [(x, y) | x <- xs, y <- ys]

-- Nested with guards
pythagoras :: Int -> [(Int, Int, Int)]
pythagoras n = [(a, b, c) | a <- [1..n],
                             b <- [a..n],
                             c <- [b..n],
                             a^2 + b^2 == c^2]

Erlang:

%% Filter and map
-spec even_squares([integer()]) -> [integer()].
even_squares(Xs) ->
    [X * X || X <- Xs, X rem 2 =:= 0].

%% Cartesian product
-spec pairs([A], [B]) -> [{A, B}].
pairs(Xs, Ys) ->
    [{X, Y} || X <- Xs, Y <- Ys].

%% Nested with guards
-spec pythagoras(integer()) -> [{integer(), integer(), integer()}].
pythagoras(N) ->
    [{A, B, C} || A <- lists:seq(1, N),
                   B <- lists:seq(A, N),
                   C <- lists:seq(B, N),
                   A * A + B * B =:= C * C].

Why this translation:

  • List comprehensions translate almost directly
  • Guards work similarly in both languages
  • Syntax is nearly identical

Pattern: Recursive Functions

Haskell:

factorial :: Integer -> Integer
factorial 0 = 1
factorial n = n * factorial (n - 1)

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

Erlang:

-spec factorial(integer()) -> integer().
factorial(0) -> 1;
factorial(N) when N > 0 -> N * factorial(N - 1).

-spec sum([integer()]) -> integer().
sum([]) -> 0;
sum([X|Xs]) -> X + sum(Xs).

-spec map(fun((A) -> B), [A]) -> [B].
map(_, []) -> [];
map(F, [X|Xs]) -> [F(X) | map(F, Xs)].

Why this translation:

  • Pattern matching translates directly
  • Recursion works identically
  • Guards provide additional safety in Erlang

Pattern: Higher-Order Functions

Haskell:

processItems :: [Item] -> [Result]
processItems items =
    items
    & filter isValid
    & map transform
    & map enrich
    & filter isComplete

-- Function composition
addThenDouble :: Int -> Int
addThenDouble = (*2) . (+1)

-- Partial application
add5 :: Int -> Int
add5 = (+5)

Erlang:

-spec process_items([item()]) -> [result()].
process_items(Items) ->
    lists:filter(fun is_complete/1,
        lists:map(fun enrich/1,
            lists:map(fun transform/1,
                lists:filter(fun is_valid/1, Items)))).

%% Or with pipes (shell syntax, not in modules)
%% Items
%%   |> lists:filter(fun is_valid/1)
%%   |> lists:map(fun transform/1)
%%   |> lists:map(fun enrich/1)
%%   |> lists:filter(fun is_complete/1).

%% Function composition - manual
-spec add_then_double(integer()) -> integer().
add_then_double(X) -> (X + 1) * 2.

%% Partial application - use fun wrapper
-spec add5(integer()) -> integer().
add5(X) -> X + 5.

%% Or return a fun
make_adder(N) -> fun(X) -> X + N end.

Why this translation:

  • Erlang doesn't have built-in composition operators
  • Functions are not curried by default
  • Use nested calls or explicit intermediate variables
  • Partial application requires wrapping in fun

Error Handling

Haskell Error Model → Erlang Error Model

| Haskell Pattern | Erlang Pattern | Use Case | |----------------|----------------|----------| | Maybe a | {ok, A} \| error | Simple success/failure | | Maybe a | undefined \| A | Nullable values | | Either String a | {ok, A} \| {error, Reason} | Errors with context | | Pure exception (error) | erlang:error(Reason) | Unrecoverable errors | | MonadError | Process exit/crash | Let it crash philosophy |

Exception Handling

Haskell:

-- Throwing errors
safeDivide :: Float -> Float -> Either String Float
safeDivide _ 0 = Left "division by zero"
safeDivide x y = Right (x / y)

-- Catching exceptions
parseConfig :: FilePath -> IO (Either String Config)
parseConfig path = do
    contents <- readFile path
    return $ case decode contents of
        Just config -> Right config
        Nothing -> Left "Failed to parse config"

Erlang:

%% Returning errors
-spec safe_divide(float(), float()) -> {ok, float()} | {error, string()}.
safe_divide(_, 0.0) ->
    {error, "division by zero"};
safe_divide(X, Y) ->
    {ok, X / Y}.

%% Try-catch for external calls
-spec parse_config(file:filename()) -> {ok, config()} | {error, term()}.
parse_config(Path) ->
    try
        {ok, Contents} = file:read_file(Path),
        case jsone:decode(Contents) of
            Config -> {ok, Config}
        end
    catch
        error:Reason -> {error, Reason}
    end.

Let It Crash vs Defensive Programming

Haskell (defensive):

processUser :: UserId -> IO (Either String Result)
processUser uid = do
    userResult <- fetchUser uid
    case userResult of
        Left err -> return $ Left err
        Right user -> do
            validationResult <- validateUser user
            case validationResult of
                Left err -> return $ Left err
                Right validUser -> do
                    saveResult <- saveUser validUser
                    case saveResult of
                        Left err -> return $ Left err
                        Right result -> return $ Right result

Erlang (let it crash):

%% Under supervisor, just crash on error
-spec process_user(user_id()) -> result().
process_user(UserId) ->
    User = fetch_user(UserId),        % Crash if not found
    ValidUser = validate_user(User),  % Crash if invalid
    save_user(ValidUser).             % Crash if save fails

%% Supervisor handles restart
-spec init([]) -> {ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}.
init([]) ->
    SupFlags = #{
        strategy => one_for_one,
        intensity => 5,
        period => 60
    },
    ChildSpecs = [#{
        id => user_processor,
        start => {user_processor, start_link, []},
        restart => permanent,
        shutdown => 5000,
        type => worker
    }],
    {ok, {SupFlags, ChildSpecs}}.

Why this translation:

  • Erlang embraces failure with supervision trees
  • Don't handle every error - let supervisors restart failed processes
  • Reserve error tuples for expected failures

Concurrency Patterns

IO Monad → Processes

Haskell:

-- Sequential IO operations
main :: IO ()
main = do
    putStrLn "Enter your name:"
    name <- getLine
    putStrLn $ "Hello, " ++ name

-- Concurrent operations with Async
import Control.Concurrent.Async

fetchData :: IO ()
fetchData = do
    (users, orders) <- concurrently fetchUsers fetchOrders
    processData users orders

Erlang:

%% Sequential operations (no special monad needed)
main() ->
    io:format("Enter your name:~n"),
    {ok, [Name]} = io:fread("", "~s"),
    io:format("Hello, ~s~n", [Name]).

%% Concurrent operations with processes
fetch_data() ->
    Parent = self(),
    spawn(fun() -> Parent ! {users, fetch_users()} end),
    spawn(fun() -> Parent ! {orders, fetch_orders()} end),

    Users = receive {users, U} -> U end,
    Orders = receive {orders, O} -> O end,

    process_data(Users, Orders).

State Monad → gen_server

Haskell:

import Control.Monad.State

type Counter a = State Int a

increment :: Counter ()
increment = modify (+1)

getCount :: Counter Int
getCount = get

program :: Counter Int
program = do
    increment
    increment
    increment
    getCount

main :: IO ()
main = print $ evalState program 0  -- Prints 3

Erlang:

-module(counter).
-behaviour(gen_server).

%% API
-export([start_link/0, increment/0, get_count/0]).

%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2]).

start_link() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).

increment() ->
    gen_server:cast(?MODULE, increment).

get_count() ->
    gen_server:call(?MODULE, get_count).

%% Callbacks
init([]) ->
    {ok, 0}.

handle_call(get_count, _From, Count) ->
    {reply, Count, Count}.

handle_cast(increment, Count) ->
    {noreply, Count + 1}.

%% Usage
main() ->
    {ok, _Pid} = counter:start_link(),
    counter:increment(),
    counter:increment(),
    counter:increment(),
    Count = counter:get_count(),
    io:format("~p~n", [Count]).  % Prints 3

STM → ETS or gen_server

Haskell:

import Control.Concurrent.STM

type Account = TVar Int

transfer :: Account -> Account -> Int -> STM ()
transfer from to amount = do
    fromBalance <- readTVar from
    when (fromBalance < amount) retry
    modifyTVar from (subtract amount)
    modifyTVar to (+ amount)

main :: IO ()
main = do
    account1 <- newTVarIO 1000
    account2 <- newTVarIO 0
    atomically $ transfer account1 account2 500

Erlang (using ETS):

-module(bank).
-export([init/0, transfer/3]).

init() ->
    ets:new(accounts, [named_table, public, set]),
    ets:insert(accounts, {account1, 1000}),
    ets:insert(accounts, {account2, 0}).

transfer(From, To, Amount) ->
    % Note: ETS operations are not atomic across multiple keys
    % For true atomicity, use gen_server or mnesia transactions
    [{From, FromBalance}] = ets:lookup(accounts, From),

    if FromBalance >= Amount ->
        ets:update_counter(accounts, From, {2, -Amount}),
        ets:update_counter(accounts, To, {2, Amount}),
        ok;
    true ->
        {error, insufficient_funds}
    end.

Erlang (using gen_server for atomicity):

-module(bank_server).
-behaviour(gen_server).

-export([start_link/0, transfer/3]).
-export([init/1, handle_call/3]).

start_link() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).

transfer(From, To, Amount) ->
    gen_server:call(?MODULE, {transfer, From, To, Amount}).

init([]) ->
    State = #{account1 => 1000, account2 => 0},
    {ok, State}.

handle_call({transfer, From, To, Amount}, _From, State) ->
    FromBalance = maps:get(From, State),
    if FromBalance >= Amount ->
        NewState = State#{
            From => FromBalance - Amount,
            To => maps:get(To, State) + Amount
        },
        {reply, ok, NewState};
    true ->
        {reply, {error, insufficient_funds}, State}
    end.

Why this translation:

  • Erlang has no STM; use ETS for shared state or gen_server for atomicity
  • gen_server provides serialized access (single-threaded state)
  • For distributed transactions, use Mnesia

Memory & Evaluation

Lazy → Eager Evaluation

Haskell (lazy):

-- Infinite lists
naturals :: [Integer]
naturals = [1..]

take10 :: [Integer]
take10 = take 10 naturals  -- Only evaluates first 10

-- Lazy evaluation benefits
expensiveComputation :: Int -> Int
expensiveComputation x = trace ("Computing " ++ show x) (x * 2)

result = if condition
         then take 5 $ map expensiveComputation [1..1000]
         else []
-- Only computes first 5 if condition is True

Erlang (eager):

%% No infinite lists - must be explicit
naturals(N) -> lists:seq(1, N).

take10() -> lists:sublist(naturals(10), 10).

%% Must be careful with large computations
expensive_computation(X) ->
    io:format("Computing ~p~n", [X]),
    X * 2.

result(Condition) ->
    case Condition of
        true ->
            %% All 1000 items are computed, then we take 5
            lists:sublist(lists:map(fun expensive_computation/1,
                                     lists:seq(1, 1000)), 5);
        false ->
            []
    end.

%% Better: Use lazy construction
result_lazy(Condition) ->
    case Condition of
        true ->
            %% Compute only what's needed
            [expensive_computation(X) || X <- lists:seq(1, 5)];
        false ->
            []
    end.

Thunks for Delayed Evaluation

Haskell:

-- Lazy by default
expensiveValue :: Int
expensiveValue = sum [1..1000000]

useValue :: Bool -> Int
useValue False = 0
useValue True = expensiveValue  -- Only computed if needed

Erlang:

%% Use fun() for thunks
expensive_value() -> lists:sum(lists:seq(1, 1000000)).

use_value(false) -> 0;
use_value(true) -> expensive_value().  % Computed every time called

%% Or pass as thunk
use_value_lazy(false, _Thunk) -> 0;
use_value_lazy(true, Thunk) -> Thunk().

%% Usage
Result = use_value_lazy(true, fun expensive_value/0).

Why this translation:

  • Erlang is eager by default
  • Use fun() to create thunks for delayed evaluation
  • Be explicit about when computation happens

Common Pitfalls

  1. Assuming laziness: Erlang evaluates eagerly. Infinite lists don't work; be careful with large computations.

  2. Missing currying: Haskell functions are curried by default; Erlang functions take all arguments at once. Use fun wrappers for partial application.

  3. Type safety assumptions: Haskell's type system catches errors at compile time; Erlang catches them at runtime. Add comprehensive tests.

  4. Monadic composition: Haskell's >>= and do-notation have no direct equivalent. Use nested case statements or helper functions.

  5. Defensive error handling: Don't translate Haskell's comprehensive error handling to defensive Erlang code. Embrace "let it crash" with supervision.

  6. Immutability patterns: Both languages are immutable, but Erlang uses processes for state instead of monads. Don't try to replicate State monad patterns.

  7. String handling: Haskell String is [Char]; Erlang string() is [char()]. Prefer binary() in Erlang for efficiency.

  8. Pattern matching limitations: Erlang guards are more limited than Haskell's. Can't call arbitrary functions in guards.

  9. Type class translation: Haskell type classes don't map directly to Erlang behaviors. Behaviors are for OTP patterns, not ad-hoc polymorphism.

  10. Performance expectations: Pure functional Haskell can be optimized heavily by GHC. Erlang prioritizes fault tolerance and concurrency over raw speed.


Tooling

| Tool | Purpose | Notes | |------|---------|-------| | Manual translation | Convert code by hand | No mature transpiler exists | | Dialyzer | Static analysis for Erlang | Type specs help catch errors | | EUnit | Unit testing | Port Haskell HSpec tests | | PropEr | Property-based testing | Similar to QuickCheck | | Common Test | Integration testing | For OTP application testing | | Rebar3 | Build tool | Like Cabal/Stack | | Observer | Runtime inspection | Monitor processes, memory |


Examples

Example 1: Simple - Pure Function Translation

Before (Haskell):

-- Calculate factorial
factorial :: Integer -> Integer
factorial 0 = 1
factorial n = n * factorial (n - 1)

-- Filter even numbers
evens :: [Int] -> [Int]
evens xs = [x | x <- xs, even x]

After (Erlang):

%% Calculate factorial
-spec factorial(integer()) -> integer().
factorial(0) -> 1;
factorial(N) when N > 0 -> N * factorial(N - 1).

%% Filter even numbers
-spec evens([integer()]) -> [integer()].
evens(Xs) -> [X || X <- Xs, X rem 2 =:= 0].

Example 2: Medium - Maybe and List Processing

Before (Haskell):

data User = User { userId :: Int, userName :: String, userEmail :: String }

findUser :: Int -> [User] -> Maybe User
findUser uid users = find (\u -> userId u == uid) users

getUserEmails :: [Int] -> [User] -> [String]
getUserEmails ids users =
    mapMaybe (\uid -> fmap userEmail $ findUser uid users) ids

processUserData :: [Int] -> [User] -> [String]
processUserData ids users =
    getUserEmails ids users
    & filter (not . null)
    & map (\email -> "Email: " ++ email)

After (Erlang):

-record(user, {
    user_id :: integer(),
    user_name :: string(),
    user_email :: string()
}).

-spec find_user(integer(), [#user{}]) -> {ok, #user{}} | error.
find_user(UserId, Users) ->
    case lists:filter(fun(U) -> U#user.user_id =:= UserId end, Users) of
        [User|_] -> {ok, User};
        [] -> error
    end.

-spec get_user_emails([integer()], [#user{}]) -> [string()].
get_user_emails(Ids, Users) ->
    lists:filtermap(
        fun(UserId) ->
            case find_user(UserId, Users) of
                {ok, User} -> {true, User#user.user_email};
                error -> false
            end
        end,
        Ids
    ).

-spec process_user_data([integer()], [#user{}]) -> [string()].
process_user_data(Ids, Users) ->
    Emails = get_user_emails(Ids, Users),
    Filtered = lists:filter(fun(E) -> E =/= "" end, Emails),
    lists:map(fun(Email) -> "Email: " ++ Email end, Filtered).

Example 3: Complex - Stateful Server with Error Handling

Before (Haskell):

{-# LANGUAGE GeneralizedNewtypeDeriving #-}

import Control.Monad.State
import Control.Monad.Except
import qualified Data.Map as Map

type UserId = Int
type SessionId = String

data User = User
    { userId :: UserId
    , userName :: String
    } deriving (Show, Eq)

data AppError
    = UserNotFound UserId
    | InvalidSession SessionId
    | DatabaseError String
    deriving (Show)

type Sessions = Map.Map SessionId UserId
type Users = Map.Map UserId User

data AppState = AppState
    { appSessions :: Sessions
    , appUsers :: Users
    } deriving (Show)

newtype App a = App
    { runApp :: ExceptT AppError (State AppState) a
    } deriving (Functor, Applicative, Monad, MonadState AppState, MonadError AppError)

createSession :: UserId -> App SessionId
createSession uid = do
    users <- gets appUsers
    case Map.lookup uid users of
        Nothing -> throwError $ UserNotFound uid
        Just _ -> do
            let sessionId = "session-" ++ show uid
            modify $ \s -> s { appSessions = Map.insert sessionId uid (appSessions s) }
            return sessionId

getUser :: SessionId -> App User
getUser sessionId = do
    sessions <- gets appSessions
    case Map.lookup sessionId sessions of
        Nothing -> throwError $ InvalidSession sessionId
        Just uid -> do
            users <- gets appUsers
            case Map.lookup uid users of
                Nothing -> throwError $ UserNotFound uid
                Just user -> return user

logout :: SessionId -> App ()
logout sessionId = do
    sessions <- gets appSessions
    unless (Map.member sessionId sessions) $
        throwError $ InvalidSession sessionId
    modify $ \s -> s { appSessions = Map.delete sessionId (appSessions s) }

-- Usage
runProgram :: AppState -> Either AppError (SessionId, AppState)
runProgram initialState =
    let action = do
            sid <- createSession 1
            user <- getUser sid
            logout sid
            return sid
    in runState (runExceptT $ runApp action) initialState

After (Erlang):

-module(session_server).
-behaviour(gen_server).

%% API
-export([start_link/0, create_session/1, get_user/1, logout/1]).

%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, terminate/2]).

-record(user, {
    user_id :: integer(),
    user_name :: string()
}).

-record(state, {
    sessions :: #{session_id() => user_id()},
    users :: #{user_id() => #user{}}
}).

-type user_id() :: integer().
-type session_id() :: string().
-type error_reason() :: {user_not_found, user_id()}
                      | {invalid_session, session_id()}
                      | {database_error, string()}.

%%% API Functions

-spec start_link() -> {ok, pid()} | {error, term()}.
start_link() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).

-spec create_session(user_id()) -> {ok, session_id()} | {error, error_reason()}.
create_session(UserId) ->
    gen_server:call(?MODULE, {create_session, UserId}).

-spec get_user(session_id()) -> {ok, #user{}} | {error, error_reason()}.
get_user(SessionId) ->
    gen_server:call(?MODULE, {get_user, SessionId}).

-spec logout(session_id()) -> ok | {error, error_reason()}.
logout(SessionId) ->
    gen_server:call(?MODULE, {logout, SessionId}).

%%% gen_server Callbacks

init([]) ->
    %% Initialize with sample user
    Users = #{1 => #user{user_id = 1, user_name = "Alice"}},
    {ok, #state{sessions = #{}, users = Users}}.

handle_call({create_session, UserId}, _From, State) ->
    #state{sessions = Sessions, users = Users} = State,
    case maps:is_key(UserId, Users) of
        false ->
            {reply, {error, {user_not_found, UserId}}, State};
        true ->
            SessionId = "session-" ++ integer_to_list(UserId),
            NewSessions = maps:put(SessionId, UserId, Sessions),
            NewState = State#state{sessions = NewSessions},
            {reply, {ok, SessionId}, NewState}
    end;

handle_call({get_user, SessionId}, _From, State) ->
    #state{sessions = Sessions, users = Users} = State,
    case maps:get(SessionId, Sessions, undefined) of
        undefined ->
            {reply, {error, {invalid_session, SessionId}}, State};
        UserId ->
            case maps:get(UserId, Users, undefined) of
                undefined ->
                    {reply, {error, {user_not_found, UserId}}, State};
                User ->
                    {reply, {ok, User}, State}
            end
    end;

handle_call({logout, SessionId}, _From, State) ->
    #state{sessions = Sessions} = State,
    case maps:is_key(SessionId, Sessions) of
        false ->
            {reply, {error, {invalid_session, SessionId}}, State};
        true ->
            NewSessions = maps:remove(SessionId, Sessions),
            NewState = State#state{sessions = NewSessions},
            {reply, ok, NewState}
    end.

handle_cast(_Msg, State) ->
    {noreply, State}.

terminate(_Reason, _State) ->
    ok.

%%% Usage Example

run_program() ->
    {ok, _Pid} = start_link(),
    {ok, SessionId} = create_session(1),
    {ok, _User} = get_user(SessionId),
    ok = logout(SessionId),
    {ok, SessionId}.

Key differences in this example:

  • Haskell's monad transformers become gen_server state
  • ExceptT error handling becomes {ok, Value} | {error, Reason} tuples
  • State monad becomes gen_server process state
  • Pattern matching and guards handle all error cases
  • OTP behavior provides structure and supervision

See Also

For more examples and patterns, see:

  • meta-convert-dev - Foundational patterns with cross-language examples
  • convert-erlang-haskell - Reverse conversion (Erlang → Haskell)
  • lang-haskell-dev - Haskell development patterns
  • lang-erlang-dev - Erlang development patterns

Cross-cutting pattern skills:

  • patterns-concurrency-dev - Processes, supervision, fault tolerance
  • patterns-serialization-dev - Data encoding, validation
  • patterns-metaprogramming-dev - Compile-time code generation