Convert 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
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: 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 |
|-----|--------|-------|
| String | String.t() / binary() | UTF-8 binaries |
| Int | integer() | Arbitrary precision |
| Float | float() | 64-bit double |
| Bool | boolean() | true / false |
| List a | list(a) | Linked lists |
| Maybe a | nil / {:ok, a} / {:error, reason} | Context-dependent |
| Result err ok | {:ok, ok} / {:error, err} | Tagged tuples |
| ( a, b ) | {a, b} | Tuples |
| type alias | defstruct / @type | Records → Structs |
| type (union) | @type with \| or defmodule enum pattern | Sum types |
| Cmd msg | GenServer.cast/2 or async process | Side effects |
| Sub msg | GenServer callbacks / PubSub | Event subscriptions |
| Model | GenServer state / LiveView assigns | Application state |
| view | LiveView render/1 | 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 |
|-----|--------|-------|
| String | String.t() | Both UTF-8, Elixir uses binaries |
| Int | integer() | Elm uses 32-bit JS numbers, Elixir has arbitrary precision |
| Float | float() | IEEE 754 double precision in both |
| Bool | boolean() | Direct mapping |
| Char | String.t() (single char) | Elixir doesn't have char type; use single-char string |
| () (unit) | :ok / nil | Context-dependent; often :ok for success |
Collection Types
| Elm | Elixir | Notes |
|-----|--------|-------|
| List a | list(a) | Both are linked lists |
| Array a | list(a) | Elm's Array is optimized, Elixir uses lists or :array module |
| ( a, b ) | {a, b} | Tuples map directly |
| ( a, b, c ) | {a, b, c} | Tuples support any arity |
| Dict k v | Map.t(k, v) | Elm Dict → Elixir Map |
| Set a | MapSet.t(a) | Set implementations |
Composite Types
| Elm | Elixir | Notes |
|-----|--------|-------|
| type alias User = { name : String, ... } | defstruct [:name, ...] + @type | Record → Struct |
| type Msg = Inc \| Dec | @type msg :: :inc \| :dec or atoms | Union types → Atoms or tagged tuples |
| type Msg = SetName String | {:set_name, String.t()} | Tagged tuple pattern |
| Maybe a | nil \| a or {:ok, a} \| {:error, term()} | Elm Maybe → Elixir optionals |
| Result err ok | {:ok, ok} \| {:error, err} | Elm Result → Elixir result tuples |
The Elm Architecture → Phoenix LiveView / GenServer
| Elm TEA | Elixir (LiveView) | Notes |
|---------|-------------------|-------|
| Model | socket.assigns | State in LiveView socket |
| Msg | Event names (atoms) | Messages sent to LiveView |
| init : flags -> ( Model, Cmd Msg ) | mount/3 | Initialize state |
| update : Msg -> Model -> ( Model, Cmd Msg ) | handle_event/3 | Handle user events |
| view : Model -> Html Msg | render/1 | Render UI |
| Cmd Msg | send(self(), msg) or async tasks | Side effects |
| Sub Msg | PubSub.subscribe/2 | Event subscriptions |
| Elm TEA | Elixir (GenServer) | Notes |
|---------|---------------------|-------|
| Model | GenServer state | Application state |
| Msg | Messages to GenServer | Pattern-matched in callbacks |
| init | init/1 callback | Initialize GenServer |
| update | handle_call/3 or handle_cast/2 | Handle messages |
| Cmd | Async process / Task | Side effects |
| Sub | handle_info/2 | 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
Maybeis explicit about presence/absence - Elixir uses
nilfor simple optionals or{:ok, value}/{:error, reason}for operations - 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
Resultto Elixir tagged tuples - Pattern matching syntax is very similar
- Elixir's
withstatement can chain multiple results (similar to Elm'sResult.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
update→ LiveView'shandle_event/3 - TEA's
view→ LiveView'srender/1 - TEA's
Cmd→ LiveView async operations (viasend(self(), ...)orTask.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
Listmodule →Enummodule- Lambda syntax differs: Elm
\x -> x * 2→ Elixir&(&1 * 2)orfn 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 | Maybe / Result | Tagged tuples ({:ok, val} / {:error, reason}) |
| Error Propagation | Result.andThen | with statement |
| Null Safety | No nulls; use Maybe | nil exists but tuples preferred |
| Exceptions | None (compile-time guarantee) | Exceptions exist but discouraged; use {:error, reason} |
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
Result.andThenfor chaining → Elixir'swithstatement - Both short-circuit on first error
- Elixir's
withis more flexible (can handle multiple patterns)
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
Cmd→ Elixirsend(self(), msg)orTask.async - Elm runtime guarantees serialized
updatecalls → Elixir GenServer/LiveView handle one message at a time - 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
Sub→ ElixirPhoenix.PubSubor:timerorhandle_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 (
@spec), Dialyzer, and pattern match exhaustively
-
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
type aliascreates a record type - Elixir's
defstructcreates a module-specific struct - Fix: Use
defstructfor domain models,@typefor type annotations
- 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:
meta-convert-dev- Foundational patterns with cross-language exampleslang-elm-dev- Elm development patterns and TEAlang-elixir-dev- Elixir development patternslang-elixir-phoenix-dev- Advanced Phoenix and LiveView patternslang-elixir-otp-dev- OTP patterns for concurrency
Cross-cutting pattern skills:
patterns-concurrency-dev- Compare Elm Cmd/Sub to Elixir processes/GenServerpatterns-serialization-dev- JSON encoding/decoding across languagespatterns-testing-dev- Testing strategies for functional code
References
- Elm Guide - Elm fundamentals and TEA
- Phoenix LiveView Docs - LiveView guide
- Elixir School - Elixir tutorials
- Programming Phoenix LiveView - Book on LiveView patterns
微信扫一扫