Back to skills
extension
Category: Development & EngineeringNo API key required

convert-roc-erlang

Convert Roc code to idiomatic Erlang. Use when migrating Roc projects to Erlang/OTP, translating functional patterns to process-based architectures, or refactoring Roc codebases. Extends meta-convert-dev with Roc-to-Erlang specific patterns.

personAuthor: jakexiaohubgithub

Convert Roc to Erlang

Convert Roc code to idiomatic Erlang. This skill extends meta-convert-dev with Roc-to-Erlang specific type mappings, idiom translations, and architectural patterns for moving from pure functional programming to process-based concurrency.

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: Roc static types → Erlang dynamic types
  • Paradigm translation: Pure functional + platform Tasks → Process-based concurrency
  • Idiom translations: Roc functional patterns → OTP patterns
  • Error handling: Result types → Let-it-crash + supervisors
  • Concurrency: Roc platform Tasks → Erlang processes
  • Module system: Roc platform/application → Erlang OTP application architecture

This Skill Does NOT Cover

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

Quick Reference

| Roc | Erlang | Notes | |-----|--------|-------| | [Tag] | atom() | Tags become atoms | | I64 / U64 | integer() | Erlang integers are arbitrary precision | | F64 | float() | 64-bit float | | List U8 | binary() | Byte sequences | | List a | list() | Heterogeneous possible | | (a, b, c) | {a, b, c} | Fixed-size tuples | | Dict k v | map() or #{k => v} | Key-value maps | | Ok(value) | {ok, Value} | Success result | | Err(reason) | {error, Reason} | Error result | | Task a err | Process/gen_server | Platform tasks become processes | | {} -> T | fun(() -> T) | Zero-arg function | | None in tag union | undefined | Optional values |

When Converting Code

  1. Identify pure vs effectful - Pure functions translate directly, effects need processes
  2. Design process architecture - Tasks become gen_server or simple processes
  3. Map static types to dynamic patterns - Use tagged tuples and specs
  4. Implement supervision - Add supervision trees for fault tolerance
  5. Extract platform logic - Platform becomes OTP behaviors
  6. Test equivalence - Verify behavior matches despite different architecture

Paradigm Translation

Mental Model Shift: Pure Functions + Platform → Processes + Message Passing

| Roc Concept | Erlang Approach | Key Insight | |-------------|-----------------|-------------| | Pure function with data | Function or process state | Processes add lifecycle management | | Function composition | Message passing or function calls | Choose based on concurrency needs | | Platform task | Process with message loop | Effects require process model | | Task chaining | Sequential message sends | Or gen_server calls | | Result type | Tagged tuples {ok, Val} / {error, Reason} | Explicit error tuples | | Tag unions | Tagged tuples and pattern matching | Multiple atoms as tags | | Platform capabilities | OTP behaviors (gen_server, supervisor) | Framework provides structure |

Concurrency Mental Model

| Roc Model | Erlang Model | Conceptual Translation | |-----------|--------------|------------------------| | Platform Tasks | Lightweight processes | Tasks become spawned processes | | Task composition | Message passing | Chain via messages or calls | | Pure data flow | Explicit message protocols | Messages are data flow | | Platform error handling | Supervision trees | Let it crash philosophy | | Platform-managed lifecycle | Process lifecycle + monitors | Explicit lifecycle management |


Type System Mapping

Primitive Types

| Roc | Erlang | Notes | |-----|--------|-------| | Bool | true / false | Boolean atoms | | I8, I16, I32, I64, I128 | integer() | Arbitrary precision in Erlang | | U8, U16, U32, U64, U128 | integer() | Erlang doesn't distinguish signed/unsigned | | F32, F64 | float() | 64-bit floating point | | Str | string() / binary() | Use binary for efficiency | | List U8 | binary() | Byte sequences |

Collection Types

| Roc | Erlang | Notes | |-----|--------|-------| | List a | [a] | Erlang lists can be heterogeneous | | Dict k v | #{k => v} | Maps (Erlang 17+) | | Set a | sets:set(a) or gb_sets:set(a) | Standard library modules | | (A, B, C) | {A, B, C} | Direct tuple mapping |

Composite Types

| Roc | Erlang | Notes | |-----|--------|-------| | { field : Type } | -record(name, {field :: type()}) | Records with type specs | | { field : Type } | #{field => Type} | Or maps for flexibility | | [Tag] | atom() | Single tag becomes atom | | [Tag1, Tag2] | Tagged tuple or atom | Multiple tags use tuples | | [Tag(A)] | {tag, A} | Tag with payload | | Result a e | {ok, A} \| {error, E} | Standard error convention |

Function Types

| Roc | Erlang | Notes | |-----|--------|-------| | {} -> R | fun(() -> R) | Zero-argument function | | A -> R | fun((A) -> R) | Single argument | | A, B -> R | fun((A, B) -> R) | Multiple arguments | | Generic <a> | Dynamic typing | No generics needed |

Error Types

| Roc | Erlang | Notes | |-----|--------|-------| | Ok(value) | {ok, Value} | Success case | | Err(reason) | {error, Reason} | Error case | | Result V R | {ok, V} \| {error, R} | Result pattern | | Tag unions for errors | Multiple error atoms/tuples | Explicit error variants |


Idiom Translation

Pattern 1: Simple Pure Function

Roc:

interface MathUtils
    exposes [add, square]
    imports []

add : I64, I64 -> I64
add = \a, b -> a + b

square : I64 -> I64
square = \n -> n * n

Erlang:

-module(math_utils).
-export([add/2, square/1]).

-spec add(integer(), integer()) -> integer().
add(A, B) -> A + B.

-spec square(integer()) -> integer().
square(N) -> N * N.

Why this translation:

  • Roc interfaces become Erlang modules
  • exposes maps to -export
  • Type signatures become -spec declarations
  • Lambda syntax becomes function clauses
  • No process needed for pure functions

Pattern 2: Pattern Matching on Tags

Roc:

processResult : [Ok Data, Err Reason, Unknown] -> [Success Data, Failure Reason]
processResult = \result ->
    when result is
        Ok(data) -> Success(data)
        Err(reason) -> Failure(reason)
        Unknown -> Failure(UnknownResult)

Erlang:

-spec process_result({ok, Data} | {error, Reason} | unknown) ->
    {success, Data} | {failure, Reason}.
process_result({ok, Data}) ->
    {success, Data};
process_result({error, Reason}) ->
    {failure, Reason};
process_result(unknown) ->
    {failure, unknown_result}.

Why this translation:

  • Roc tags map to Erlang atoms and tagged tuples
  • when becomes multiple function clauses
  • Pattern matching syntax is similar
  • Tag payloads become tuple elements

Pattern 3: List Processing

Roc:

sum : List I64 -> I64
sum = \list ->
    List.walk(list, 0, Num.add)

map : List a, (a -> b) -> List b
map = \list, fn ->
    List.map(list, fn)

filter : List a, (a -> Bool) -> List a
filter = \list, pred ->
    List.keepIf(list, pred)

Erlang:

-spec sum([integer()]) -> integer().
sum(List) ->
    lists:foldl(fun(X, Acc) -> X + Acc end, 0, List).

-spec map([A], fun((A) -> B)) -> [B].
map(List, Fn) ->
    lists:map(Fn, List).

-spec filter([A], fun((A) -> boolean())) -> [A].
filter(List, Pred) ->
    lists:filter(Pred, List).

Why this translation:

  • List.walk becomes lists:foldl
  • List.map becomes lists:map
  • List.keepIf becomes lists:filter
  • Higher-order functions translate directly
  • Erlang lists module provides standard operations

Pattern 4: Records

Roc:

User : {
    name : Str,
    age : U32,
    email : Str,
}

createUser : Str, U32, Str -> User
createUser = \name, age, email ->
    { name, age, email }

updateAge : User, U32 -> User
updateAge = \user, newAge ->
    { user & age: newAge }

getName : User -> Str
getName = \{ name } ->
    name

Erlang:

-record(user, {
    name :: string(),
    age :: non_neg_integer(),
    email :: string()
}).

-spec create_user(string(), non_neg_integer(), string()) -> #user{}.
create_user(Name, Age, Email) ->
    #user{name=Name, age=Age, email=Email}.

-spec update_age(#user{}, non_neg_integer()) -> #user{}.
update_age(User, NewAge) ->
    User#user{age=NewAge}.

-spec get_name(#user{}) -> string().
get_name(#user{name=Name}) ->
    Name.

Why this translation:

  • Roc records map to Erlang records
  • Record update syntax is similar
  • Pattern matching on records works similarly
  • Use type specs for record field types

Pattern 5: Pure State Functions → gen_server

Roc:

# Pure state machine - no processes
State : I64

init : State
init = 0

increment : State -> State
increment = \count ->
    count + 1

getCount : State -> I64
getCount = \count ->
    count

# Platform would provide state management primitives

Erlang:

-module(counter_server).
-behaviour(gen_server).

-export([start_link/0, increment/0, get_count/0]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
         terminate/2, code_change/3]).

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

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

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

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

handle_call(get_count, _From, Count) ->
    {reply, Count, Count};
handle_call(_Request, _From, State) ->
    {reply, ok, State}.

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

handle_info(_Info, State) ->
    {noreply, State}.

terminate(_Reason, _State) ->
    ok.

code_change(_OldVsn, State, _Extra) ->
    {ok, State}.

Why this translation:

  • Pure state functions become gen_server state machine
  • State is maintained by process, not passed as parameter
  • Function calls become gen_server:call or cast
  • OTP provides supervision and fault tolerance
  • Process lifecycle adds start_link, terminate

Pattern 6: Result Type → Tagged Tuples

Roc:

divide : F64, F64 -> Result F64 [DivisionByZero]
divide = \a, b ->
    if b == 0 then
        Err(DivisionByZero)
    else
        Ok(a / b)

safeDivide : F64, F64 -> F64
safeDivide = \a, b ->
    divide(a, b)
    |> Result.withDefault(0)

Erlang:

-spec divide(float(), float()) -> {ok, float()} | {error, division_by_zero}.
divide(_A, 0.0) ->
    {error, division_by_zero};
divide(A, B) ->
    {ok, A / B}.

-spec safe_divide(float(), float()) -> float().
safe_divide(A, B) ->
    case divide(A, B) of
        {ok, Result} -> Result;
        {error, _} -> 0.0
    end.

Why this translation:

  • Roc Result type maps to {ok, Value} / {error, Reason}
  • Pattern matching on result works like case
  • Result.withDefault becomes case with fallback
  • Error tags become atoms

Pattern 7: Optional Values

Roc:

findUser : U64, List User -> [Some User, None]
findUser = \id, users ->
    users
    |> List.findFirst(\user -> user.id == id)
    |> Result.map(Some)
    |> Result.withDefault(None)

getEmail : [Some User, None] -> Str
getEmail = \maybeUser ->
    when maybeUser is
        Some({ email }) -> email
        None -> "no email"

Erlang:

-spec find_user(non_neg_integer(), [user()]) -> user() | undefined.
find_user(Id, Users) ->
    case lists:keyfind(Id, #user.id, Users) of
        #user{} = User -> User;
        false -> undefined
    end.

-spec get_email(user() | undefined) -> string().
get_email(undefined) ->
    "no email";
get_email(#user{email=Email}) ->
    Email.

Why this translation:

  • Roc None maps to Erlang undefined atom
  • Some(value) becomes the value directly
  • Pattern matching on undefined is explicit
  • Alternatively use {ok, Value} / error pattern

Pattern 8: List Operations

Roc:

squares : List I64 -> List I64
squares = \list ->
    List.map(list, \x -> x * x)

evens : List I64 -> List I64
evens = \list ->
    List.keepIf(list, \x -> x % 2 == 0)

pairs : List a, List b -> List (a, b)
pairs = \list1, list2 ->
    List.joinMap(list1, \x ->
        List.map(list2, \y -> (x, y))
    )

Erlang:

-spec squares([integer()]) -> [integer()].
squares(List) ->
    [X * X || X <- List].

-spec evens([integer()]) -> [integer()].
evens(List) ->
    [X || X <- List, X rem 2 == 0].

-spec pairs([A], [B]) -> [{A, B}].
pairs(List1, List2) ->
    [{X, Y} || X <- List1, Y <- List2].

Why this translation:

  • Roc map/filter operations become list comprehensions
  • List comprehensions are idiomatic in Erlang
  • joinMap (flatMap) becomes nested comprehension
  • More concise than explicit map/filter calls

Concurrency Patterns

Roc Task Model → Erlang Process Model

Roc has no built-in concurrency - it's platform-provided. Erlang's concurrency is core to the language via lightweight processes.

Roc:

# No processes - pure functions
processWork : Data -> Result
processWork = \data ->
    transform(data)

# Platform provides Tasks
doWork : Data -> Task Result []
doWork = \data ->
    Task.fromResult(processWork(data))

# Multiple concurrent tasks (platform-dependent)
doMultipleWork : List Data -> Task (List Result) []
doMultipleWork = \dataList ->
    dataList
    |> List.map(doWork)
    |> Task.sequence

Erlang:

% Direct process spawning
process_work(Data) ->
    transform(Data).

do_work(Data) ->
    % Spawn process to do work
    Self = self(),
    spawn(fun() ->
        Result = process_work(Data),
        Self ! {result, Result}
    end),
    receive
        {result, Result} -> Result
    end.

% Multiple concurrent processes
do_multiple_work(DataList) ->
    Self = self(),
    % Spawn workers
    _Pids = [spawn(fun() ->
        Result = process_work(Data),
        Self ! {result, Result}
    end) || Data <- DataList],
    % Collect results
    [receive {result, R} -> R end || _ <- DataList].

Why this approach:

  • Roc Tasks are abstracted by platform
  • Erlang exposes processes directly
  • spawn creates lightweight processes
  • Message passing is explicit
  • Erlang gives fine-grained control

Platform Effects → OTP Behaviors

Roc:

# Hypothetical platform API
doWorkflow : Input -> Result Output [WorkerFailed, ValidationFailed]
doWorkflow = \input ->
    validated = validate!(input)
    processed = processData!(validated)
    saved = saveResult!(processed)
    Ok(saved)

Erlang:

% gen_server for stateful workflow
-module(workflow_server).
-behaviour(gen_server).

-export([start_link/0, execute/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
         terminate/2, code_change/3]).

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

execute(Input) ->
    gen_server:call(?MODULE, {execute, Input}).

init([]) ->
    {ok, #{}}.

handle_call({execute, Input}, _From, State) ->
    Result = do_workflow(Input),
    {reply, Result, State}.

do_workflow(Input) ->
    case validate(Input) of
        {ok, Validated} ->
            case process_data(Validated) of
                {ok, Processed} ->
                    save_result(Processed);
                {error, Reason} ->
                    {error, {worker_failed, Reason}}
            end;
        {error, Reason} ->
            {error, {validation_failed, Reason}}
    end.

% ... other callbacks

Why this translation:

  • Roc platform Tasks become gen_server
  • Error handling is explicit with tagged tuples
  • Process provides lifecycle management
  • Supervision can be added for fault tolerance

Supervision → Explicit Supervision Trees

Roc:

# Platform handles process-level concerns
# Application code doesn't deal with supervision

Erlang:

-module(my_supervisor).
-behaviour(supervisor).

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

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

init([]) ->
    SupFlags = #{
        strategy => one_for_one,
        intensity => 5,
        period => 60
    },

    ChildSpecs = [
        #{
            id => worker1,
            start => {worker_module, start_link, []},
            restart => permanent,
            shutdown => 5000,
            type => worker,
            modules => [worker_module]
        }
    ],

    {ok, {SupFlags, ChildSpecs}}.

Why this approach:

  • Roc platforms hide supervision
  • Erlang exposes supervision trees
  • Design supervision hierarchy explicitly
  • Configure restart strategies
  • Implement "let it crash" philosophy

Error Handling

Result Types → Let It Crash + Tagged Tuples

Roc Philosophy:

# Make errors explicit with Result types
processData : Data -> Result Success [ValidationErr, TransformErr, SaveErr]
processData = \data ->
    validated = validate!(data)
    transformed = transform!(validated)
    saved = save!(transformed)
    Ok(saved)

Erlang Philosophy:

% Let it crash - supervisor will restart
process_data(Data) ->
    Validated = validate(Data),      % May crash
    Transformed = transform(Validated),     % May crash
    save(Transformed).         % May crash

% Or use tagged tuples for recoverable errors
process_data_safe(Data) ->
    case validate(Data) of
        {ok, Validated} ->
            case transform(Validated) of
                {ok, Transformed} ->
                    save(Transformed);
                {error, Reason} ->
                    {error, {transform_error, Reason}}
            end;
        {error, Reason} ->
            {error, {validation_error, Reason}}
    end.

Key Differences:

  • Roc: Explicit error propagation via Result
  • Erlang: Let it crash OR explicit error tuples
  • Roc: Fault tolerance via platform
  • Erlang: Fault tolerance via process isolation + supervisors

Error Pattern Translation

| Roc Pattern | Erlang Pattern | Notes | |-------------|----------------|-------| | Err(error) | {error, Reason} or crash | Choose based on recoverability | | Ok(value) | {ok, Value} | Standard success pattern | | Result.try (!) | case or crash | Chain error handling | | Tag unions for errors | Atoms or tagged tuples | Multiple error types | | Platform retry | Explicit retry or supervisor restart | No automatic retry |


Module System

Roc Interface → Erlang Module

Roc:

interface Calculator
    exposes [Result, add, subtract]
    imports []

Result : [Ok F64, Err [InvalidInput]]

add : F64, F64 -> Result
add = \a, b -> Ok(a + b)

subtract : F64, F64 -> Result
subtract = \a, b -> Ok(a - b)

Erlang:

-module(calculator).
-export([add/2, subtract/2]).
-export_type([result/0]).

-type result() :: {ok, float()} | {error, invalid_input}.

-spec add(float(), float()) -> result().
add(A, B) -> {ok, A + B}.

-spec subtract(float(), float()) -> result().
subtract(A, B) -> {ok, A - B}.

Why this translation:

  • interface becomes -module
  • exposes becomes -export
  • Type exports use -export_type
  • Type definitions use -type
  • Specs provide type signatures

Application Structure

Roc Application:

my-app/
├── main.roc          # Entry point
├── Worker.roc        # Worker module
└── Types.roc         # Shared types

Erlang OTP Application:

my_app/
├── src/
│   ├── my_app.app.src    # Application resource file
│   ├── my_app_app.erl    # Application behavior
│   ├── my_app_sup.erl    # Top-level supervisor
│   └── my_worker.erl     # Worker gen_server
├── include/
│   └── my_app.hrl        # Header files
└── rebar.config          # Build configuration

Key Differences:

  • Roc: Single entry point
  • Erlang: Application behavior + supervisor tree
  • Roc: Platform provides I/O
  • Erlang: OTP behaviors structure application
  • Erlang adds supervision hierarchy

Platform Architecture

Roc Application + Platform → OTP Application

Roc:

app [main] {
    pf: platform "https://github.com/roc-lang/basic-cli/..."
}

import pf.Stdout
import pf.Task exposing [Task]

main : Task {} []
main =
    Stdout.line!("Hello, World!")

Erlang:

% my_app.app.src
{application, my_app, [
    {description, "My Application"},
    {vsn, "1.0.0"},
    {registered, []},
    {mod, {my_app_app, []}},
    {applications, [kernel, stdlib]},
    {env, []}
]}.

% my_app_app.erl
-module(my_app_app).
-behaviour(application).

-export([start/2, stop/1]).

start(_Type, _Args) ->
    io:format("Hello, World!~n"),
    my_app_sup:start_link().

stop(_State) ->
    ok.

% my_app_sup.erl
-module(my_app_sup).
-behaviour(supervisor).

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

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

init([]) ->
    SupFlags = #{strategy => one_for_one, intensity => 5, period => 60},
    ChildSpecs = [],
    {ok, {SupFlags, ChildSpecs}}.

Why this approach:

  • Roc separates platform from application
  • Erlang uses application behavior
  • OTP requires supervisor even if empty
  • Application file defines metadata
  • Erlang exposes more lifecycle control

Testing Strategy

Roc expect → EUnit

Roc:

add : I64, I64 -> I64
add = \a, b -> a + b

expect add(2, 3) == 5
expect add(0, 0) == 0
expect add(-1, 1) == 0

expect
    result = add(2, 3)
    result == 5

Erlang:

-module(calculator_tests).
-include_lib("eunit/include/eunit.hrl").

add_test() ->
    ?assertEqual(5, calculator:add(2, 3)).

add_zero_test() ->
    ?assertEqual(0, calculator:add(0, 0)).

add_negative_test() ->
    ?assertEqual(0, calculator:add(-1, 1)).

add_complex_test() ->
    Result = calculator:add(2, 3),
    ?assertEqual(5, Result).

Why this translation:

  • Roc inline expects become EUnit test functions
  • Test function names end with _test
  • ?assertEqual provides assertion
  • Tests run with rebar3 eunit

Common Pitfalls

  1. Trying to keep everything pure: Erlang embraces processes and side effects. Don't avoid them.

  2. Not using OTP behaviors: Raw processes are harder to supervise. Use gen_server, gen_statem, supervisor.

  3. Ignoring dynamic typing: Erlang is dynamically typed. Use specs and dialyzer, but don't expect compile-time type safety.

  4. Over-engineering for fault tolerance: Not everything needs to be a process. Pure functions can stay functions.

  5. Translating Result chains literally: Erlang's error handling is often simpler with let-it-crash. Only use explicit error handling when recovery is possible.

  6. Forgetting about distribution: Erlang makes distribution easy. Consider whether your application should be distributed.

  7. Not thinking about hot code reload: Erlang supports hot code reloading. Design with code_change in mind.

  8. Assuming immutability everywhere: While Erlang data is immutable, process state is mutable via message passing.

  9. Missing the message passing idiom: Don't just call functions - think about message protocols between processes.

  10. Not using ETS for shared state: For shared read-heavy state, ETS is more efficient than message passing.


Tooling

| Purpose | Roc | Erlang | Notes | |---------|-----|--------|-------| | Build tool | roc CLI | rebar3 | Erlang has mature build ecosystem | | Package manager | Platform URLs | hex.pm + rebar3 | Hex is standard package registry | | Testing | roc test | EUnit, CT, PropEr | Multiple testing frameworks | | REPL | - | erl shell | Interactive development | | Formatter | roc format | erlfmt, rebar3 fmt | Multiple formatters available | | Documentation | Comments | EDoc | Generate HTML docs | | Debugger | - | debugger, observer | GUI debugging tools | | Profiling | - | fprof, eprof | Multiple profiling tools | | Static analysis | Type system | dialyzer | Gradual typing via specs | | Release building | Platform | relx (via rebar3) | Production releases |


Examples

Example 1: Simple - Pure Function Translation

Before (Roc):

interface Color
    exposes [Color, toString]
    imports []

Color : [Red, Green, Blue, Rgb U8 U8 U8]

toString : Color -> Str
toString = \color ->
    when color is
        Red -> "Red"
        Green -> "Green"
        Blue -> "Blue"
        Rgb(r, g, b) -> "RGB(\(Num.toStr(r)), \(Num.toStr(g)), \(Num.toStr(b)))"

After (Erlang):

-module(color).
-export([to_string/1]).

-type color() :: red | green | blue | {rgb, byte(), byte(), byte()}.

-spec to_string(color()) -> string().
to_string(red) -> "Red";
to_string(green) -> "Green";
to_string(blue) -> "Blue";
to_string({rgb, R, G, B}) ->
    io_lib:format("RGB(~p, ~p, ~p)", [R, G, B]).

Example 2: Medium - State Machine with Error Handling

Before (Roc):

interface BankAccount
    exposes [Account, new, deposit, withdraw, balance]
    imports []

Account : { balance : U64 }

new : Account
new = { balance: 0 }

deposit : Account, U64 -> Result Account [InvalidAmount]
deposit = \account, amount ->
    if amount > 0 then
        Ok({ account & balance: account.balance + amount })
    else
        Err(InvalidAmount)

withdraw : Account, U64 -> Result Account [InvalidAmount, InsufficientFunds]
withdraw = \account, amount ->
    if amount == 0 then
        Err(InvalidAmount)
    else if amount > account.balance then
        Err(InsufficientFunds)
    else
        Ok({ account & balance: account.balance - amount })

balance : Account -> U64
balance = \account ->
    account.balance

After (Erlang):

-module(bank_account).
-behaviour(gen_server).

%% API
-export([start_link/0, deposit/1, withdraw/1, balance/0]).

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

-record(state, {
    balance = 0 :: non_neg_integer()
}).

%%% API

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

deposit(Amount) ->
    gen_server:call(?MODULE, {deposit, Amount}).

withdraw(Amount) ->
    gen_server:call(?MODULE, {withdraw, Amount}).

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

%%% gen_server callbacks

init([]) ->
    {ok, #state{}}.

handle_call({deposit, Amount}, _From, State) when Amount > 0 ->
    NewState = State#state{balance = State#state.balance + Amount},
    {reply, {ok, NewState#state.balance}, NewState};
handle_call({deposit, _}, _From, State) ->
    {reply, {error, invalid_amount}, State};

handle_call({withdraw, Amount}, _From, State) when Amount =< 0 ->
    {reply, {error, invalid_amount}, State};
handle_call({withdraw, Amount}, _From, State) when Amount > State#state.balance ->
    {reply, {error, insufficient_funds}, State};
handle_call({withdraw, Amount}, _From, State) ->
    NewState = State#state{balance = State#state.balance - Amount},
    {reply, {ok, NewState#state.balance}, NewState};

handle_call(balance, _From, State) ->
    {reply, State#state.balance, State}.

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

handle_info(_Info, State) ->
    {noreply, State}.

terminate(_Reason, _State) ->
    ok.

code_change(_OldVsn, State, _Extra) ->
    {ok, State}.

Example 3: Complex - Pure State Machine to Full OTP Application

Before (Roc):

interface TaskQueue
    exposes [Queue, empty, addTask, getNext, count]
    imports []

Queue task : {
    tasks : List task,
    processed : U64,
}

empty : Queue task
empty = {
    tasks: [],
    processed: 0,
}

addTask : Queue task, task -> Queue task
addTask = \queue, task ->
    { queue & tasks: List.append(queue.tasks, task) }

getNext : Queue task -> Result (Queue task, task) [Empty]
getNext = \queue ->
    when queue.tasks is
        [] -> Err(Empty)
        [first, ..rest] ->
            newQueue = {
                tasks: rest,
                processed: queue.processed + 1,
            }
            Ok((newQueue, first))

count : Queue task -> { pending : U64, processed : U64 }
count = \queue ->
    {
        pending: List.len(queue.tasks),
        processed: queue.processed,
    }

After (Erlang):

%% task_queue.erl - gen_server implementation
-module(task_queue).
-behaviour(gen_server).

%% API
-export([start_link/0, add_task/1, get_next/0, count/0, stop/0]).

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

-record(state, {
    tasks = [] :: [term()],
    processed = 0 :: non_neg_integer()
}).

%%% API Functions

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

-spec add_task(term()) -> ok.
add_task(Task) ->
    gen_server:cast(?MODULE, {add, Task}).

-spec get_next() -> {ok, term()} | {error, empty}.
get_next() ->
    gen_server:call(?MODULE, get_next).

-spec count() -> #{pending => non_neg_integer(), processed => non_neg_integer()}.
count() ->
    gen_server:call(?MODULE, count).

-spec stop() -> ok.
stop() ->
    gen_server:stop(?MODULE).

%%% gen_server Callbacks

init([]) ->
    {ok, #state{}}.

handle_call(get_next, _From, #state{tasks = []} = State) ->
    {reply, {error, empty}, State};
handle_call(get_next, _From, #state{tasks = [First | Rest], processed = P} = State) ->
    NewState = State#state{
        tasks = Rest,
        processed = P + 1
    },
    {reply, {ok, First}, NewState};

handle_call(count, _From, #state{tasks = Tasks, processed = P} = State) ->
    Result = #{
        pending => length(Tasks),
        processed => P
    },
    {reply, Result, State}.

handle_cast({add, Task}, #state{tasks = Tasks} = State) ->
    NewState = State#state{tasks = Tasks ++ [Task]},
    {noreply, NewState};
handle_cast(_Msg, State) ->
    {noreply, State}.

handle_info(_Info, State) ->
    {noreply, State}.

terminate(_Reason, _State) ->
    ok.

code_change(_OldVsn, State, _Extra) ->
    {ok, State}.

%% task_queue_sup.erl - Supervisor
-module(task_queue_sup).
-behaviour(supervisor).

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

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

init([]) ->
    SupFlags = #{
        strategy => one_for_one,
        intensity => 5,
        period => 60
    },

    ChildSpecs = [
        #{
            id => task_queue,
            start => {task_queue, start_link, []},
            restart => permanent,
            shutdown => 5000,
            type => worker,
            modules => [task_queue]
        }
    ],

    {ok, {SupFlags, ChildSpecs}}.

%% task_queue_app.erl - Application
-module(task_queue_app).
-behaviour(application).

-export([start/2, stop/1]).

start(_Type, _Args) ->
    task_queue_sup:start_link().

stop(_State) ->
    ok.

Limitations

Areas Where Direct Translation Is Difficult

  1. Static Type Safety: Roc's compile-time type checking doesn't exist in Erlang. Use specs and dialyzer for gradual typing.

  2. Platform Abstraction: Roc platforms hide implementation details. In Erlang, you must design process architecture explicitly.

  3. Pure Functional Guarantees: Roc guarantees purity. Erlang processes have side effects - embrace it.

  4. Zero-Cost Abstractions: Roc compiles to native code. Erlang runs on BEAM VM with different performance characteristics.

  5. Automatic Memory Management: Roc has predictable memory, Erlang uses per-process GC.

Working Around Limitations

  • Instead of static types: Use comprehensive specs and run dialyzer regularly
  • Instead of platform abstraction: Design OTP application structure explicitly
  • Instead of purity: Use processes for effects, keep business logic pure where practical
  • Instead of native performance: Leverage Erlang's concurrency for throughput
  • Instead of predictable memory: Design for process isolation and let GC handle each process

See Also

For more examples and patterns, see:

  • meta-convert-dev - Foundational patterns with cross-language examples
  • convert-erlang-roc - Reverse conversion (Erlang → Roc)
  • lang-roc-dev - Roc development patterns and platform model
  • lang-erlang-dev - Erlang development patterns and OTP

Cross-cutting pattern skills:

  • patterns-concurrency-dev - Pure functions vs actors vs tasks across languages
  • patterns-serialization-dev - Encoding/decoding across languages
  • patterns-metaprogramming-dev - Compile-time vs runtime code generation