| name | convert-roc-erlang |
| description | 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. |
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
- Identify pure vs effectful - Pure functions translate directly, effects need processes
- Design process architecture - Tasks become gen_server or simple processes
- Map static types to dynamic patterns - Use tagged tuples and specs
- Implement supervision - Add supervision trees for fault tolerance
- Extract platform logic - Platform becomes OTP behaviors
- 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
exposesmaps to-export- Type signatures become
-specdeclarations - 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
whenbecomes 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.walkbecomeslists:foldlList.mapbecomeslists:mapList.keepIfbecomeslists: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.withDefaultbecomes 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
undefinedatom - Some(value) becomes the value directly
- Pattern matching on undefined is explicit
- Alternatively use
{ok, Value}/errorpattern
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:
interfacebecomes-moduleexposesbecomes-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 ?assertEqualprovides assertion- Tests run with
rebar3 eunit
Common Pitfalls
Trying to keep everything pure: Erlang embraces processes and side effects. Don't avoid them.
Not using OTP behaviors: Raw processes are harder to supervise. Use gen_server, gen_statem, supervisor.
Ignoring dynamic typing: Erlang is dynamically typed. Use specs and dialyzer, but don't expect compile-time type safety.
Over-engineering for fault tolerance: Not everything needs to be a process. Pure functions can stay functions.
Translating Result chains literally: Erlang's error handling is often simpler with let-it-crash. Only use explicit error handling when recovery is possible.
Forgetting about distribution: Erlang makes distribution easy. Consider whether your application should be distributed.
Not thinking about hot code reload: Erlang supports hot code reloading. Design with code_change in mind.
Assuming immutability everywhere: While Erlang data is immutable, process state is mutable via message passing.
Missing the message passing idiom: Don't just call functions - think about message protocols between processes.
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
Static Type Safety: Roc's compile-time type checking doesn't exist in Erlang. Use specs and dialyzer for gradual typing.
Platform Abstraction: Roc platforms hide implementation details. In Erlang, you must design process architecture explicitly.
Pure Functional Guarantees: Roc guarantees purity. Erlang processes have side effects - embrace it.
Zero-Cost Abstractions: Roc compiles to native code. Erlang runs on BEAM VM with different performance characteristics.
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 examplesconvert-erlang-roc- Reverse conversion (Erlang → Roc)lang-roc-dev- Roc development patterns and platform modellang-erlang-dev- Erlang development patterns and OTP
Cross-cutting pattern skills:
patterns-concurrency-dev- Pure functions vs actors vs tasks across languagespatterns-serialization-dev- Encoding/decoding across languagespatterns-metaprogramming-dev- Compile-time vs runtime code generation