| 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