Agents convert-elixir-elm
Bidirectional conversion between Elixir and Elm. Use when migrating projects between these languages in either direction. Extends meta-convert-dev with Elixir↔Elm specific patterns. Use when migrating server-side Elixir logic to frontend applications, translating BEAM concurrency patterns to The Elm Architecture, or refactoring Elixir codebases for browser-based UI. Extends meta-convert-dev with Elixir-to-Elm specific patterns.
git clone https://github.com/aRustyDev/agents
T=$(mktemp -d) && git clone --depth=1 https://github.com/aRustyDev/agents "$T" && mkdir -p ~/.claude/skills && cp -r "$T/content/skills/convert-elixir-elm" ~/.claude/skills/arustydev-agents-convert-elixir-elm && rm -rf "$T"
content/skills/convert-elixir-elm/SKILL.mdElixir ↔ Elm Conversion
Bidirectional conversion between Elixir and Elm. This skill extends
meta-convert-dev with Elixir↔Elm specific type mappings, idiom translations, and architectural guidance for translating server-side BEAM code to client-side functional UI.
This Skill Extends
- Foundational conversion patterns (APTV workflow, testing strategies)meta-convert-dev
For general concepts like the Analyze → Plan → Transform → Validate workflow, testing strategies, and common pitfalls, see the meta-skill first.
This Skill Adds
- Type mappings: Elixir dynamic types → Elm static types
- Idiom translations: Pattern matching, pipelines, and functional patterns
- Architecture translation: GenServer/OTP → The Elm Architecture (TEA)
- Concurrency translation: Processes/message passing → Cmd/Sub model
- Error handling:
/{:ok, _}
→ Result type{:error, _} - Effect management: Side effects anywhere → Managed Cmd/Sub
This Skill Does NOT Cover
- General conversion methodology - see
meta-convert-dev - Elixir language fundamentals - see
lang-elixir-dev - Elm language fundamentals - see
lang-elm-dev - Server-side Elixir deployment - focus on logic portable to frontend
- Phoenix-specific patterns - Phoenix LiveView is client-server hybrid
Quick Reference
| Elixir | Elm | Notes |
|---|---|---|
| | Direct mapping |
| | Elm Int is fixed-width |
| | Direct mapping |
| | / → / |
| | Direct mapping |
| | Tuples up to 3 elements in Elm |
/ | | Elm Dict requires comparable keys |
| | Result type |
| | Result type |
| | Use Maybe type |
| Pattern match | | Both support pattern matching |
pipe | pipe | Identical operator |
| | Similar API |
| GenServer state | TEA Model | State management pattern shift |
| Cmd/Sub | Effects are managed |
When Converting Code
- Identify pure business logic - Elm runs in browser, not on BEAM VM
- Map dynamic types to static - Add explicit type annotations
- Convert processes to TEA - GenServers become Model-View-Update
- Translate effects - Side effects become Cmd, subscriptions become Sub
- Preserve semantics - Both are functional, immutable languages
- Handle compilation errors first - Elm compiler guides you to correctness
- Test with property-based tests - Both languages support them well
Type System Mapping
Primitive Types
| Elixir | Elm | Notes |
|---|---|---|
| | Elm has fixed-width integers (no BigInt) |
| | Direct mapping |
| | → , → |
| | Both are UTF-8 strings |
| Union types | , become custom type variants |
| | Use type |
| - | No direct binary type; use String or custom encoding |
| | Convert charlists to strings |
Collection Types
| Elixir | Elm | Notes |
|---|---|---|
| | Direct mapping |
| | Tuples identical, max 3 in Elm |
| | Max tuple size in Elm |
/ | | Elm requires comparable keys |
| | Elm requires comparable elements |
| | Convert keyword lists to list of tuples |
| | Use |
Composite Types
| Elixir Pattern | Elm Pattern | Notes |
|---|---|---|
| | Maps → Records |
| | Tagged tuples → Result type |
| | Nil → Nothing, value → Just value |
| | Atoms → Union type variants |
Struct () | | Structs → Type aliases |
| Protocol implementation | - | No protocols in Elm, use functions on types |
Function Types
| Elixir | Elm | Notes |
|---|---|---|
| | Function type identical syntax |
| | Elm auto-curries, no arity-2 syntax |
| | No-argument functions |
| Anonymous fn | Lambda | Both support anonymous functions |
Idiom Translation
Pattern 1: Tagged Tuples → Result
Elixir:
def divide(a, b) when b != 0, do: {:ok, a / b} def divide(_, 0), do: {:error, :division_by_zero} case divide(10, 2) do {:ok, result} -> IO.puts("Result: #{result}") {:error, reason} -> IO.puts("Error: #{reason}") end
Elm:
divide : Float -> Float -> Result String Float divide a b = if b /= 0 then Ok (a / b) else Err "division_by_zero" -- Usage case divide 10 2 of Ok result -> "Result: " ++ String.fromFloat result Err reason -> "Error: " ++ reason
Why this translation:
- Elixir's
/{:ok, value}
pattern maps directly to Elm's{:error, reason}Result error value - Guards in Elixir (
) become if-expressions in Elmwhen b != 0 - Atoms like
become strings or custom types:division_by_zero - Both use pattern matching in case expressions
Pattern 2: Nil Handling → Maybe
Elixir:
def find_user(id) do users = %{1 => %{name: "Alice"}, 2 => %{name: "Bob"}} Map.get(users, id) end def display_name(user) do case user do nil -> "Anonymous" %{name: name} -> name end end # Pipeline with defaults name = find_user(1) |> display_name()
Elm:
findUser : Int -> Maybe User findUser id = let users = Dict.fromList [ ( 1, { name = "Alice" } ) , ( 2, { name = "Bob" } ) ] in Dict.get id users displayName : Maybe User -> String displayName maybeUser = case maybeUser of Nothing -> "Anonymous" Just user -> user.name -- Pipeline with Maybe.map name : String name = findUser 1 |> Maybe.map .name |> Maybe.withDefault "Anonymous"
Why this translation:
- Elixir's
becomes Elm'snilNothing - Present values become
Just value
replaces nil coalescingMaybe.withDefault- Pattern matching translates directly
Pattern 3: Enum Operations → List Functions
Elixir:
[1, 2, 3, 4, 5] |> Enum.filter(&(rem(&1, 2) == 0)) |> Enum.map(&(&1 * 2)) |> Enum.reduce(0, &(&1 + &2))
Elm:
[ 1, 2, 3, 4, 5 ] |> List.filter (\x -> modBy 2 x == 0) |> List.map (\x -> x * 2) |> List.foldl (+) 0
Why this translation:
→Enum.filterList.filter
→Enum.mapList.map
→Enum.reduce
(orList.foldl
)List.foldr- Pipeline operator
is identical|> - Capture operator
becomes lambda&()\x ->
becomesrem
in ElmmodBy
Pattern 4: Pattern Matching Lists
Elixir:
def sum([]), do: 0 def sum([head | tail]), do: head + sum(tail) def first([head | _]), do: {:ok, head} def first([]), do: {:error, :empty_list}
Elm:
sum : List Int -> Int sum list = case list of [] -> 0 head :: tail -> head + sum tail first : List a -> Result String a first list = case list of head :: _ -> Ok head [] -> Err "empty_list"
Why this translation:
- List pattern matching syntax is nearly identical
in Elixir →[head | tail]
in Elmhead :: tail- Multiple function clauses become case branches
- Guards can become if-expressions inside branches
Pattern 5: With Statement → Nested Case or Result.andThen
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
Elm:
-- Option 1: Nested case createUser : Params -> Result Error User createUser params = case validateParams params of Ok validated -> case insertUser validated of Ok user -> case sendWelcomeEmail user of Ok _ -> Ok user Err reason -> Err reason Err reason -> Err reason Err reason -> Err reason -- Option 2: Result.andThen (preferred) createUser : Params -> Result Error User createUser params = validateParams params |> Result.andThen insertUser |> Result.andThen (\user -> sendWelcomeEmail user |> Result.map (\_ -> user) )
Why this translation:
- Elixir's
chainswith
results{:ok, _} - Elm uses
for equivalent chainingResult.andThen - Early error returns are handled by Result's short-circuiting
- Pipeline style preserves readability
Architecture Translation: GenServer → The Elm Architecture
GenServer State Management
Elixir (GenServer):
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.call(__MODULE__, :increment) end def get do GenServer.call(__MODULE__, :get) end # Server Callbacks @impl true def init(initial_value), do: {:ok, initial_value} @impl true def handle_call(:increment, _from, state) do {:reply, state + 1, state + 1} end def handle_call(:get, _from, state) do {:reply, state, state} end end
Elm (TEA Model):
-- MODEL type alias Model = { count : Int } init : () -> ( Model, Cmd Msg ) init _ = ( { count = 0 }, Cmd.none ) -- UPDATE type Msg = Increment | Get update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of Increment -> ( { model | count = model.count + 1 }, Cmd.none ) Get -> -- In Elm, view always has access to model -- No need for explicit "get" message ( model, Cmd.none ) -- VIEW view : Model -> Html Msg view model = div [] [ button [ onClick Increment ] [ text "+" ] , div [] [ text (String.fromInt model.count) ] ]
Why this translation:
- GenServer state → Model
- Client API calls → Msg variants
→handle_call
function branchesupdate- Synchronous replies → View reads model directly
- No process needed; Elm runtime manages state
Process Communication → Cmd/Sub
Elixir (Process Messages):
# Sending process pid = spawn(fn -> receive do {:fetch_user, caller, user_id} -> user = fetch_user_from_db(user_id) send(caller, {:user_fetched, user}) end end) send(pid, {:fetch_user, self(), 123}) receive do {:user_fetched, user} -> IO.inspect(user) after 5000 -> IO.puts("Timeout") end
Elm (Commands and Subscriptions):
-- Commands represent effects to perform type Msg = FetchUser Int | GotUser (Result Http.Error User) update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of FetchUser userId -> ( { model | loading = True } , Http.get { url = "/api/users/" ++ String.fromInt userId , expect = Http.expectJson GotUser userDecoder } ) GotUser result -> case result of Ok user -> ( { model | user = Just user, loading = False } , Cmd.none ) Err _ -> ( { model | loading = False } , Cmd.none ) -- Subscriptions for incoming events subscriptions : Model -> Sub Msg subscriptions model = -- Listen to time every second Time.every 1000 Tick
Why this translation:
→ Create Cmd in updatesend
→ Handle Msg in updatereceive- Process spawning → Cmd.batch for multiple effects
- Message passing → Msg type with variants
- Timeouts → Handled by Sub/Cmd cancellation
Supervision Trees → Application Structure
Elixir (Supervisor):
defmodule MyApp.Application do use Application def start(_type, _args) do children = [ {Counter, 0}, {UserCache, []}, {DatabasePool, pool_size: 10} ] opts = [strategy: :one_for_one, name: MyApp.Supervisor] Supervisor.start_link(children, opts) end end
Elm (Module Organization):
-- No supervision needed - no crashes! -- Instead: Organize code into modules -- Main.elm module Main exposing (main) import Browser import Counter import UserCache type alias Model = { counter : Counter.Model , userCache : UserCache.Model } type Msg = CounterMsg Counter.Msg | UserCacheMsg UserCache.Msg init : () -> ( Model, Cmd Msg ) init _ = let ( counterModel, counterCmd ) = Counter.init () ( cacheModel, cacheCmd ) = UserCache.init () in ( { counter = counterModel , userCache = cacheModel } , Cmd.batch [ Cmd.map CounterMsg counterCmd , Cmd.map UserCacheMsg cacheCmd ] ) update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of CounterMsg subMsg -> let ( counterModel, counterCmd ) = Counter.update subMsg model.counter in ( { model | counter = counterModel } , Cmd.map CounterMsg counterCmd ) UserCacheMsg subMsg -> let ( cacheModel, cacheCmd ) = UserCache.update subMsg model.userCache in ( { model | userCache = cacheModel } , Cmd.map UserCacheMsg cacheCmd )
Why this translation:
- No supervision needed - Elm has no runtime exceptions
- Module composition replaces supervision trees
- Each "child" is a module with its own Model/Msg/update
- Parent routes messages to appropriate child module
- Cmd.map translates between parent and child messages
Concurrency Model Translation
Elixir: Process-Based Concurrency
# Multiple concurrent processes tasks = Enum.map(user_ids, fn id -> Task.async(fn -> fetch_user(id) end) end) users = Task.await_many(tasks, 5000)
Elm: Command-Based Concurrency
-- Multiple HTTP requests (runtime handles concurrency) type Msg = FetchUsers | GotUser Int (Result Http.Error User) fetchUsers : List Int -> Cmd Msg fetchUsers userIds = userIds |> List.map (\id -> Http.get { url = "/api/users/" ++ String.fromInt id , expect = Http.expectJson (GotUser id) userDecoder } ) |> Cmd.batch update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of FetchUsers -> ( { model | loading = True } , fetchUsers [ 1, 2, 3, 4, 5 ] ) GotUser id result -> -- Each response handled as it arrives case result of Ok user -> ( { model | users = Dict.insert id user model.users } , Cmd.none ) Err _ -> ( model, Cmd.none )
Why this translation:
- Elixir's
→ Elm'sTask.asyncCmd.batch - Elixir manages processes explicitly → Elm runtime handles concurrency
- Both allow multiple operations in flight
- Elm guarantees serialized message handling (no race conditions)
GenServer Periodic Work → Time Subscriptions
Elixir:
defmodule PeriodicWorker do use GenServer def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__) def init(state) do schedule_work() {:ok, state} end def handle_info(:work, state) do do_work() schedule_work() {:noreply, state} end defp schedule_work do Process.send_after(self(), :work, 5000) end end
Elm:
import Time type Msg = DoWork Time.Posix subscriptions : Model -> Sub Msg subscriptions model = Time.every 5000 DoWork -- 5000 milliseconds update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of DoWork time -> ( model, performWork time ) performWork : Time.Posix -> Cmd Msg performWork time = -- Perform work here Cmd.none
Why this translation:
→Process.send_after
subscriptionTime.every
→handle_info
with time-based Msgupdate- No manual rescheduling needed; Sub is continuous
- Elm runtime manages subscription lifecycle
Error Handling
Elixir Error Patterns
# Pattern 1: Tagged tuples {:ok, value} | {:error, reason} # Pattern 2: Raise/rescue try do dangerous_operation() rescue e in RuntimeError -> {:error, e.message} end # Pattern 3: Pattern match or default user = find_user(id) || %User{name: "Anonymous"} # Pattern 4: With statement with {:ok, a} <- step1(), {:ok, b} <- step2(a) do {:ok, b} else {:error, reason} -> {:error, reason} end
Elm Error Patterns
-- Pattern 1: Result type Ok value | Err reason -- Pattern 2: No exceptions! -- All errors are values dangerousOperation : () -> Result String Value dangerousOperation () = -- Cannot throw, must return Result -- Pattern 3: Maybe with default user : User user = findUser id |> Maybe.withDefault { name = "Anonymous" } -- Pattern 4: Result.andThen processData : Input -> Result String Output processData input = step1 input |> Result.andThen step2 |> Result.andThen step3
Key Differences:
- Elm has NO exceptions - all errors are Result or Maybe values
- No try/rescue needed - compiler enforces error handling
statement →with
chainingResult.andThen- Pattern matching on errors is identical
Testing Strategy
Property-Based Testing
Elixir (StreamData):
defmodule MathTest do use ExUnit.Case use ExUnitProperties property "addition is commutative" do check all x <- integer(), y <- integer() do assert Math.add(x, y) == Math.add(y, x) end end property "list reverse is idempotent" do check all list <- list_of(integer()) do assert list |> Enum.reverse() |> Enum.reverse() == list end end end
Elm (elm-test with fuzz):
module MathTest exposing (..) import Expect import Fuzz exposing (int, list) import Test exposing (Test, describe, fuzz, fuzz2) suite : Test suite = describe "Math properties" [ fuzz2 int int "addition is commutative" <| \x y -> Math.add x y |> Expect.equal (Math.add y x) , fuzz (list int) "list reverse is idempotent" <| \list -> list |> List.reverse |> List.reverse |> Expect.equal list ]
Why this translation:
- Both support property-based testing
→check all
/fuzzfuzz2- Generators translate directly
- Same test philosophy
Unit Testing
Elixir:
test "parses valid age" do assert {:ok, 25} = parse_age("25") end test "rejects negative age" do assert {:error, _} = parse_age("-5") end
Elm:
test "parses valid age" <| \_ -> parseAge "25" |> Expect.equal (Ok 25) test "rejects negative age" <| \_ -> parseAge "-5" |> Expect.err
Common Pitfalls
1. Expecting Runtime Dynamism
# Elixir: Dynamic typing allows this defmodule Flexible do def process(value) when is_integer(value), do: value * 2 def process(value) when is_binary(value), do: String.upcase(value) end process(5) # 10 process("hi") # "HI"
-- Elm: Must use union types for different types type Value = IntValue Int | StringValue String process : Value -> String process value = case value of IntValue int -> String.fromInt (int * 2) StringValue str -> String.toUpper str -- Usage process (IntValue 5) -- "10" process (StringValue "hi") -- "HI"
Fix: Define explicit union types for polymorphic values.
2. Side Effects Anywhere vs. Managed Effects
# Elixir: Can perform IO anywhere def get_user(id) do IO.puts("Fetching user #{id}") # Side effect! Database.get(:users, id) # Side effect! end
-- Elm: All effects through Cmd getUser : Int -> Cmd Msg getUser id = -- Cannot perform side effects directly -- Must return Cmd for Elm runtime Http.get { url = "/api/users/" ++ String.fromInt id , expect = Http.expectJson GotUser userDecoder } -- "Logging" must also be a command (via port) port logMessage : String -> Cmd msg getUserWithLog : Int -> Cmd Msg getUserWithLog id = Cmd.batch [ logMessage ("Fetching user " ++ String.fromInt id) , getUser id ]
Fix: Plan where effects belong in your Elm architecture.
3. Assuming Atoms Translate to Strings
# Elixir: Atoms are efficient, unique :ok :error :atom_name
-- Elm: Create custom types instead type Status = Ok | Error | AtomName -- NOT strings! -- type Status = String -- WRONG - loses type safety
Fix: Use union types for atom-like values, not strings.
4. GenServer State vs. TEA Model
# Elixir: State hidden in process GenServer.call(MyServer, :get_state)
-- Elm: State is always in Model, always visible to view view : Model -> Html Msg view model = -- Direct access to all state div [] [ text model.name ]
Fix: In Elm, embrace that all state is in Model and visible.
5. Expecting to "Let It Crash"
# Elixir: Supervision restarts crashed processes def risky_operation(value) do # If this crashes, supervisor restarts the process dangerous_thing(value) end
-- Elm: NO CRASHES - compiler guarantees no runtime exceptions riskyOperation : Value -> Result Error Output riskyOperation value = -- Must handle all error cases explicitly case dangerousThing value of Ok result -> Ok result Err error -> Err error
Fix: Handle all error cases explicitly with Result type.
Tooling
| Elixir Tool | Elm Equivalent | Notes |
|---|---|---|
| | Auto-formatting |
| | Unit and property testing |
| | Linting and code quality |
| Elm compiler | Type checking (Elm is stricter) |
| | Interactive REPL |
| | Dependency management |
| Browser DevTools | Runtime inspection |
| | Documentation generation |
Example: Complete Conversion
Elixir: User Management Module
defmodule UserManager do use GenServer # Client API def start_link(_) do GenServer.start_link(__MODULE__, %{}, name: __MODULE__) end def add_user(user) do GenServer.call(__MODULE__, {:add, user}) end def get_user(id) do GenServer.call(__MODULE__, {:get, id}) end def list_users do GenServer.call(__MODULE__, :list) end # Server Callbacks @impl true def init(_) do {:ok, %{}} end @impl true def handle_call({:add, user}, _from, state) do new_state = Map.put(state, user.id, user) {:reply, {:ok, user}, new_state} end def handle_call({:get, id}, _from, state) do result = Map.get(state, id) {:reply, result, state} end def handle_call(:list, _from, state) do users = Map.values(state) {:reply, users, state} end end # Usage {:ok, _} = UserManager.start_link([]) UserManager.add_user(%{id: 1, name: "Alice"}) UserManager.list_users()
Elm: User Management in TEA
module Main exposing (main) import Browser import Dict exposing (Dict) import Html exposing (Html, button, div, input, text) import Html.Attributes exposing (placeholder, value) import Html.Events exposing (onClick, onInput) -- MODEL type alias User = { id : Int , name : String } type alias Model = { users : Dict Int User , nextId : Int , nameInput : String } init : () -> ( Model, Cmd Msg ) init _ = ( { users = Dict.empty , nextId = 1 , nameInput = "" } , Cmd.none ) -- UPDATE type Msg = AddUser | SetNameInput String | RemoveUser Int update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of AddUser -> if String.isEmpty model.nameInput then ( model, Cmd.none ) else let user = { id = model.nextId , name = model.nameInput } newUsers = Dict.insert model.nextId user model.users in ( { model | users = newUsers | nextId = model.nextId + 1 | nameInput = "" } , Cmd.none ) SetNameInput name -> ( { model | nameInput = name }, Cmd.none ) RemoveUser id -> ( { model | users = Dict.remove id model.users } , Cmd.none ) -- VIEW view : Model -> Html Msg view model = div [] [ div [] [ input [ placeholder "Enter name" , value model.nameInput , onInput SetNameInput ] [] , button [ onClick AddUser ] [ text "Add User" ] ] , div [] (viewUsers model.users) ] viewUsers : Dict Int User -> List (Html Msg) viewUsers users = users |> Dict.values |> List.map viewUser viewUser : User -> Html Msg viewUser user = div [] [ text user.name , button [ onClick (RemoveUser user.id) ] [ text "Remove" ] ] -- MAIN main : Program () Model Msg main = Browser.element { init = init , update = update , view = view , subscriptions = \_ -> Sub.none }
Key Translation Points:
- GenServer state → Model record
→handle_call
function branchesupdate- Client API → Msg variants
- Synchronous calls → Direct model access in view
- Map-based storage → Dict in Elm
See Also
For more examples and patterns, see:
- Foundational patterns with cross-language examplesmeta-convert-dev
- Related conversion from Elmconvert-elm-scala
- Similar pure functional → frontend conversionconvert-haskell-elm
- Elixir development patternslang-elixir-dev
- Elm development patternslang-elm-dev
Cross-cutting pattern skills:
- Processes, async, and message passing across languagespatterns-concurrency-dev
- JSON handling and data encoding patternspatterns-serialization-dev
- Compare dynamic features to type-driven designpatterns-metaprogramming-dev