Claude-skill-registry 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.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/convert-roc-erlang" ~/.claude/skills/majiayu000-claude-skill-registry-convert-roc-erlang && rm -rf "$T"
skills/data/convert-roc-erlang/SKILL.mdConvert 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
- Foundational conversion patterns (APTV workflow, testing strategies)meta-convert-dev
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 |
|---|---|---|
| | Tags become atoms |
/ | | Erlang integers are arbitrary precision |
| | 64-bit float |
| | Byte sequences |
| | Heterogeneous possible |
| | Fixed-size tuples |
| or | Key-value maps |
| | Success result |
| | Error result |
| Process/gen_server | Platform tasks become processes |
| | Zero-arg function |
in tag union | | 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 / | 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 |
|---|---|---|
| / | Boolean atoms |
, , , , | | Arbitrary precision in Erlang |
, , , , | | Erlang doesn't distinguish signed/unsigned |
, | | 64-bit floating point |
| / | Use binary for efficiency |
| | Byte sequences |
Collection Types
| Roc | Erlang | Notes |
|---|---|---|
| | Erlang lists can be heterogeneous |
| | Maps (Erlang 17+) |
| or | Standard library modules |
| | Direct tuple mapping |
Composite Types
| Roc | Erlang | Notes |
|---|---|---|
| | Records with type specs |
| | Or maps for flexibility |
| | Single tag becomes atom |
| Tagged tuple or atom | Multiple tags use tuples |
| | Tag with payload |
| | Standard error convention |
Function Types
| Roc | Erlang | Notes |
|---|---|---|
| | Zero-argument function |
| | Single argument |
| | Multiple arguments |
Generic | Dynamic typing | No generics needed |
Error Types
| Roc | Erlang | Notes |
|---|---|---|
| | Success case |
| | Error case |
| | 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
maps toexposes-export- Type signatures become
declarations-spec - 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
becomes multiple function clauseswhen- 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:
becomesList.walklists:foldl
becomesList.maplists:map
becomesList.keepIflists: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
becomes case with fallbackResult.withDefault- 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
atomundefined - Some(value) becomes the value directly
- Pattern matching on undefined is explicit
- Alternatively use
/{ok, Value}
patternerror
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
(flatMap) becomes nested comprehensionjoinMap- 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 |
|---|---|---|
| or crash | Choose based on recoverability |
| | Standard success pattern |
(!) | 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:
becomesinterface-module
becomesexposes-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
provides assertion?assertEqual- 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 | CLI | rebar3 | Erlang has mature build ecosystem |
| Package manager | Platform URLs | hex.pm + rebar3 | Hex is standard package registry |
| Testing | | EUnit, CT, PropEr | Multiple testing frameworks |
| REPL | - | shell | Interactive development |
| Formatter | | 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:
- Foundational patterns with cross-language examplesmeta-convert-dev
- Reverse conversion (Erlang → Roc)convert-erlang-roc
- Roc development patterns and platform modellang-roc-dev
- Erlang development patterns and OTPlang-erlang-dev
Cross-cutting pattern skills:
- Pure functions vs actors vs tasks across languagespatterns-concurrency-dev
- Encoding/decoding across languagespatterns-serialization-dev
- Compile-time vs runtime code generationpatterns-metaprogramming-dev