Claude-skill-registry convert-elm-elixir
Convert Elm code to idiomatic Elixir. Use when migrating Elm frontend applications to Elixir (Phoenix LiveView), translating Elm's functional patterns to Elixir, or refactoring Elm codebases to leverage OTP. Extends meta-convert-dev with Elm-to-Elixir specific patterns.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/convert-elm-elixir" ~/.claude/skills/majiayu000-claude-skill-registry-convert-elm-elixir && rm -rf "$T"
skills/data/convert-elm-elixir/SKILL.mdConvert Elm to Elixir
Convert Elm code to idiomatic Elixir. This skill extends
meta-convert-dev with Elm-to-Elixir specific type mappings, idiom translations, and tooling for migrating from frontend-focused functional programming to backend/full-stack development with OTP.
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: Elm types → Elixir types and structs
- Idiom translations: The Elm Architecture (TEA) → Phoenix LiveView / GenServer
- Error handling: Elm Maybe/Result → Elixir tagged tuples and pattern matching
- Concurrency: Elm Cmd/Sub → Elixir processes, GenServer, and Supervisor
- Architecture: Frontend TEA → Full-stack Phoenix with LiveView
This Skill Does NOT Cover
- General conversion methodology - see
meta-convert-dev - Elm language fundamentals - see
lang-elm-dev - Elixir language fundamentals - see
lang-elixir-dev - Reverse conversion (Elixir → Elm) - see
convert-elixir-elm - Phoenix-specific patterns beyond basics - see
lang-elixir-phoenix-dev
Quick Reference
| Elm | Elixir | Notes |
|---|---|---|
| / | UTF-8 binaries |
| | Arbitrary precision |
| | 64-bit double |
| | / |
| | Linked lists |
| / / | Context-dependent |
| / | Tagged tuples |
| | Tuples |
| / | Records → Structs |
(union) | with or enum pattern | Sum types |
| or async process | Side effects |
| callbacks / PubSub | Event subscriptions |
| GenServer state / LiveView assigns | Application state |
| LiveView | UI rendering |
When Converting Code
- Analyze source thoroughly - Understand TEA lifecycle and Cmd/Sub usage
- Map types first - Create type equivalence table for domain models
- Preserve semantics - TEA's guarantees (no runtime errors, managed effects) need OTP equivalents
- Adopt Elixir idioms - Don't write "Elm code in Elixir syntax"
- Consider architecture shift - Frontend-only TEA → Full-stack Phoenix LiveView or Backend API
- Test equivalence - Same business logic outcomes
Type System Mapping
Primitive Types
| Elm | Elixir | Notes |
|---|---|---|
| | Both UTF-8, Elixir uses binaries |
| | Elm uses 32-bit JS numbers, Elixir has arbitrary precision |
| | IEEE 754 double precision in both |
| | Direct mapping |
| (single char) | Elixir doesn't have char type; use single-char string |
(unit) | / | Context-dependent; often for success |
Collection Types
| Elm | Elixir | Notes |
|---|---|---|
| | Both are linked lists |
| | Elm's Array is optimized, Elixir uses lists or module |
| | Tuples map directly |
| | Tuples support any arity |
| | Elm Dict → Elixir Map |
| | Set implementations |
Composite Types
| Elm | Elixir | Notes |
|---|---|---|
| + | Record → Struct |
| or atoms | Union types → Atoms or tagged tuples |
| | Tagged tuple pattern |
| or | Elm Maybe → Elixir optionals |
| | Elm Result → Elixir result tuples |
The Elm Architecture → Phoenix LiveView / GenServer
| Elm TEA | Elixir (LiveView) | Notes |
|---|---|---|
| | State in LiveView socket |
| Event names (atoms) | Messages sent to LiveView |
| | Initialize state |
| | Handle user events |
| | Render UI |
| or async tasks | Side effects |
| | Event subscriptions |
| Elm TEA | Elixir (GenServer) | Notes |
|---|---|---|
| GenServer state | Application state |
| Messages to GenServer | Pattern-matched in callbacks |
| callback | Initialize GenServer |
| or | Handle messages |
| Async process / Task | Side effects |
| | Receive external messages |
Idiom Translation
Pattern 1: Maybe → Result Tuples
Elm:
type Maybe a = Just a | Nothing findUser : Int -> Maybe User findUser id = users |> List.filter (\u -> u.id == id) |> List.head case findUser 1 of Just user -> user.name Nothing -> "Anonymous"
Elixir:
# Using nil def find_user(id) do Enum.find(users(), fn u -> u.id == id end) end case find_user(1) do nil -> "Anonymous" user -> user.name end # Or using result tuples (more idiomatic for operations that can fail) def find_user(id) do case Enum.find(users(), fn u -> u.id == id end) do nil -> {:error, :not_found} user -> {:ok, user} end end case find_user(1) do {:ok, user} -> user.name {:error, :not_found} -> "Anonymous" end
Why this translation:
- Elm's
is explicit about presence/absenceMaybe - Elixir uses
for simple optionals ornil
/{:ok, value}
for operations{:error, reason} - Pattern matching works similarly in both languages
- Elixir's tagged tuples provide more context (error reasons)
Pattern 2: Result → Tagged Tuples
Elm:
type Result error value = Ok value | Err error parseAge : String -> Result String Int parseAge str = case String.toInt str of Just age -> if age >= 0 then Ok age else Err "Age must be non-negative" Nothing -> Err "Not a valid number" case parseAge "25" of Ok age -> "Age: " ++ String.fromInt age Err message -> "Error: " ++ message
Elixir:
@spec parse_age(String.t()) :: {:ok, integer()} | {:error, String.t()} def parse_age(str) do case Integer.parse(str) do {age, ""} when age >= 0 -> {:ok, age} {_age, ""} -> {:error, "Age must be non-negative"} _ -> {:error, "Not a valid number"} end end case parse_age("25") do {:ok, age} -> "Age: #{age}" {:error, message} -> "Error: #{message}" end
Why this translation:
- Direct mapping from Elm
to Elixir tagged tuplesResult - Pattern matching syntax is very similar
- Elixir's
statement can chain multiple results (similar to Elm'swith
)Result.andThen
Pattern 3: The Elm Architecture → Phoenix LiveView
Elm (Counter):
module Main exposing (main) import Browser import Html exposing (Html, button, div, text) import Html.Events exposing (onClick) -- MODEL type alias Model = { count : Int } init : () -> ( Model, Cmd Msg ) init _ = ( { count = 0 }, Cmd.none ) -- UPDATE type Msg = Increment | Decrement update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of Increment -> ( { model | count = model.count + 1 }, Cmd.none ) Decrement -> ( { model | count = model.count - 1 }, Cmd.none ) -- VIEW view : Model -> Html Msg view model = div [] [ button [ onClick Decrement ] [ text "-" ] , div [] [ text (String.fromInt model.count) ] , button [ onClick Increment ] [ text "+" ] ] main : Program () Model Msg main = Browser.element { init = init , update = update , view = view , subscriptions = \_ -> Sub.none }
Elixir (Phoenix LiveView):
defmodule MyAppWeb.CounterLive do use MyAppWeb, :live_view # MOUNT (like init) @impl true def mount(_params, _session, socket) do {:ok, assign(socket, count: 0)} end # HANDLE_EVENT (like update) @impl true def handle_event("increment", _params, socket) do {:noreply, update(socket, :count, &(&1 + 1))} end def handle_event("decrement", _params, socket) do {:noreply, update(socket, :count, &(&1 - 1))} end # RENDER (like view) @impl true def render(assigns) do ~H""" <div> <button phx-click="decrement">-</button> <div><%= @count %></div> <button phx-click="increment">+</button> </div> """ end end
Why this translation:
- TEA's Model → LiveView's
socket.assigns - TEA's Msg → LiveView event names (strings/atoms)
- TEA's
→ LiveView'supdatehandle_event/3 - TEA's
→ LiveView'sviewrender/1 - TEA's
→ LiveView async operations (viaCmd
orsend(self(), ...)
)Task.async - LiveView handles the runtime (like Elm Runtime), managing concurrency and state
Pattern 4: List Processing
Elm:
processData : List Int -> Int processData numbers = numbers |> List.filter (\x -> x > 0) |> List.map (\x -> x * 2) |> List.foldl (+) 0
Elixir:
def process_data(numbers) do numbers |> Enum.filter(&(&1 > 0)) |> Enum.map(&(&1 * 2)) |> Enum.sum() end # Or using reduce explicitly def process_data(numbers) do numbers |> Enum.filter(&(&1 > 0)) |> Enum.map(&(&1 * 2)) |> Enum.reduce(0, &+/2) end
Why this translation:
- Pipe operators work identically
module →List
moduleEnum- Lambda syntax differs: Elm
→ Elixir\x -> x * 2
or&(&1 * 2)fn x -> x * 2 end - Both are lazy in comprehensions, eager in pipes
Pattern 5: Union Types → Pattern Matching
Elm:
type Status = Loading | Success String | Failure String handleStatus : Status -> String handleStatus status = case status of Loading -> "Loading..." Success data -> "Data: " ++ data Failure error -> "Error: " ++ error
Elixir:
# Using atoms and tagged tuples @type status :: :loading | {:success, String.t()} | {:failure, String.t()} def handle_status(status) do case status do :loading -> "Loading..." {:success, data} -> "Data: #{data}" {:failure, error} -> "Error: #{error}" end end
Why this translation:
- Elm's union types → Elixir atoms (for zero-arg variants) and tagged tuples (for data-carrying variants)
- Pattern matching syntax is nearly identical
- Elixir's approach is more dynamic but equally powerful
Error Handling
Elm Error Model → Elixir Error Model
| Aspect | Elm | Elixir |
|---|---|---|
| Primary Model | / | Tagged tuples ( / ) |
| Error Propagation | | statement |
| Null Safety | No nulls; use | exists but tuples preferred |
| Exceptions | None (compile-time guarantee) | Exceptions exist but discouraged; use |
Chaining Operations with Errors
Elm:
validateAndSave : String -> Result String User validateAndSave input = input |> validateEmail |> Result.andThen createUser |> Result.andThen saveToDatabase case validateAndSave "alice@example.com" of Ok user -> "Saved: " ++ user.name Err message -> "Failed: " ++ message
Elixir:
def validate_and_save(input) do with {:ok, email} <- validate_email(input), {:ok, user} <- create_user(email), {:ok, saved_user} <- save_to_database(user) do {:ok, saved_user} else {:error, reason} -> {:error, reason} end end case validate_and_save("alice@example.com") do {:ok, user} -> "Saved: #{user.name}" {:error, message} -> "Failed: #{message}" end
Why this translation:
- Elm's
for chaining → Elixir'sResult.andThen
statementwith - Both short-circuit on first error
- Elixir's
is more flexible (can handle multiple patterns)with
Concurrency Patterns
Elm Cmd/Sub → Elixir Processes
Elm's concurrency is managed by the Elm Runtime - you describe side effects declaratively via
Cmd and Sub. Elixir requires explicit process management but provides OTP primitives.
Cmd (Commands) → Async Operations
Elm:
-- Cmd describes side effect; runtime executes it type Msg = GotUsers (Result Http.Error (List User)) getUsers : Cmd Msg getUsers = Http.get { url = "https://api.example.com/users" , expect = Http.expectJson GotUsers (Decode.list userDecoder) } update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of FetchUsers -> ( { model | loading = True }, getUsers ) GotUsers result -> case result of Ok users -> ( { model | users = users, loading = False }, Cmd.none ) Err _ -> ( { model | error = Just "Failed", loading = False }, Cmd.none )
Elixir (LiveView):
defmodule MyAppWeb.UsersLive do use MyAppWeb, :live_view def handle_event("fetch_users", _params, socket) do # Async task (like Cmd) send(self(), :perform_fetch) {:noreply, assign(socket, loading: true)} end def handle_info(:perform_fetch, socket) do case HTTPoison.get("https://api.example.com/users") do {:ok, %{body: body}} -> users = Jason.decode!(body) {:noreply, assign(socket, users: users, loading: false)} {:error, _reason} -> {:noreply, assign(socket, error: "Failed", loading: false)} end end end
Elixir (GenServer):
defmodule UserFetcher do use GenServer def handle_cast(:fetch_users, state) do # Spawn async task Task.async(fn -> HTTPoison.get("https://api.example.com/users") end) {:noreply, %{state | loading: true}} end def handle_info({_ref, {:ok, %{body: body}}}, state) do users = Jason.decode!(body) {:noreply, %{state | users: users, loading: false}} end def handle_info({_ref, {:error, _reason}}, state) do {:noreply, %{state | error: "Failed", loading: false}} end end
Why this translation:
- Elm
→ ElixirCmd
orsend(self(), msg)Task.async - Elm runtime guarantees serialized
calls → Elixir GenServer/LiveView handle one message at a timeupdate - Both avoid race conditions in user code
Sub (Subscriptions) → PubSub / handle_info
Elm:
subscriptions : Model -> Sub Msg subscriptions model = Time.every 1000 Tick -- Every second
Elixir (LiveView):
def mount(_params, _session, socket) do if connected?(socket) do # Subscribe to Phoenix PubSub Phoenix.PubSub.subscribe(MyApp.PubSub, "events") # Or schedule periodic message :timer.send_interval(1000, self(), :tick) end {:ok, socket} end def handle_info(:tick, socket) do # Handle periodic event {:noreply, update(socket, :count, &(&1 + 1))} end def handle_info(%{event: "user_updated", payload: user}, socket) do # Handle PubSub message {:noreply, assign(socket, current_user: user)} end
Why this translation:
- Elm
→ ElixirSub
orPhoenix.PubSub
or:timerhandle_info/2 - Both models ensure messages arrive one at a time in the update/handle loop
Common Pitfalls
-
Assuming Elm's "No Runtime Errors" in Elixir
- Elm guarantees no runtime errors via its type system
- Elixir has dynamic typing and exceptions
- Fix: Use typespecs (
), Dialyzer, and pattern match exhaustively@spec
-
Forgetting Immutability Differences
- Elm enforces immutability at language level
- Elixir has immutable data but processes have mutable state
- Fix: Never mutate GenServer state directly; always return new state
-
Misunderstanding Cmd/Sub → Process Translation
- Elm's Cmd/Sub are declarative; runtime manages everything
- Elixir requires explicit process spawning and message handling
- Fix: Use GenServer/LiveView patterns, don't try to replicate Elm Runtime exactly
-
Over-using Exceptions
- Elm has no exceptions
- Elixir has exceptions but idiomatic code uses
/{:ok, val}{:error, reason} - Fix: Use tagged tuples and pattern matching, reserve exceptions for truly exceptional cases
-
Ignoring OTP Supervision
- Elm runtime handles all failures invisibly
- Elixir requires explicit supervision trees
- Fix: Use Supervisors to restart crashed processes ("let it crash" philosophy)
-
Not Leveraging Elixir's Strengths
- Elm is frontend-only; Elixir is full-stack
- Fix: Use Phoenix for backend + LiveView for reactive frontend; don't just replicate Elm's architecture
-
Type Alias vs Struct Confusion
- Elm's
creates a record typetype alias - Elixir's
creates a module-specific structdefstruct - Fix: Use
for domain models,defstruct
for type annotations@type
- Elm's
Tooling
| Tool | Purpose | Notes |
|---|---|---|
| Phoenix | Web framework | Use for full-stack apps; LiveView for reactive UIs |
| Phoenix LiveView | Reactive UI | Closest equivalent to Elm's TEA |
| Ecto | Database ORM | No direct Elm equivalent (frontend-only) |
| ExUnit | Testing framework | Similar philosophy to elm-test |
| Dialyzer | Static analysis | Partial type checking (not as strong as Elm's) |
| Credo | Code linter | Enforce Elixir conventions |
| mix format | Code formatter | Like elm-format |
Examples
Example 1: Simple - Type Alias to Struct
Elm:
type alias User = { name : String , email : String , age : Int } createUser : String -> String -> Int -> User createUser name email age = { name = name, email = email, age = age }
Elixir:
defmodule User do @type t :: %__MODULE__{ name: String.t(), email: String.t(), age: integer() } defstruct [:name, :email, :age] @spec create(String.t(), String.t(), integer()) :: t() def create(name, email, age) do %User{name: name, email: email, age: age} end end
Example 2: Medium - Result Chaining
Elm:
type alias ValidationError = String validateUser : String -> String -> Int -> Result ValidationError User validateUser name email age = validateName name |> Result.andThen (\_ -> validateEmail email) |> Result.andThen (\_ -> validateAge age) |> Result.map (\_ -> { name = name, email = email, age = age }) validateName : String -> Result ValidationError String validateName name = if String.isEmpty name then Err "Name cannot be empty" else Ok name validateEmail : String -> Result ValidationError String validateEmail email = if String.contains email "@" then Ok email else Err "Invalid email" validateAge : Int -> Result ValidationError Int validateAge age = if age >= 18 then Ok age else Err "Must be at least 18"
Elixir:
defmodule UserValidator do @type validation_error :: String.t() @spec validate_user(String.t(), String.t(), integer()) :: {:ok, User.t()} | {:error, validation_error()} def validate_user(name, email, age) do with {:ok, _} <- validate_name(name), {:ok, _} <- validate_email(email), {:ok, _} <- validate_age(age) do {:ok, User.create(name, email, age)} end end defp validate_name(""), do: {:error, "Name cannot be empty"} defp validate_name(name), do: {:ok, name} defp validate_email(email) do if String.contains?(email, "@") do {:ok, email} else {:error, "Invalid email"} end end defp validate_age(age) when age >= 18, do: {:ok, age} defp validate_age(_), do: {:error, "Must be at least 18"} end
Example 3: Complex - TEA to LiveView Migration
Elm (Todo App):
module Main exposing (main) import Browser import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (..) -- MODEL type alias Model = { todos : List Todo , input : String } type alias Todo = { id : Int , text : String , completed : Bool } init : () -> ( Model, Cmd Msg ) init _ = ( { todos = [], input = "" }, Cmd.none ) -- UPDATE type Msg = UpdateInput String | AddTodo | ToggleTodo Int | RemoveTodo Int update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of UpdateInput input -> ( { model | input = input }, Cmd.none ) AddTodo -> if String.isEmpty model.input then ( model, Cmd.none ) else let newTodo = { id = List.length model.todos , text = model.input , completed = False } in ( { model | todos = model.todos ++ [ newTodo ] , input = "" } , Cmd.none ) ToggleTodo id -> let toggleTodo todo = if todo.id == id then { todo | completed = not todo.completed } else todo in ( { model | todos = List.map toggleTodo model.todos }, Cmd.none ) RemoveTodo id -> ( { model | todos = List.filter (\t -> t.id /= id) model.todos } , Cmd.none ) -- VIEW view : Model -> Html Msg view model = div [] [ input [ type_ "text" , placeholder "Add todo" , value model.input , onInput UpdateInput ] [] , button [ onClick AddTodo ] [ text "Add" ] , ul [] (List.map viewTodo model.todos) ] viewTodo : Todo -> Html Msg viewTodo todo = li [] [ input [ type_ "checkbox" , checked todo.completed , onClick (ToggleTodo todo.id) ] [] , span [ style "text-decoration" (if todo.completed then "line-through" else "none") ] [ text todo.text ] , button [ onClick (RemoveTodo todo.id) ] [ text "X" ] ]
Elixir (Phoenix LiveView Todo App):
defmodule MyAppWeb.TodoLive do use MyAppWeb, :live_view # Domain model defmodule Todo do @type t :: %__MODULE__{ id: integer(), text: String.t(), completed: boolean() } defstruct [:id, :text, :completed] end # MOUNT (init) @impl true def mount(_params, _session, socket) do {:ok, assign(socket, todos: [], input: "")} end # HANDLE_EVENT (update) @impl true def handle_event("update_input", %{"value" => input}, socket) do {:noreply, assign(socket, input: input)} end def handle_event("add_todo", _params, socket) do input = socket.assigns.input if String.trim(input) == "" do {:noreply, socket} else todos = socket.assigns.todos new_todo = %Todo{ id: length(todos), text: input, completed: false } {:noreply, assign(socket, todos: todos ++ [new_todo], input: "")} end end def handle_event("toggle_todo", %{"id" => id}, socket) do id = String.to_integer(id) todos = Enum.map(socket.assigns.todos, fn todo -> if todo.id == id do %{todo | completed: !todo.completed} else todo end end) {:noreply, assign(socket, todos: todos)} end def handle_event("remove_todo", %{"id" => id}, socket) do id = String.to_integer(id) todos = Enum.reject(socket.assigns.todos, &(&1.id == id)) {:noreply, assign(socket, todos: todos)} end # RENDER (view) @impl true def render(assigns) do ~H""" <div> <input type="text" placeholder="Add todo" value={@input} phx-keyup="update_input" /> <button phx-click="add_todo">Add</button> <ul> <%= for todo <- @todos do %> <li> <input type="checkbox" checked={todo.completed} phx-click="toggle_todo" phx-value-id={todo.id} /> <span style={"text-decoration: #{if todo.completed, do: "line-through", else: "none"}"}> <%= todo.text %> </span> <button phx-click="remove_todo" phx-value-id={todo.id}>X</button> </li> <% end %> </ul> </div> """ end end
Router configuration:
# In router.ex scope "/", MyAppWeb do pipe_through :browser live "/todos", TodoLive end
See Also
For more examples and patterns, see:
- Foundational patterns with cross-language examplesmeta-convert-dev
- Elm development patterns and TEAlang-elm-dev
- Elixir development patternslang-elixir-dev
- Advanced Phoenix and LiveView patternslang-elixir-phoenix-dev
- OTP patterns for concurrencylang-elixir-otp-dev
Cross-cutting pattern skills:
- Compare Elm Cmd/Sub to Elixir processes/GenServerpatterns-concurrency-dev
- JSON encoding/decoding across languagespatterns-serialization-dev
- Testing strategies for functional codepatterns-testing-dev
References
- Elm Guide - Elm fundamentals and TEA
- Phoenix LiveView Docs - LiveView guide
- Elixir School - Elixir tutorials
- Programming Phoenix LiveView - Book on LiveView patterns