Agent Skills: Elixir ↔ Roc Conversion

Bidirectional conversion between Elixir and Roc. Use when migrating projects between these languages in either direction. Extends meta-convert-dev with Elixir↔Roc specific patterns. 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.

UncategorizedID: arustydev/ai/convert-elixir-roc

Repository

aRustyDevLicense: AGPL-3.0
72

Install this agent skill to your local

pnpm dlx add-skill https://github.com/aRustyDev/agents/tree/HEAD/content/skills/convert-elixir-roc

Skill Files

Browse the full folder contents for convert-elixir-roc.

Download Skill

Loading file tree…

content/skills/convert-elixir-roc/SKILL.md

Skill Metadata

Name
convert-elixir-roc
Description
Bidirectional conversion between Elixir and Roc. Use when migrating projects between these languages in either direction. Extends meta-convert-dev with Elixir↔Roc specific patterns. 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.

Elixir ↔ Roc Conversion

Bidirectional conversion between Elixir and Roc. This skill extends meta-convert-dev with Elixir↔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

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

  1. Analyze OTP patterns before writing Roc - GenServers become pure state machines
  2. Identify process boundaries - these become platform Task interactions
  3. Map dynamic patterns to static types - use tag unions for variants
  4. Redesign supervision trees - Roc platforms handle failure differently
  5. Extract pure logic - separate computation from effects (Task, IO)
  6. Handle hot code loading - not a language feature in Roc
  7. 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 expect tests 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 (when vs 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 keepIf instead of reject with 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.reduce becomes List.walk (fold)
  • Enum.filter becomes List.keepIf
  • Enum.find returns Result, 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
  • call operations become synchronous functions that return new state
  • cast operations 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 with becomes 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 nil maps to None in tag union
  • Enum.find returns nil or value; Roc findFirst returns Result
  • Explicit pattern matching on option types
  • Type system prevents forgetting to handle None case
  • Must convert Result to option type with Some/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/keepIf operations
  • Nested comprehensions use joinMap (flatMap/concatMap)
  • Filters become keepIf
  • into: %{} becomes Dict.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 call vs cast distinction - 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.async maps to platform Task creation
  • Platform decides concurrency strategy
  • Task.sequence combines multiple Tasks
  • Timeout is platform-specific (may be in Task API)
  • No separate "await" - composition with ! or Task.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 with becomes 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 automatically
  • Result.mapErr converts 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/rescue in 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
  • exposes replaces public def
  • Documentation uses ## comments
  • Imports are explicit (no alias shorthand)
  • 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 @impl annotations 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
  • expect statements run with roc 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 expect statements
  • Actual tests are separate expect statements
  • 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.exs equivalent - 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 examples
  • convert-erlang-roc - Related BEAM → Roc conversion (similar paradigm shift)
  • lang-elixir-dev - Elixir development patterns
  • lang-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 comparison
  • patterns-serialization-dev - JSON, validation across languages
  • patterns-metaprogramming-dev - Macros vs abilities comparison