convert-elixir-roc
Convert Elixir code to idiomatic Roc. Use when migrating Elixir projects to Roc, translating BEAM/OTP patterns to platform-based architecture, or refactoring Elixir codebases. Extends meta-convert-dev with Elixir-to-Roc specific patterns.
When & Why to Use This Skill
This Claude skill automates the complex process of converting Elixir codebases into idiomatic Roc, bridging the gap between dynamic BEAM/OTP patterns and static, pure functional architectures. It provides comprehensive mappings for types, error handling, and concurrency models, enabling developers to migrate legacy Elixir projects or refactor logic into Roc's high-performance environment with high fidelity and type safety.
Use Cases
- Migrating Elixir-based backend services to Roc to leverage its static typing, memory safety, and superior execution speed.
- Translating complex Elixir GenServer and OTP actor patterns into pure state machines and platform-provided Task architectures in Roc.
- Refactoring dynamic Elixir error handling (ok/error tuples) and exceptions into Roc’s robust, compile-time enforced Result types.
- Mapping Elixir's macro-heavy DSLs and protocols into Roc's ability-based system and explicit, predictable function compositions.
- Converting Elixir ExUnit test suites and doctests into Roc's built-in 'expect' testing framework for immediate validation of migrated logic.
| name | convert-elixir-roc |
|---|---|
| description | Convert Elixir code to idiomatic Roc. Use when migrating Elixir projects to Roc, translating BEAM/OTP patterns to platform-based architecture, or refactoring Elixir codebases. Extends meta-convert-dev with Elixir-to-Roc specific patterns. |
Convert Elixir to Roc
Convert Elixir code to idiomatic Roc. This skill extends meta-convert-dev with Elixir-to-Roc specific type mappings, idiom translations, and architectural patterns for moving from dynamic, actor-based concurrency to static, pure functional programming with platform-provided effects.
This Skill Extends
meta-convert-dev- Foundational conversion patterns (APTV workflow, testing strategies)
For general concepts like the Analyze → Plan → Transform → Validate workflow, testing strategies, and common pitfalls, see the meta-skill first.
This Skill Adds
- Type mappings: Elixir dynamic types → Roc static types
- Paradigm translation: GenServer/OTP patterns → Pure state machines + platform Tasks
- Idiom translations: Elixir functional patterns → Roc functional patterns
- Error handling: Elixir error tuples + exceptions → Result types
- Concurrency: Processes/GenServer → Roc platform Tasks
- Module system: Elixir modules → Roc platform/application architecture
- Metaprogramming: Elixir macros → Roc abilities and external codegen
This Skill Does NOT Cover
- General conversion methodology - see
meta-convert-dev - Elixir language fundamentals - see
lang-elixir-dev - Roc language fundamentals - see
lang-roc-dev - Reverse conversion (Roc → Elixir) - see
convert-roc-elixir
Quick Reference
| Elixir | Roc | Notes |
|---|---|---|
:atom |
[Tag] |
Atoms become tags in tag unions |
integer() |
I64 / U64 |
Specify signedness explicitly |
float() |
F64 |
64-bit float |
String.t() |
Str |
UTF-8 strings |
binary() |
List U8 |
Byte sequences |
list() |
List a |
Homogeneous lists |
tuple() |
(a, b, c) |
Fixed-size tuples |
map() |
Dict k v |
Key-value maps (requires Hash+Eq keys) |
keyword() |
List (Str, a) |
List of tuples (no duplicate keys guaranteed) |
{:ok, value} |
Ok(value) |
Success result |
{:error, reason} |
Err(reason) |
Error result |
nil |
None in tag union |
Optional values |
pid() |
- | No direct equivalent (platform handles processes) |
fn(x) -> x end |
\x -> x |
Lambda syntax |
%{__struct__: Name} |
Opaque type Name := ... |
Structs become opaque types |
When Converting Code
- Analyze OTP patterns before writing Roc - GenServers become pure state machines
- Identify process boundaries - these become platform Task interactions
- Map dynamic patterns to static types - use tag unions for variants
- Redesign supervision trees - Roc platforms handle failure differently
- Extract pure logic - separate computation from effects (Task, IO)
- Handle hot code loading - not a language feature in Roc
- Test equivalence - verify behavior matches despite different architecture
Type System Mapping
Primitive Types
| Elixir | Roc | Notes |
|---|---|---|
:atom |
[Tag] |
Atoms as tags in tag unions |
integer() |
I64 |
Default signed 64-bit |
integer() |
U64 |
Unsigned variant |
integer() (small) |
I32 / U32 |
For smaller values |
integer() (arbitrary) |
- | Roc has fixed-size integers |
float() |
F64 |
64-bit floating point |
boolean() |
Bool |
Direct mapping |
String.t() |
Str |
UTF-8 strings |
binary() |
List U8 |
Byte sequences |
bitstring() |
List U8 |
Byte-aligned only in Roc |
nil |
None |
In tag union [Some a, None] |
pid() |
- | Processes don't exist in Roc |
port() |
- | Platform handles I/O |
reference() |
- | No direct equivalent |
function() |
Function types | See function mappings below |
Collection Types
| Elixir | Roc | Notes |
|---|---|---|
list() |
List a |
Homogeneous lists (all same type) |
[T] |
List T |
Type-safe, uniform elements |
tuple() |
(A, B, C) |
Fixed-size tuples |
{A, B, C} |
(A, B, C) |
Direct structural mapping |
map() |
Dict k v |
Key-value dictionary (requires Hash+Eq for keys) |
%{K => V} |
Dict K V |
Keys must implement Hash and Eq abilities |
MapSet.t() |
Set a |
Unique values (requires Hash+Eq) |
keyword() |
List (Str, a) |
List of tuples, no uniqueness guarantee |
[{:key, value}] |
List (Str, a) |
Keyword lists as tuple lists |
Range.t() |
- | Use List.range to generate lists |
Stream.t() |
- | Roc has lazy iterators but no Stream type |
Composite Types
| Elixir | Roc | Notes |
|---|---|---|
defstruct |
{ field : Type } |
Structs become records |
%Name{field: value} |
{ field: value } |
Record literals |
Tagged tuple {:tag, value} |
Tag(value) |
Tags with payloads |
@type name :: spec |
Name : Type |
Type alias |
@opaque name :: spec |
Opaque type Name := Type |
Hidden implementation |
@spec annotations |
Type signatures | Enforced in Roc |
Function Types
| Elixir | Roc | Notes |
|---|---|---|
(-> R) |
({} -> R) |
Zero-argument function |
(A -> R) |
(A -> R) |
Single argument |
(A, B -> R) |
(A, B -> R) |
Multiple arguments (curried) |
| Variable arity | - | Roc doesn't support varargs |
| Default arguments | - | Use separate function signatures |
Error Types
| Elixir | Roc | Notes |
|---|---|---|
{:ok, value} |
Ok(value) |
Success case |
{:error, reason} |
Err(reason) |
Error case |
:ok atom |
Ok({}) |
Success with no value |
{:ok, V} | {:error, R} |
Result V R |
Result type |
| Exception raising | Err variant |
No exceptions, use Result |
rescue clause |
Pattern match on Result | Explicit error handling |
Idiom Translation
Pattern 1: Simple Module Conversion
Elixir:
defmodule MathUtils do
@doc "Add two numbers"
def add(a, b), do: a + b
@doc "Square a number"
def square(n), do: n * n
# Private function
defp internal_helper(x), do: x * 2
end
Roc:
interface MathUtils
exposes [add, square]
imports []
## Add two numbers
add : I64, I64 -> I64
add = \a, b -> a + b
## Square a number
square : I64 -> I64
square = \n -> n * n
# Private helper (not exposed)
internalHelper : I64 -> I64
internalHelper = \x -> x * 2
Why this translation:
- Elixir modules become Roc interfaces
- Public functions go in
exposes - Private functions are simply not exposed
- Doctests become inline
expecttests in Roc - Type signatures are explicit (not optional like typespecs)
Pattern 2: Pattern Matching on Tagged Tuples
Elixir:
def process_result({:ok, data}) do
{:success, data}
end
def process_result({:error, reason}) do
{:failure, reason}
end
def process_result(_unknown) do
{:failure, :unknown_result}
end
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)
Why this translation:
- Elixir tagged tuples map to Roc tags
- Pattern matching syntax is similar (
whenvs pattern match in function head) - Tag unions make all cases explicit in type signature
- Type system ensures exhaustiveness (can't forget cases)
Pattern 3: Pipe Operator
Elixir:
def process_data(data) do
data
|> String.trim()
|> String.downcase()
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
Roc:
processData : Str -> List Str
processData = \data ->
data
|> Str.trim
|> Str.toLowercase
|> Str.split(",")
|> List.map(Str.trim)
|> List.keepIf(\s -> s != "")
Why this translation:
- Both languages have pipe operators with similar semantics
- Roc uses
keepIfinstead ofrejectwith negated predicate - Capture operator
&in Elixir becomes explicit lambdas in Roc - Type inference works similarly in both
Pattern 4: Enum Operations
Elixir:
def sum(list), do: Enum.reduce(list, 0, &+/2)
def squares(list), do: Enum.map(list, fn x -> x * x end)
def evens(list), do: Enum.filter(list, fn x -> rem(x, 2) == 0 end)
def find_first_large(list) do
Enum.find(list, fn x -> x > 100 end)
end
Roc:
sum : List I64 -> I64
sum = \list ->
List.walk(list, 0, Num.add)
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)
findFirstLarge : List I64 -> [Some I64, None]
findFirstLarge = \list ->
list
|> List.findFirst(\x -> x > 100)
|> Result.map(Some)
|> Result.withDefault(None)
Why this translation:
Enum.reducebecomesList.walk(fold)Enum.filterbecomesList.keepIfEnum.findreturnsResult, needs conversion to option type- Function captures (
&+/2) become explicit function references (Num.add) - Roc requires explicit handling of "not found" cases
Pattern 5: Struct to Record
Elixir:
defmodule User do
defstruct [:name, :email, age: 0]
def new(name, email) do
%User{name: name, email: email}
end
def update_age(user, new_age) do
%{user | age: new_age}
end
def greet(%User{name: name}), do: "Hello, #{name}!"
end
Roc:
interface User
exposes [User, new, updateAge, greet]
imports []
User : {
name : Str,
email : Str,
age : U32,
}
new : Str, Str -> User
new = \name, email ->
{ name, email, age: 0 }
updateAge : User, U32 -> User
updateAge = \user, newAge ->
{ user & age: newAge }
greet : User -> Str
greet = \{ name } ->
"Hello, \(name)!"
Why this translation:
- Elixir structs map directly to Roc records
- Record update syntax is similar (
%{user | age:}vs{ user & age:}) - Pattern matching on records works similarly
- Roc records are structural (no
__struct__metadata) - Default values in struct definition become default in constructor
Pattern 6: GenServer → Pure State Machine
Elixir:
defmodule Counter do
use GenServer
# Client API
def start_link(initial_value) do
GenServer.start_link(__MODULE__, initial_value, name: __MODULE__)
end
def increment do
GenServer.cast(__MODULE__, :increment)
end
def get do
GenServer.call(__MODULE__, :get)
end
# Server Callbacks
@impl true
def init(initial_value) do
{:ok, initial_value}
end
@impl true
def handle_call(:get, _from, state) do
{:reply, state, state}
end
@impl true
def handle_cast(:increment, state) do
{:noreply, state + 1}
end
end
Roc:
# Pure state machine - no processes
interface Counter
exposes [State, init, increment, get]
imports []
State : I64
init : State
init = 0
increment : State -> State
increment = \count ->
count + 1
get : State -> I64
get = \count ->
count
# If you need effects, use platform Tasks
# The platform would provide state management and concurrency
# Application code remains pure
Why this translation:
- GenServer becomes pure state functions
- No process lifecycle - just data transformation
- State is explicit parameter and return value
calloperations become synchronous functions that return new statecastoperations become functions that just transform state- Effects and concurrency are platform responsibilities, not shown here
- Platform provides state management if needed
Pattern 7: With Statement Error Handling
Elixir:
def create_user(params) do
with {:ok, validated} <- validate_params(params),
{:ok, user} <- insert_user(validated),
{:ok, _email} <- send_welcome_email(user) do
{:ok, user}
else
{:error, reason} -> {:error, reason}
end
end
Roc:
createUser : Params -> Result User [ValidationErr, InsertErr, EmailErr]
createUser = \params ->
validated = validateParams!(params)
user = insertUser!(validated)
_email = sendWelcomeEmail!(user)
Ok(user)
Why this translation:
- Elixir
withbecomes Roc!operator (try/propagate) !suffix propagates errors automatically- Error types are unified in tag union
- Early return on error is implicit with
! - More concise than explicit pattern matching
Pattern 8: Optional Values
Elixir:
def find_user(id, users) do
Enum.find(users, fn user -> user.id == id end)
end
def get_email(nil), do: "no email"
def get_email(user), do: user.email
# Using in pipeline
def display_email(id, users) do
users
|> find_user(id)
|> case do
nil -> "User not found"
user -> user.email
end
end
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"
displayEmail : U64, List User -> Str
displayEmail = \id, users ->
when findUser(id, users) is
Some(user) -> user.email
None -> "User not found"
Why this translation:
- Elixir
nilmaps toNonein tag union Enum.findreturnsnilor value; RocfindFirstreturnsResult- Explicit pattern matching on option types
- Type system prevents forgetting to handle
Nonecase - Must convert
Resultto option type withSome/None
Pattern 9: List Comprehensions
Elixir:
def squares(list) do
for x <- list, do: x * x
end
def evens(list) do
for x <- list, rem(x, 2) == 0, do: x
end
def cartesian_product(list1, list2) do
for x <- list1, y <- list2, do: {x, y}
end
def filtered_map(list) do
for x <- list, x > 0, into: %{}, do: {x, x * x}
end
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)
cartesianProduct : List a, List b -> List (a, b)
cartesianProduct = \list1, list2 ->
List.joinMap(list1, \x ->
List.map(list2, \y -> (x, y))
)
filteredMap : List I64 -> Dict I64 I64
filteredMap = \list ->
list
|> List.keepIf(\x -> x > 0)
|> List.map(\x -> (x, x * x))
|> Dict.fromList
Why this translation:
- Comprehensions become
map/keepIfoperations - Nested comprehensions use
joinMap(flatMap/concatMap) - Filters become
keepIf into: %{}becomesDict.fromList- More verbose but explicit
- Type signatures make intent clear
Pattern 10: Protocol Implementation
Elixir:
defprotocol Serializable do
@doc "Serialize to JSON"
def to_json(data)
end
defimpl Serializable, for: Map do
def to_json(map) do
Jason.encode!(map)
end
end
defimpl Serializable, for: List do
def to_json(list) do
Jason.encode!(list)
end
end
# Usage
Serializable.to_json(%{name: "Alice"})
Roc:
# Roc uses abilities (similar to typeclasses)
# Built-in Encode ability is auto-derived for most types
# For custom encoding:
toJson : a -> Str where a implements Encode
toJson = \value ->
value
|> Encode.toBytes(Json.utf8)
|> Str.fromUtf8
# Automatic for records and tags
user = { name: "Alice", age: 30 }
json = toJson(user) # Works automatically
# For custom types with specific behavior:
User := { name : Str, age : U32 }
toJsonUser : User -> Str
toJsonUser = \@User({ name, age }) ->
"""
{\"name\": \"\(name)\", \"age\": \(Num.toStr(age))}
"""
Why this translation:
- Elixir protocols map to Roc abilities
- Many abilities are auto-derived (Encode, Decode, Eq, Hash, Inspect)
- For custom behavior, write explicit functions
- No dynamic dispatch in Roc - abilities are compile-time
- Roc's approach is more restrictive but type-safe
Concurrency Patterns
Elixir Process Model vs Roc Task Model
Elixir's concurrency is built on lightweight BEAM processes with message passing. Roc has no built-in concurrency - it's all platform-provided through Tasks.
Mental Model Shift:
| Elixir Concept | Roc Approach | Key Insight |
|---|---|---|
| Process with state | Pure state machine + platform Task | Data and behavior separated |
| Message passing | Function parameters and results | Explicit data flow, no mailboxes |
spawn |
Platform Task creation | Effects are platform capability |
| GenServer | Pure functions + Task orchestration | Business logic pure, I/O delegated |
| Supervisor | Platform-level concern | Fault tolerance in host |
| Hot code reload | Not available | Platform restart required |
Pattern: Spawned Task
Elixir:
defmodule Worker do
def start do
spawn(fn -> loop() end)
end
defp loop do
receive do
{:work, from, data} ->
result = process(data)
send(from, {:result, result})
loop()
:stop ->
:ok
end
end
defp process(data), do: String.upcase(data)
end
# Usage
pid = Worker.start()
send(pid, {:work, self(), "hello"})
receive do
{:result, result} -> IO.puts(result)
end
Roc:
# Pure processing function
process : Str -> Str
process = \data ->
Str.toUppercase(data)
# If concurrency is needed, platform provides it
import pf.Task exposing [Task]
processAsync : Str -> Task Str []
processAsync = \data ->
# Platform handles concurrent execution
Task.await(Task.fromThunk(\{} -> process(data)))
# Usage (in Task context)
main : Task {} []
main =
result = processAsync!("hello")
Stdout.line!(result)
Why this translation:
- No process spawning in Roc - platform handles concurrency
- Pure function for business logic (
process) - Platform Task for effects
- No message passing - direct function composition
- Platform decides execution strategy (sync/async/parallel)
Pattern: GenServer State
Elixir:
defmodule Cache do
use GenServer
def start_link do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
def get(key) do
GenServer.call(__MODULE__, {:get, key})
end
def put(key, value) do
GenServer.cast(__MODULE__, {:put, key, value})
end
@impl true
def init(_), do: {:ok, %{}}
@impl true
def handle_call({:get, key}, _from, state) do
{:reply, Map.get(state, key), state}
end
@impl true
def handle_cast({:put, key, value}, state) do
{:noreply, Map.put(state, key, value)}
end
end
Roc:
# Pure state functions
State : Dict Str Str
init : State
init = Dict.empty({})
get : State, Str -> [Some Str, None]
get = \state, key ->
Dict.get(state, key)
|> Result.map(Some)
|> Result.withDefault(None)
put : State, Str, Str -> State
put = \state, key, value ->
Dict.insert(state, key, value)
# Platform would provide state management
# For example, a hypothetical Agent-like platform API:
#
# import pf.Agent exposing [Agent, start, getState, updateState]
#
# cacheAgent : Agent State
# cacheAgent = start(init)
#
# getValue : Str -> Task [Some Str, None] []
# getValue = \key ->
# Agent.getState(cacheAgent, \state -> get(state, key))
#
# putValue : Str, Str -> Task {} []
# putValue = \key, value ->
# Agent.updateState(cacheAgent, \state -> put(state, key, value))
Why this translation:
- GenServer becomes pure state transformations
- State management delegated to platform (Agent, Store, etc.)
- No process registered names - platform handles references
- No
callvscastdistinction - just sync vs async Task - Fault tolerance handled by platform, not supervision tree
Pattern: Task.async
Elixir:
def fetch_multiple(urls) do
tasks = Enum.map(urls, fn url ->
Task.async(fn -> fetch_url(url) end)
end)
Task.await_many(tasks, 5000)
end
defp fetch_url(url) do
case HTTPoison.get(url) do
{:ok, %{body: body}} -> {:ok, body}
{:error, reason} -> {:error, reason}
end
end
Roc:
import pf.Http
import pf.Task exposing [Task]
fetchMultiple : List Str -> Task (List Str) [HttpErr]
fetchMultiple = \urls ->
urls
|> List.map(\url -> Http.get(url))
|> Task.sequence # Platform may execute concurrently
|> Task.map(\responses -> List.map(responses, \r -> r.body))
# Platform-specific concurrent execution
# Some platforms may provide parallel primitives:
# fetchMultipleParallel : List Str -> Task (List Str) [HttpErr]
# fetchMultipleParallel = \urls ->
# Task.parallel(List.map(urls, Http.get))
Why this translation:
Task.asyncmaps to platform Task creation- Platform decides concurrency strategy
Task.sequencecombines multiple Tasks- Timeout is platform-specific (may be in Task API)
- No separate "await" - composition with
!orTask.map
Pattern: Agent State
Elixir:
defmodule Counter do
use Agent
def start_link(_opts) do
Agent.start_link(fn -> 0 end, name: __MODULE__)
end
def increment do
Agent.update(__MODULE__, &(&1 + 1))
end
def get do
Agent.get(__MODULE__, & &1)
end
end
Roc:
# Pure state functions
State : I64
init : State
init = 0
increment : State -> State
increment = \count -> count + 1
get : State -> I64
get = \count -> count
# Hypothetical platform API for stateful agents
# (actual API depends on platform)
#
# import pf.Agent exposing [Agent]
#
# counterAgent : Agent State
# counterAgent = Agent.start(init)
#
# incrementCounter : Task {} []
# incrementCounter =
# Agent.update(counterAgent, increment)
#
# getCounter : Task I64 []
# getCounter =
# Agent.get(counterAgent, get)
Why this translation:
- Agent pattern becomes pure state + platform state management
- No global registered names - agent references are explicit
- Updates are explicit functions, not anonymous lambdas
- Platform provides concurrency-safe state updates
- Business logic (increment, get) remains pure and testable
Error Handling
Elixir Error Model → Roc Result Type
| Elixir Pattern | Roc Pattern | Translation Strategy |
|---|---|---|
{:ok, value} |
Ok(value) |
Direct mapping |
{:error, reason} |
Err(reason) |
Direct mapping |
raise/1 |
Err variant |
No exceptions in Roc |
rescue clause |
Pattern match on Result |
Explicit error handling |
try/rescue |
Result combinators |
Compose with Result.try, Result.map |
| Multiple error tuples | Tag union error type | [Err1, Err2, Err3] |
! (bang) functions |
Regular functions returning Result |
All errors explicit |
Pattern: Error Propagation
Elixir:
def calculate(a, b, c) do
with {:ok, x} <- divide(a, b),
{:ok, y} <- divide(x, c) do
{:ok, y}
else
{:error, reason} -> {:error, reason}
end
end
# Or using the ! convention:
def calculate!(a, b, c) do
x = divide!(a, b)
y = divide!(x, c)
y
end
Roc:
calculate : I64, I64, I64 -> Result I64 [DivByZero]
calculate = \a, b, c ->
x = divide!(a, b) # Propagates error automatically
y = divide!(x, c)
Ok(y)
# Or explicitly:
calculateExplicit : I64, I64, I64 -> Result I64 [DivByZero]
calculateExplicit = \a, b, c ->
divide(a, b)
|> Result.try(\x ->
divide(x, c)
)
Why this translation:
- Elixir
withbecomes Roc!operator !in Roc is try/propagate (not unwrap/crash like Elixir!)- Both patterns allow early return on error
- Roc's approach is more concise
- Type system ensures all errors are handled
Pattern: Multiple Error Types
Elixir:
defmodule Parser do
def parse_and_save(input) do
with {:ok, data} <- parse(input),
{:ok, validated} <- validate(data),
:ok <- save(validated) do
{:ok, validated}
else
{:error, :invalid_format} -> {:error, "Invalid format"}
{:error, :validation_failed, msg} -> {:error, "Validation: #{msg}"}
{:error, :save_failed} -> {:error, "Could not save"}
end
end
end
Roc:
parseAndSave : Str -> Result Data [InvalidFormat, ValidationFailed Str, SaveFailed]
parseAndSave = \input ->
data = parse!(input) # Returns Result Data [InvalidFormat]
validated = validate!(data) # Returns Result Data [ValidationFailed Str]
save!(validated) # Returns Result {} [SaveFailed]
Ok(validated)
# Error handling with descriptive messages
parseAndSaveWithMsg : Str -> Result Data Str
parseAndSaveWithMsg = \input ->
parseAndSave(input)
|> Result.mapErr(\err ->
when err is
InvalidFormat -> "Invalid format"
ValidationFailed(msg) -> "Validation: \(msg)"
SaveFailed -> "Could not save"
)
Why this translation:
- Elixir error atoms map to Roc tag variants
- Error payloads become tag parameters
!operator unifies error types automaticallyResult.mapErrconverts to user-friendly messages- Type signature documents all possible errors
Pattern: Raise and Rescue
Elixir:
def process_file(path) do
content = File.read!(path) # Raises on error
parse(content)
rescue
e in File.Error -> {:error, "File error: #{e.message}"}
e in ArgumentError -> {:error, "Invalid arguments"}
end
# Safer alternative
def process_file_safe(path) do
case File.read(path) do
{:ok, content} -> parse(content)
{:error, reason} -> {:error, "File error: #{inspect(reason)}"}
end
end
Roc:
# Roc has no exceptions - all errors are Result types
import pf.File
processFile : Str -> Result Data [FileErr Str, ParseErr]
processFile = \path ->
content = File.readUtf8!(path) # ! propagates error
parse!(content)
# Explicit error handling
processFileExplicit : Str -> Result Data Str
processFileExplicit = \path ->
when File.readUtf8(path) is
Ok(content) ->
when parse(content) is
Ok(data) -> Ok(data)
Err(ParseErr) -> Err("Parse error")
Err(err) -> Err("File error: \(File.errorToStr(err))")
Why this translation:
- No
raise/rescuein Roc - all errors are values - File I/O returns
Result, never throws - Bang functions in Roc propagate errors (not panic like Elixir)
- Error handling is always explicit
- Can't forget to handle errors (type system enforces)
Metaprogramming
Elixir Macros → Roc Alternatives
Roc has no macro system. Elixir's metaprogramming capabilities must be replaced with alternative patterns.
| Elixir Feature | Roc Alternative | Notes |
|---|---|---|
defmacro |
External codegen | Build-time code generation |
use Module |
Interface composition | Import and compose interfaces |
quote / unquote |
- | Not available |
| Protocols | Abilities | Auto-derivation, compile-time |
@derive |
Automatic | Most abilities auto-derived |
| Compile-time code gen | Build scripts | External tools generate Roc |
| AST manipulation | - | Not supported |
Pattern: Use Directive
Elixir:
defmodule MyGenServer do
use GenServer # Injects callbacks and helpers
@impl true
def init(state), do: {:ok, state}
@impl true
def handle_call(:get, _from, state) do
{:reply, state, state}
end
end
Roc:
# No macro injection - explicit structure
interface MyServer
exposes [State, init, handleGet]
imports []
State : I64
init : I64 -> State
init = \initialValue -> initialValue
handleGet : State -> (I64, State)
handleGet = \state ->
(state, state)
# Platform would provide GenServer-like behavior
# but without macro magic - explicit function signatures
Why this translation:
- No compile-time code injection in Roc
- All functions are explicit
- Behavior is defined by interface contract, not macros
- Platform provides runtime, application provides logic
- More verbose but clearer
Pattern: Protocol Derivation
Elixir:
defmodule User do
@derive {Jason.Encoder, only: [:name, :email]}
@derive Jason.Decoder
defstruct [:name, :email, :age]
end
# Automatic implementation
Jason.encode!(%User{name: "Alice", email: "alice@example.com", age: 30})
Roc:
# Abilities are auto-derived for most types
User : {
name : Str,
email : Str,
age : U32,
}
# Encode/Decode automatically available
user = { name: "Alice", email: "alice@example.com", age: 30 }
encoded = Encode.toBytes(user, Json.utf8)
# For custom encoding (rare):
encodeUser : User -> List U8
encodeUser = \{ name, email, age } ->
# Manual JSON construction if needed
jsonStr = """
{"name":"\(name)","email":"\(email)","age":\(Num.toStr(age))}
"""
Str.toUtf8(jsonStr)
Why this translation:
- Most abilities (Eq, Hash, Inspect, Encode, Decode) auto-derived
- No need for
@derive- happens automatically - Custom encoding requires explicit functions
- More restrictive but simpler
- No runtime overhead (compile-time derivation)
Pattern: Compile-Time Configuration
Elixir:
defmodule MyApp do
@api_url Application.compile_env(:my_app, :api_url, "https://default.com")
def fetch_data do
HTTPoison.get(@api_url <> "/data")
end
end
Roc:
# No compile-time config in language
# Use build scripts or environment-specific modules
# Option 1: Separate config modules (build-time switch)
# config/prod.roc
apiUrl : Str
apiUrl = "https://prod.com"
# config/dev.roc
apiUrl : Str
apiUrl = "https://dev.com"
# Option 2: Platform environment variables
import pf.Env
fetchData : Task Response [HttpErr, EnvErr]
fetchData =
apiUrl = Env.var!("API_URL") # Runtime config
Http.get("\(apiUrl)/data")
# Option 3: External codegen
# Build script generates config.roc from environment
Why this translation:
- No compile-time metaprogramming in Roc
- Config is either build-time (separate modules) or runtime (Env vars)
- External tools can generate Roc modules
- Simpler language, more predictable builds
- Clear separation of build vs runtime config
Pattern: Macros for DSLs
Elixir:
defmodule Router do
import MyWeb.Router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
end
scope "/", MyWeb do
pipe_through :browser
get "/", PageController, :index
get "/users/:id", UserController, :show
end
end
Roc:
# No DSL macros - use plain data structures
Route : {
method : [Get, Post, Put, Delete],
path : Str,
handler : Request -> Task Response [],
}
Pipeline : List (Request -> Task Request [])
browserPipeline : Pipeline
browserPipeline = [
acceptsHtml,
fetchSession,
]
routes : List Route
routes = [
{ method: Get, path: "/", handler: pageIndex },
{ method: Get, path: "/users/:id", handler: userShow },
]
# Platform provides routing engine that interprets data
# No macros needed - just data and functions
Why this translation:
- DSLs become data structures
- Macros become data transformation functions
- Platform interprets routing data
- More verbose but explicit
- Easier to reason about (no compile-time magic)
Module System Translation
Elixir Modules → Roc Interfaces
| Elixir | Roc | Notes |
|---|---|---|
defmodule |
interface |
Module declaration |
@moduledoc |
Doc comments ## |
Module documentation |
@doc |
Doc comments ## |
Function documentation |
def (public) |
In exposes list |
Public functions |
defp (private) |
Not in exposes |
Private functions |
import |
import |
Import modules |
alias |
- | Roc uses full names |
use |
- | No macro injection |
Pattern: Module Organization
Elixir:
# lib/my_app/accounts/user.ex
defmodule MyApp.Accounts.User do
@moduledoc """
User account management.
"""
defstruct [:name, :email, :age]
@doc "Create a new user"
def new(name, email) do
%__MODULE__{name: name, email: email, age: 0}
end
@doc "Update user age"
def update_age(user, age) do
%{user | age: age}
end
defp validate(user), do: # ...
end
# lib/my_app/accounts.ex
defmodule MyApp.Accounts do
alias MyApp.Accounts.User
def create_user(name, email) do
User.new(name, email)
end
end
Roc:
# MyApp/Accounts/User.roc
## User account management
interface User
exposes [User, new, updateAge]
imports []
User : {
name : Str,
email : Str,
age : U32,
}
## Create a new user
new : Str, Str -> User
new = \name, email ->
{ name, email, age: 0 }
## Update user age
updateAge : User, U32 -> User
updateAge = \user, age ->
{ user & age }
# Private function (not exposed)
validate : User -> Bool
validate = \user -> # ...
# MyApp/Accounts.roc
interface Accounts
exposes [createUser]
imports [User]
createUser : Str, Str -> User.User
createUser = \name, email ->
User.new(name, email)
Why this translation:
- File structure mirrors namespace
- Interfaces replace modules
exposesreplaces publicdef- Documentation uses
##comments - Imports are explicit (no
aliasshorthand) - Nested namespaces use directory structure
Pattern: Behaviours and Callbacks
Elixir:
defmodule MyBehaviour do
@callback handle(term()) :: {:ok, term()} | {:error, term()}
@callback init(term()) :: {:ok, term()}
end
defmodule MyImplementation do
@behaviour MyBehaviour
@impl true
def init(config), do: {:ok, config}
@impl true
def handle(data), do: {:ok, process(data)}
defp process(data), do: data
end
Roc:
# Behaviours become ability constraints or explicit interfaces
# Option 1: Ability (for polymorphism)
Handler implements
handle : a, Request -> Result Response Err where a implements Handler
init : Config -> Result a Err where a implements Handler
# Option 2: Interface contract (simpler, common case)
interface MyHandler
exposes [State, init, handle]
imports []
State : { config : Config }
init : Config -> Result State Err
init = \config -> Ok({ config })
handle : State, Request -> Result Response Err
handle = \state, request ->
Ok(process(request))
# Private
process : Request -> Response
process = \request -> # ...
Why this translation:
- Behaviours map to abilities (for polymorphism) or interfaces (simpler)
- No
@implannotations needed (type system checks) - Callbacks become function signatures in interface
- Type system ensures implementation matches contract
- More restrictive but type-safe
Testing Strategy
Elixir ExUnit → Roc Expect
| Elixir | Roc | Notes |
|---|---|---|
ExUnit.Case |
expect statements |
Inline tests |
test "..." |
expect ... |
No test names |
assert |
expect x == y |
Boolean expressions |
refute |
expect !(x == y) |
Negation |
setup |
- | No test lifecycle hooks |
describe |
Comments # |
Organizational comments |
| Doctest | expect in docs |
Inline in documentation |
assert_receive |
- | No message-based testing |
Pattern: Basic Testing
Elixir:
defmodule MathUtilsTest do
use ExUnit.Case
describe "add/2" do
test "adds two positive numbers" do
assert MathUtils.add(2, 3) == 5
end
test "adds negative numbers" do
assert MathUtils.add(-1, 1) == 0
end
end
describe "divide/2" do
test "divides successfully" do
assert MathUtils.divide(10, 2) == {:ok, 5.0}
end
test "returns error on division by zero" do
assert MathUtils.divide(10, 0) == {:error, :division_by_zero}
end
end
end
Roc:
interface MathUtils
exposes [add, divide]
imports []
add : I64, I64 -> I64
add = \a, b -> a + b
# Tests for add
expect add(2, 3) == 5
expect add(-1, 1) == 0
expect add(0, 0) == 0
divide : F64, F64 -> Result F64 [DivisionByZero]
divide = \a, b ->
if b == 0 then
Err(DivisionByZero)
else
Ok(a / b)
# Tests for divide
expect divide(10, 2) == Ok(5.0)
expect divide(10, 0) == Err(DivisionByZero)
expect
when divide(20, 4) is
Ok(result) -> result == 5.0
Err(_) -> Bool.false
Why this translation:
- No test framework setup needed
expectstatements run withroc test- No test names - expectations speak for themselves
- Inline with code (closer to doctest philosophy)
- Can use pattern matching in expects
Pattern: Doctests
Elixir:
defmodule Calculator do
@doc """
Adds two numbers.
## Examples
iex> Calculator.add(2, 3)
5
iex> Calculator.add(-1, 1)
0
"""
def add(a, b), do: a + b
end
Roc:
interface Calculator
exposes [add]
imports []
## Adds two numbers.
##
## Examples:
##
## ```
## expect add(2, 3) == 5
## expect add(-1, 1) == 0
## ```
add : I64, I64 -> I64
add = \a, b -> a + b
# Actual test expectations (run with roc test)
expect add(2, 3) == 5
expect add(-1, 1) == 0
Why this translation:
- Documentation includes example
expectstatements - Actual tests are separate
expectstatements - No special doctest syntax
- Examples in docs can be copy-pasted as tests
- Simpler mental model
Pattern: Setup and Teardown
Elixir:
defmodule DatabaseTest do
use ExUnit.Case
setup do
{:ok, conn} = Database.connect()
on_exit(fn -> Database.disconnect(conn) end)
{:ok, conn: conn}
end
test "inserts user", %{conn: conn} do
assert {:ok, user} = Database.insert(conn, %User{name: "Alice"})
assert user.name == "Alice"
end
end
Roc:
# No setup/teardown in Roc tests
# Tests are pure - create test data inline
interface Database
exposes [connect, disconnect, insert, User]
imports []
User : { name : Str }
# Tests create their own state
expect
conn = testConnection # Helper for test connections
user = insert(conn, { name: "Alice" })
when user is
Ok(u) -> u.name == "Alice"
Err(_) -> Bool.false
# Helper for test data
testConnection : Connection
testConnection = # Create test connection
Why this translation:
- No setup/teardown hooks
- Tests are pure functions
- Create test data inline or with helper functions
- No shared mutable state between tests
- Simpler but requires more explicit test data creation
Pattern: Property-Based Testing
Elixir:
defmodule StringPropertiesTest do
use ExUnit.Case
use ExUnitProperties
property "reversing twice returns original" do
check all string <- string(:printable) do
assert string |> String.reverse() |> String.reverse() == string
end
end
property "length is preserved when reversing" do
check all string <- string(:alphanumeric) do
assert String.length(string) == String.length(String.reverse(string))
end
end
end
Roc:
# Roc doesn't have built-in property-based testing yet
# Write tests for specific cases or use external tools
# Specific test cases
expect
str = "hello"
str == (str |> Str.reverse |> Str.reverse)
expect
str = "Roc Lang"
str == (str |> Str.reverse |> Str.reverse)
expect
str = ""
str == (str |> Str.reverse |> Str.reverse)
# Length preservation
expect
str = "testing"
Str.countUtf8Bytes(str) == Str.countUtf8Bytes(Str.reverse(str))
Why this translation:
- No built-in property-based testing library yet
- Write representative test cases manually
- Consider external codegen for test generation
- Simpler but less comprehensive
- May evolve as ecosystem matures
Build System and Dependencies
Mix → Roc Platform System
| Elixir (Mix) | Roc | Notes |
|---|---|---|
mix.exs |
app / package header |
Project definition |
deps |
Platform URL | Dependencies via platforms |
mix deps.get |
Automatic | Platforms fetched automatically |
mix compile |
roc build |
Build command |
mix test |
roc test |
Test runner |
mix format |
roc format |
Code formatter |
mix run |
roc run |
Run application |
Pattern: Project Configuration
Elixir:
# mix.exs
defmodule MyApp.MixProject do
use Mix.Project
def project do
[
app: :my_app,
version: "0.1.0",
elixir: "~> 1.14",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
def application do
[
extra_applications: [:logger],
mod: {MyApp.Application, []}
]
end
defp deps do
[
{:phoenix, "~> 1.7"},
{:jason, "~> 1.4"},
{:httpoison, "~> 2.0"}
]
end
end
Roc:
# MyApp.roc - Application header
app [main] {
pf: platform "https://github.com/roc-lang/basic-cli/releases/download/0.10.0/vNe6s9hWzoTZtFmNkvEICPErI9ptji_ySjicO6CkucY.tar.br"
}
import pf.Stdout
import pf.Task exposing [Task]
main : Task {} []
main =
Stdout.line!("Hello, World!")
# No separate config file
# Platform URL includes all dependencies
# Version is embedded in URL hash
Why this translation:
- No
mix.exsequivalent - project is defined in app header - Dependencies come through platform (not direct package deps)
- Version pinning via URL hash
- Simpler dependency model but less flexible
- Platform provides cohesive set of APIs
Pattern: Custom Mix Tasks
Elixir:
# lib/mix/tasks/generate_docs.ex
defmodule Mix.Tasks.GenerateDocs do
use Mix.Task
@shortdoc "Generate custom documentation"
def run(_args) do
Mix.shell().info("Generating documentation...")
# Custom logic
end
end
# Usage: mix generate_docs
Roc:
# No mix task equivalent
# Use external build scripts or just/make
# Justfile
generate-docs:
#!/usr/bin/env bash
echo "Generating documentation..."
roc run generate_docs.roc
# Or shell script: scripts/generate_docs.sh
#!/usr/bin/env bash
roc build docs_generator.roc
./docs_generator
Why this translation:
- No task system in Roc
- Use external build tools (just, make, shell scripts)
- Write Roc programs for custom tooling
- Platform provides basic commands only
- Simpler language, external orchestration
Common Pitfalls
1. Process-Based Thinking in Pure Code
Pitfall:
# ❌ Trying to replicate GenServer with state
# This doesn't work - Roc has no processes
counter = 0 # Can't have mutable module-level state
increment = \{} ->
counter = counter + 1 # Error: counter is not mutable
Solution:
# ✓ Use explicit state passing
State : I64
increment : State -> State
increment = \count -> count + 1
# State is passed explicitly, not hidden in process
# Platform provides state management if needed
2. Expecting Dynamic Types
Pitfall:
# ❌ Trying to use heterogeneous lists
users = [
{ name: "Alice", age: 30 },
"Invalid user", # Error: different type
42, # Error: different type
]
Solution:
# ✓ Use tag unions for variants
User : [Valid { name : Str, age : U32 }, Invalid Str, Unknown I64]
users : List User
users = [
Valid({ name: "Alice", age: 30 }),
Invalid("Invalid user"),
Unknown(42),
]
3. Message Passing Patterns
Pitfall:
# ❌ Trying to use message passing
# No send/receive in Roc
send(pid, message) # Error: no send function
Solution:
# ✓ Use function composition
# Messages become function parameters
handleMessage : Message -> State -> State
handleMessage = \msg, state ->
when msg is
Increment -> increment(state)
Decrement -> decrement(state)
Get -> state
4. Hot Code Loading Expectations
Pitfall: Expecting to reload modules without restart (Elixir/BEAM feature).
Solution:
- Roc requires full program restart
- Design for stateless or externalizable state
- Use platform-level state persistence if needed
- Accept different deployment model
5. Macro-Heavy Code
Pitfall:
# Elixir macro-heavy DSL
defmacro assert_valid(condition, message) do
quote do
unless unquote(condition) do
raise ArgumentError, unquote(message)
end
end
end
Solution:
# ✓ Use plain functions
assertValid : Bool, Str -> Result {} [ValidationErr Str]
assertValid = \condition, message ->
if condition then
Ok({})
else
Err(ValidationErr(message))
6. nil vs None Confusion
Pitfall:
# ❌ Expecting nil to work like Elixir
user = nil # Error: no nil value
Solution:
# ✓ Use tag unions for optional values
User : [Some { name : Str }, None]
user : User
user = None
# Or simpler:
MaybeUser : [Some { name : Str }, None]
7. String vs Atom Usage
Pitfall:
# ❌ Using strings where tags are better
status = "pending" # Strings don't get exhaustiveness checking
Solution:
# ✓ Use tags for enumerations
Status : [Pending, Approved, Rejected]
status : Status
status = Pending
# Compiler ensures all cases handled
when status is
Pending -> "waiting"
Approved -> "done"
Rejected -> "failed"
# Forgot a case? Compiler error!
Tooling
| Purpose | Elixir | Roc | Notes |
|---|---|---|---|
| Build | mix compile |
roc build |
Compile project |
| Run | mix run |
roc run |
Execute application |
| Test | mix test |
roc test |
Run tests |
| Format | mix format |
roc format |
Code formatting |
| REPL | iex |
roc repl |
Interactive shell |
| Docs | mix docs (ExDoc) |
roc docs |
Generate documentation |
| Dependency fetch | mix deps.get |
Automatic | Platform URLs fetched automatically |
| Type checking | Dialyzer | Built-in | Type checking |
| Hot reload | iex -S mix |
- | Not available in Roc |
| Code analysis | Credo | - | No equivalent yet |
Examples
Example 1: Simple - Basic List Processing
Before (Elixir):
defmodule ListUtils do
@doc "Sum all numbers in a list"
def sum(list), do: Enum.reduce(list, 0, &+/2)
@doc "Double all numbers in a list"
def double(list), do: Enum.map(list, &(&1 * 2))
@doc "Filter even numbers"
def evens(list), do: Enum.filter(list, &(rem(&1, 2) == 0))
end
# Tests
ExUnit.start()
defmodule ListUtilsTest do
use ExUnit.Case
test "sum/1" do
assert ListUtils.sum([1, 2, 3, 4, 5]) == 15
end
test "double/1" do
assert ListUtils.double([1, 2, 3]) == [2, 4, 6]
end
test "evens/1" do
assert ListUtils.evens([1, 2, 3, 4, 5]) == [2, 4]
end
end
After (Roc):
interface ListUtils
exposes [sum, double, evens]
imports []
## Sum all numbers in a list
sum : List I64 -> I64
sum = \list ->
List.walk(list, 0, Num.add)
## Double all numbers in a list
double : List I64 -> List I64
double = \list ->
List.map(list, \x -> x * 2)
## Filter even numbers
evens : List I64 -> List I64
evens = \list ->
List.keepIf(list, \x -> x % 2 == 0)
# Tests (inline)
expect sum([1, 2, 3, 4, 5]) == 15
expect double([1, 2, 3]) == [2, 4, 6]
expect evens([1, 2, 3, 4, 5]) == [2, 4]
Example 2: Medium - Result Type and Error Handling
Before (Elixir):
defmodule Calculator do
@doc "Safely divide two numbers"
def divide(a, b) when b != 0, do: {:ok, a / b}
def divide(_, 0), do: {:error, :division_by_zero}
@doc "Chain multiple divisions"
def chain_divide(a, b, c) do
with {:ok, x} <- divide(a, b),
{:ok, y} <- divide(x, c) do
{:ok, y}
else
{:error, reason} -> {:error, reason}
end
end
@doc "Safely parse and divide"
def parse_and_divide(a_str, b_str) do
with {:ok, a} <- parse_int(a_str),
{:ok, b} <- parse_int(b_str),
{:ok, result} <- divide(a, b) do
{:ok, result}
else
{:error, :invalid_integer} -> {:error, "Invalid number format"}
{:error, :division_by_zero} -> {:error, "Cannot divide by zero"}
end
end
defp parse_int(str) do
case Integer.parse(str) do
{int, ""} -> {:ok, int}
_ -> {:error, :invalid_integer}
end
end
end
# Tests
defmodule CalculatorTest do
use ExUnit.Case
test "divide/2 success" do
assert Calculator.divide(10, 2) == {:ok, 5.0}
end
test "divide/2 by zero" do
assert Calculator.divide(10, 0) == {:error, :division_by_zero}
end
test "chain_divide/3" do
assert Calculator.chain_divide(20, 2, 2) == {:ok, 5.0}
assert Calculator.chain_divide(10, 0, 2) == {:error, :division_by_zero}
end
test "parse_and_divide/2" do
assert Calculator.parse_and_divide("10", "2") == {:ok, 5.0}
assert Calculator.parse_and_divide("abc", "2") == {:error, "Invalid number format"}
assert Calculator.parse_and_divide("10", "0") == {:error, "Cannot divide by zero"}
end
end
After (Roc):
interface Calculator
exposes [divide, chainDivide, parseAndDivide]
imports []
## Safely divide two numbers
divide : F64, F64 -> Result F64 [DivisionByZero]
divide = \a, b ->
if b == 0 then
Err(DivisionByZero)
else
Ok(a / b)
## Chain multiple divisions
chainDivide : F64, F64, F64 -> Result F64 [DivisionByZero]
chainDivide = \a, b, c ->
x = divide!(a, b) # ! propagates error
y = divide!(x, c)
Ok(y)
## Safely parse and divide
parseAndDivide : Str, Str -> Result F64 [InvalidInteger, DivisionByZero]
parseAndDivide = \aStr, bStr ->
a = parseFloat!(aStr) # Returns Result F64 [InvalidInteger]
b = parseFloat!(bStr)
divide!(a, b)
# Helper for parsing
parseFloat : Str -> Result F64 [InvalidInteger]
parseFloat = \str ->
when Str.toF64(str) is
Ok(num) -> Ok(num)
Err(_) -> Err(InvalidInteger)
# Convert to user-friendly messages
parseAndDivideMsg : Str, Str -> Result F64 Str
parseAndDivideMsg = \aStr, bStr ->
parseAndDivide(aStr, bStr)
|> Result.mapErr(\err ->
when err is
InvalidInteger -> "Invalid number format"
DivisionByZero -> "Cannot divide by zero"
)
# Tests
expect divide(10, 2) == Ok(5.0)
expect divide(10, 0) == Err(DivisionByZero)
expect chainDivide(20, 2, 2) == Ok(5.0)
expect chainDivide(10, 0, 2) == Err(DivisionByZero)
expect
when parseAndDivideMsg("10", "2") is
Ok(result) -> result == 5.0
Err(_) -> Bool.false
expect parseAndDivideMsg("abc", "2") == Err("Invalid number format")
expect parseAndDivideMsg("10", "0") == Err("Cannot divide by zero")
Example 3: Complex - GenServer to Pure State Machine with Platform Tasks
Before (Elixir):
defmodule UserCache do
use GenServer
# Client API
def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
def get(user_id) do
GenServer.call(__MODULE__, {:get, user_id})
end
def put(user_id, user) do
GenServer.cast(__MODULE__, {:put, user_id, user})
end
def delete(user_id) do
GenServer.cast(__MODULE__, {:delete, user_id})
end
def all_users do
GenServer.call(__MODULE__, :all_users)
end
# Server Callbacks
@impl true
def init(_args) do
{:ok, %{}}
end
@impl true
def handle_call({:get, user_id}, _from, state) do
{:reply, Map.get(state, user_id), state}
end
def handle_call(:all_users, _from, state) do
{:reply, Map.values(state), state}
end
@impl true
def handle_cast({:put, user_id, user}, state) do
{:noreply, Map.put(state, user_id, user)}
end
def handle_cast({:delete, user_id}, state) do
{:noreply, Map.delete(state, user_id)}
end
end
# Supervision tree
defmodule MyApp.Application do
use Application
def start(_type, _args) do
children = [
UserCache
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
end
# Tests
defmodule UserCacheTest do
use ExUnit.Case
setup do
{:ok, _pid} = start_supervised(UserCache)
:ok
end
test "get and put user" do
user = %{name: "Alice", email: "alice@example.com"}
:ok = UserCache.put(1, user)
assert UserCache.get(1) == user
end
test "get non-existent user returns nil" do
assert UserCache.get(999) == nil
end
test "delete user" do
user = %{name: "Bob", email: "bob@example.com"}
:ok = UserCache.put(2, user)
assert UserCache.get(2) == user
:ok = UserCache.delete(2)
assert UserCache.get(2) == nil
end
test "all_users returns all cached users" do
user1 = %{name: "Alice", email: "alice@example.com"}
user2 = %{name: "Bob", email: "bob@example.com"}
:ok = UserCache.put(1, user1)
:ok = UserCache.put(2, user2)
users = UserCache.all_users()
assert length(users) == 2
assert user1 in users
assert user2 in users
end
end
After (Roc):
# Pure state machine - no GenServer
interface UserCache
exposes [
State,
User,
init,
get,
put,
delete,
allUsers,
]
imports []
User : {
name : Str,
email : Str,
}
State : Dict U64 User
## Initialize empty cache
init : State
init = Dict.empty({})
## Get user by ID
get : State, U64 -> [Some User, None]
get = \state, userId ->
Dict.get(state, userId)
|> Result.map(Some)
|> Result.withDefault(None)
## Put user in cache
put : State, U64, User -> State
put = \state, userId, user ->
Dict.insert(state, userId, user)
## Delete user from cache
delete : State, U64 -> State
delete = \state, userId ->
Dict.remove(state, userId)
## Get all users
allUsers : State -> List User
allUsers = \state ->
Dict.values(state)
# Tests
expect
state = init
user = { name: "Alice", email: "alice@example.com" }
newState = put(state, 1, user)
get(newState, 1) == Some(user)
expect
state = init
get(state, 999) == None
expect
state = init
user = { name: "Bob", email: "bob@example.com" }
stateWithUser = put(state, 2, user)
get(stateWithUser, 2) == Some(user)
stateAfterDelete = delete(stateWithUser, 2)
get(stateAfterDelete, 2) == None
expect
state = init
user1 = { name: "Alice", email: "alice@example.com" }
user2 = { name: "Bob", email: "bob@example.com" }
state
|> put(1, user1)
|> put(2, user2)
|> allUsers
|> List.len
|> \len -> len == 2
# Hypothetical platform integration (if state management needed)
# This would be provided by the platform, not shown here:
#
# import pf.Agent exposing [Agent]
#
# cacheAgent : Agent State
# cacheAgent = Agent.start(init)
#
# getUser : U64 -> Task [Some User, None] []
# getUser = \userId ->
# Agent.get(cacheAgent, \state -> get(state, userId))
#
# putUser : U64, User -> Task {} []
# putUser = \userId, user ->
# Agent.update(cacheAgent, \state -> put(state, userId, user))
#
# deleteUser : U64 -> Task {} []
# deleteUser = \userId ->
# Agent.update(cacheAgent, \state -> delete(state, userId))
#
# getAllUsers : Task (List User) []
# getAllUsers =
# Agent.get(cacheAgent, allUsers)
See Also
For more examples and patterns, see:
meta-convert-dev- Foundational patterns with cross-language examplesconvert-erlang-roc- Related BEAM → Roc conversion (similar paradigm shift)lang-elixir-dev- Elixir development patternslang-roc-dev- Roc development patterns
Cross-cutting pattern skills (for areas not fully covered by lang-*-dev):
patterns-concurrency-dev- Process model vs Task model comparisonpatterns-serialization-dev- JSON, validation across languagespatterns-metaprogramming-dev- Macros vs abilities comparison