convert-clojure-elm
Bidirectional conversion between Clojure and Elm. Use when migrating projects between these languages in either direction. Extends meta-convert-dev with Clojure↔Elm specific patterns. Use when migrating Clojure projects to Elm, translating functional patterns from JVM to browser, or building type-safe frontends from Clojure logic. Extends meta-convert-dev with Clojure-to-Elm specific patterns for handling dynamic-to-static typing, REPL-driven to TEA architecture, and side effects to managed effects.
Installation
Follow the repository instructions for this skill and your agent. Review the files and dependencies before adding it to your workflow.
View source ↗GuidesSkill instructions
Clojure ↔ Elm Conversion
Bidirectional conversion between Clojure and Elm. This skill extends meta-convert-dev with Clojure↔Elm specific type mappings, idiom translations, and architectural patterns.
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: Dynamic Clojure types → Static Elm types
- Architecture translation: REPL-driven development → The Elm Architecture (TEA)
- Effect handling: Side effects anywhere → Cmd/Sub managed effects
- Null handling: nil → Maybe/Nothing pattern
- Error handling: Exceptions → Result types
- Data structures: Persistent collections → Immutable records
- Macro translation: Compile-time macros → Type-driven design
This Skill Does NOT Cover
- General conversion methodology - see
meta-convert-dev - Clojure language fundamentals - see
lang-clojure-dev - Elm language fundamentals - see
lang-elm-dev - ClojureScript → Elm (similar but with JS runtime considerations)
Quick Reference
| Clojure | Elm | Notes |
|---|---|---|
nil |
Nothing |
Explicit Maybe type |
{:key "value"} |
{ key = "value" } |
Records are explicit types |
(defn f [x] ...) |
f : a -> bf x = ... |
Type signatures required |
(map f coll) |
List.map f list |
Module-qualified |
(try ... (catch ...)) |
Result type |
No exceptions |
(atom 0) |
Model in TEA | State in architecture |
(assoc m :k v) |
{ model | k = v } |
Record update syntax |
(get m :k) |
model.k |
Field access |
(or x y) |
Maybe.withDefault y x |
Explicit Maybe handling |
(when pred ...) |
if pred then ... else ... |
Always need else |
8 Pillars Validation
Before converting, verify coverage of the 8 pillars essential for code conversion:
| Pillar | lang-clojure-dev | lang-elm-dev | Coverage |
|---|---|---|---|
| Module System | ✓ ns, :require |
✓ module, import |
Green |
| Error Handling | ✓ try/catch, ex-info |
✓ Result, Maybe |
Green |
| Concurrency Model | ✓ atoms, refs, agents | ✓ Cmd/Sub, Task | Green |
| Metaprogramming | ✓ Macros, syntax quote | ✓ No macros (intentional) | Green |
| Zero/Default Values | ~ nil everywhere | ✓ Explicit Maybe, no null | Green |
| Serialization | ✓ EDN, JSON, Transit, spec | ✓ JSON decoders/encoders | Green |
| Build/Deps | ✓ Leiningen, deps.edn | ✓ elm.json, elm install | Green |
| Testing | ✓ clojure.test, test.check | ✓ elm-test, fuzz testing | Green |
Status: Green (8/8 pillars covered)
Recommendation: Proceed with conversion - both skills have comprehensive coverage.
When Converting Code
- Define types first - Clojure is dynamic, Elm requires explicit types for everything
- Map nil → Maybe - Identify all nullable values and make them explicit
- Extract pure functions - Separate logic from side effects
- Design TEA architecture - Model, View, Update before writing code
- Handle all cases - Elm's exhaustive pattern matching replaces runtime checks
- No runtime errors - Elm guarantees no null pointers, no type mismatches
Type System Mapping
Primitive Types
| Clojure | Elm | Notes |
|---|---|---|
nil |
Nothing |
Part of Maybe type |
true/false |
True/False |
Capitalized in Elm |
"string" |
"string" |
Strings are identical |
42 (integer) |
42 : Int |
Explicit type |
3.14 (double) |
3.14 : Float |
Explicit type |
:keyword |
No direct equivalent | Use String or custom type |
'symbol |
No equivalent | Use String or custom type |
Collection Types
| Clojure | Elm | Notes |
|---|---|---|
[1 2 3] (vector) |
[ 1, 2, 3 ] : List Int |
Lists are linked, not vectors |
'(1 2 3) (list) |
[ 1, 2, 3 ] : List Int |
Same as vector in Elm |
{:a 1 :b 2} (map) |
{ a = 1, b = 2 } |
Record with type alias |
#{1 2 3} (set) |
Set.fromList [ 1, 2, 3 ] |
Import Set module |
[x y] (tuple) |
( x, y ) |
Parentheses, max 3 elements |
(seq coll) |
list |
All lists are lazy-ish in Elm |
Composite Types
| Clojure | Elm | Notes |
|---|---|---|
{:name "Alice" :age 30} |
type alias User = { name : String, age : Int } |
Explicit type required |
(defrecord User [name age]) |
type alias User = { name : String, age : Int } |
Type alias for records |
(deftype Point [x y]) |
type Point = Point Float Float |
Custom type |
| Tagged literal | type Msg = Clicked | Typed String |
Union types |
nil-able value |
Maybe User |
Explicit optional |
Function Types
| Clojure | Elm | Notes |
|---|---|---|
(defn f [x] ...) |
f : a -> b |
Type signature required |
(fn [x] ...) |
\x -> ... |
Anonymous function |
#(* % 2) |
\x -> x * 2 |
Lambda syntax |
Multi-arity (defn f ([x] ...) ([x y] ...)) |
Separate functions | No multi-arity |
Variadic (defn f [& args]) |
List a parameter |
No varargs |
Idiom Translation
Pattern: Dynamic Map → Typed Record
Clojure:
;; Maps are dynamic - any keys, any values
(def user {:name "Alice" :email "alice@example.com"})
(defn greet [user]
(str "Hello, " (:name user)))
(defn update-email [user email]
(assoc user :email email))
Elm:
-- Records have explicit types - all fields known at compile time
type alias User =
{ name : String
, email : String
}
greet : User -> String
greet user =
"Hello, " ++ user.name
updateEmail : User -> String -> User
updateEmail user email =
{ user | email = email }
Why this translation:
- Elm requires all record fields to be declared in a type alias
- Field access is compile-time checked
- No runtime key errors possible
- Type inference helps but explicit types are idiomatic
Pattern: nil Handling → Maybe
Clojure:
;; nil can appear anywhere
(defn find-user [id users]
(first (filter #(= (:id %) id) users)))
;; Returns user or nil
(defn display-name [user]
(or (:name user) "Anonymous"))
Elm:
-- Explicit Maybe for optional values
findUser : Int -> List User -> Maybe User
findUser id users =
users
|> List.filter (\u -> u.id == id)
|> List.head -- Returns Maybe User
displayName : Maybe User -> String
displayName maybeUser =
maybeUser
|> Maybe.map .name
|> Maybe.withDefault "Anonymous"
Why this translation:
- Elm has no null/nil - compiler forces you to handle absence
- Maybe makes optionality explicit in type signatures
- Pattern matching ensures all cases are handled
- No NullPointerException possible
Pattern: Sequence Operations
Clojure:
;; Lazy sequences with threading
(->> data
(filter active?)
(map :value)
(reduce +))
;; List comprehension
(for [x (range 10)
:when (even? x)]
(* x 2))
Elm:
-- List pipeline (not lazy by default)
data
|> List.filter active
|> List.map .value
|> List.foldl (+) 0
-- No list comprehension - use map/filter
List.range 0 9
|> List.filter (\x -> modBy 2 x == 0)
|> List.map (\x -> x * 2)
Why this translation:
- Elm's pipeline operator
|>is similar to Clojure's->> - Elm lists are not lazy (except for user-defined streams)
- Use explicit List module functions
- No
forcomprehension - compose map/filter instead
Pattern: Atoms/State → TEA Model
Clojure:
;; Mutable state with atom
(def counter (atom 0))
(defn increment! []
(swap! counter inc))
(defn get-count []
@counter)
Elm:
-- Immutable model in TEA
type alias Model =
{ counter : Int
}
type Msg
= Increment
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Increment ->
( { model | counter = model.counter + 1 }, Cmd.none )
-- No direct "get" - view is pure function of model
view : Model -> Html Msg
view model =
div [] [ text (String.fromInt model.counter) ]
Why this translation:
- Elm has no mutable state - all state in Model
- Updates happen through messages (Msg type)
- State changes are pure functions:
Msg -> Model -> (Model, Cmd Msg) - View is always pure function:
Model -> Html Msg
Pattern: Macros → Type-Driven Design
Clojure:
;; Macro for compile-time abstraction
(defmacro unless [condition & body]
`(if (not ~condition)
(do ~@body)))
(unless false
(println "This runs"))
Elm:
-- No macros - use types and functions instead
unless : Bool -> (() -> a) -> Maybe a
unless condition thunk =
if not condition then
Just (thunk ())
else
Nothing
-- Or just use if directly (more idiomatic)
if not condition then
Debug.log "This runs"
else
()
Why this translation:
- Elm has no macros - all code is explicit
- Use higher-order functions for abstraction
- Phantom types encode compile-time constraints
- Code generation (elm-codegen) for repetitive code
Pattern: Destructuring
Clojure:
;; Map destructuring
(let [{:keys [name age]} user]
(str name " is " age))
;; Vector destructuring
(let [[first & rest] coll]
(process first rest))
Elm:
-- Record destructuring
let
{ name, age } = user
in
name ++ " is " ++ String.fromInt age
-- List pattern matching (not destructuring)
case list of
first :: rest ->
process first rest
[] ->
-- Must handle empty list
defaultValue
Why this translation:
- Elm's record destructuring is similar to Clojure's map destructuring
- List destructuring requires pattern matching with
case - Must handle all cases - compiler enforces exhaustiveness
- Can destructure in function parameters:
greet { name } = ...
Error Handling
Clojure Exception Model → Elm Result Model
Clojure uses exceptions:
(defn parse-int [s]
(try
(Integer/parseInt s)
(catch NumberFormatException e
(throw (ex-info "Invalid number" {:input s})))))
(defn divide [a b]
(if (zero? b)
(throw (ex-info "Division by zero" {:a a :b b}))
(/ a b)))
Elm uses Result type:
parseInt : String -> Result String Int
parseInt s =
String.toInt s
|> Result.fromMaybe ("Invalid number: " ++ s)
divide : Float -> Float -> Result String Float
divide a b =
if b == 0 then
Err "Division by zero"
else
Ok (a / b)
-- Chaining Results
parseAndDivide : String -> String -> Result String Float
parseAndDivide numStr denomStr =
Result.map2 divide
(parseInt numStr)
(parseInt denomStr)
|> Result.andThen identity
Why this approach:
- Elm has no exceptions - all errors are values
- Result type makes error handling explicit
- Compiler forces you to handle Err case
- No try/catch needed - pattern match on Result
Error Propagation
Clojure:
;; Exceptions bubble up automatically
(defn process-user [id]
(let [user (fetch-user id) ;; May throw
validated (validate user) ;; May throw
saved (save-user validated)] ;; May throw
saved))
Elm:
-- Results must be explicitly chained
processUser : Int -> Cmd Msg
processUser id =
fetchUser id
|> Task.andThen validateUser
|> Task.andThen saveUser
|> Task.attempt ProcessUserComplete
-- Or with Result in update:
case fetchUser id of
Ok user ->
case validateUser user of
Ok validated ->
case saveUser validated of
Ok saved ->
( { model | user = Just saved }, Cmd.none )
Err saveError ->
( { model | error = Just saveError }, Cmd.none )
Err validationError ->
( { model | error = Just validationError }, Cmd.none )
Err fetchError ->
( { model | error = Just fetchError }, Cmd.none)
-- Better: use Result helpers
processUser : Int -> Result String User
processUser id =
fetchUser id
|> Result.andThen validateUser
|> Result.andThen saveUser
Translation strategy:
- Map Clojure exceptions to Elm Result types
- Use
Result.andThenfor sequential error handling - Use
Result.map2/map3for combining multiple Results - Pattern match in update function to handle errors
Architecture Translation
REPL-Driven Development → The Elm Architecture (TEA)
Clojure approach:
;; Direct interaction with state
(def app-state (atom {:count 0 :users []}))
;; Functions mutate state directly
(defn increment! []
(swap! app-state update :count inc))
(defn add-user! [user]
(swap! app-state update :users conj user))
;; REPL experimentation
(increment!)
@app-state ;; => {:count 1 :users []}
Elm approach (TEA):
-- 1. Define Model (all state)
type alias Model =
{ count : Int
, users : List User
}
-- 2. Define Msg (all possible actions)
type Msg
= Increment
| AddUser User
-- 3. Define Update (pure state transitions)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Increment ->
( { model | count = model.count + 1 }, Cmd.none )
AddUser user ->
( { model | users = model.users ++ [ user ] }, Cmd.none )
-- 4. Define View (pure render function)
view : Model -> Html Msg
view model =
div []
[ div [] [ text ("Count: " ++ String.fromInt model.count) ]
, button [ onClick Increment ] [ text "+" ]
, div [] (List.map viewUser model.users)
]
Translation strategy:
- Identify Clojure atoms/refs → Elm Model fields
- Map mutation functions → Msg constructors
- Extract pure logic into update branches
- Build view as pure function of Model
Side Effects → Cmd and Sub
Clojure (side effects anywhere):
(defn fetch-and-save-user [id]
(let [user (http/get (str "/api/users/" id)) ;; Side effect
parsed (json/parse-string (:body user))] ;; Pure
(db/save! parsed) ;; Side effect
parsed))
Elm (managed effects):
-- Side effects ONLY through Cmd
type Msg
= FetchUser Int
| GotUser (Result Http.Error User)
| SaveUser User
| UserSaved (Result Http.Error ())
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
FetchUser id ->
( { model | loading = True }
, Http.get
{ url = "/api/users/" ++ String.fromInt id
, expect = Http.expectJson GotUser userDecoder
}
)
GotUser (Ok user) ->
( model, saveUserCmd user )
GotUser (Err error) ->
( { model | error = Just error }, Cmd.none )
SaveUser user ->
( model, saveUserCmd user )
UserSaved (Ok ()) ->
( { model | saved = True }, Cmd.none )
UserSaved (Err error) ->
( { model | error = Just error }, Cmd.none )
-- Cmd constructors
saveUserCmd : User -> Cmd Msg
saveUserCmd user =
Http.post
{ url = "/api/users"
, body = Http.jsonBody (encodeUser user)
, expect = Http.expectWhatever UserSaved
}
Translation strategy:
- Extract all side effects → Cmd in update
- Define Msg for each async result
- Chain effects through Msg flow
- Use Task for sequential async operations
Common Pitfalls
1. Assuming Dynamic Typing
Problem: Treating Elm like dynamically-typed Clojure
-- ❌ WRONG: Can't have heterogeneous lists
users = [ { name = "Alice" }, { name = "Bob", age = 30 } ]
-- ERROR: Record fields must match
-- ✓ CORRECT: Define explicit type with Maybe for optional fields
type alias User =
{ name : String
, age : Maybe Int
}
users =
[ { name = "Alice", age = Nothing }
, { name = "Bob", age = Just 30 }
]
2. Forgetting to Handle All Cases
Problem: Incomplete pattern matching
-- ❌ WRONG: Missing Nothing case
getName : Maybe User -> String
getName maybeUser =
case maybeUser of
Just user ->
user.name
-- ERROR: Missing pattern: Nothing
-- ✓ CORRECT: Handle all cases
getName : Maybe User -> String
getName maybeUser =
case maybeUser of
Just user ->
user.name
Nothing ->
"Anonymous"
3. Trying to Mutate State
Problem: Thinking of Elm Model like Clojure atom
-- ❌ WRONG: Can't mutate model
update msg model =
model.count = model.count + 1 -- ERROR: No assignment in Elm
( model, Cmd.none )
-- ✓ CORRECT: Create new record with updated field
update msg model =
( { model | count = model.count + 1 }, Cmd.none )
4. Expecting Macros
Problem: Looking for Clojure-style macros
-- ❌ WRONG: No macros in Elm
-- Can't write:
-- defmacro myWhen [condition & body] ...
-- ✓ CORRECT: Use functions or code generation
myWhen : Bool -> (() -> a) -> Maybe a
myWhen condition thunk =
if condition then
Just (thunk ())
else
Nothing
-- Or just use if directly (more idiomatic)
5. Missing Type Annotations
Problem: Relying too much on type inference
-- ❌ BAD: No type signature
add x y =
x + y
-- Inferred type might not be what you want
-- ✓ GOOD: Explicit type signature
add : Int -> Int -> Int
add x y =
x + y
6. Forgetting Else Branch
Problem: Clojure when has no else; Elm if requires it
;; Clojure: when has implicit nil
(when condition
(do-thing))
-- ❌ WRONG: Missing else
if condition then
doThing
-- ERROR: if needs else branch
-- ✓ CORRECT: Provide else (even if unit)
if condition then
doThing
else
()
Tooling
| Tool | Purpose | Notes |
|---|---|---|
elm-format |
Auto-format Elm code | Like cljfmt |
elm-test |
Unit and property testing | Similar to test.check |
elm-review |
Custom linting rules | Like clj-kondo |
elm-json |
Package manager | Like lein or deps.edn |
elm-live |
Development server with hot reload | Like figwheel |
elm-analyse (deprecated) |
Code analysis | Use elm-review instead |
elm-codegen |
Generate Elm code | For repetitive boilerplate |
Examples
Example 1: Simple - Data Transformation
Before (Clojure):
;; Transform list of maps
(defn process-users [users]
(->> users
(filter :active)
(map :name)
(map str/upper-case)))
(process-users [{:name "alice" :active true}
{:name "bob" :active false}
{:name "charlie" :active true}])
;; => ("ALICE" "CHARLIE")
After (Elm):
-- Transform list of records
type alias User =
{ name : String
, active : Bool
}
processUsers : List User -> List String
processUsers users =
users
|> List.filter .active
|> List.map .name
|> List.map String.toUpper
-- Usage
processUsers
[ { name = "alice", active = True }
, { name = "bob", active = False }
, { name = "charlie", active = True }
]
-- => [ "ALICE", "CHARLIE" ]
Example 2: Medium - Error Handling with HTTP
Before (Clojure):
(require '[clj-http.client :as http]
'[cheshire.core :as json])
(defn fetch-user [id]
(try
(-> (http/get (str "https://api.example.com/users/" id))
:body
(json/parse-string true))
(catch Exception e
(println "Error fetching user:" (.getMessage e))
nil)))
(defn display-user [id]
(if-let [user (fetch-user id)]
(str "User: " (:name user))
"User not found"))
After (Elm):
import Http
import Json.Decode as Decode exposing (Decoder)
-- Model includes loading states
type alias Model =
{ user : RemoteData Http.Error User
}
type RemoteData error value
= NotAsked
| Loading
| Success value
| Failure error
-- Messages for async flow
type Msg
= FetchUser Int
| GotUser (Result Http.Error User)
-- User decoder
userDecoder : Decoder User
userDecoder =
Decode.map2 User
(Decode.field "name" Decode.string)
(Decode.field "email" Decode.string)
-- Update handles side effects
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
FetchUser id ->
( { model | user = Loading }
, Http.get
{ url = "https://api.example.com/users/" ++ String.fromInt id
, expect = Http.expectJson GotUser userDecoder
}
)
GotUser (Ok user) ->
( { model | user = Success user }, Cmd.none )
GotUser (Err error) ->
( { model | user = Failure error }, Cmd.none )
-- View renders based on state
view : Model -> Html Msg
view model =
case model.user of
NotAsked ->
button [ onClick (FetchUser 1) ] [ text "Load User" ]
Loading ->
div [] [ text "Loading..." ]
Success user ->
div [] [ text ("User: " ++ user.name) ]
Failure error ->
div [] [ text "User not found" ]
Example 3: Complex - Full Application with State Management
Before (Clojure):
(ns app.core
(:require [clojure.string :as str]))
;; Application state
(def state (atom {:users []
:search ""
:filter :all}))
;; State mutations
(defn add-user! [user]
(swap! state update :users conj user))
(defn set-search! [query]
(swap! state assoc :search query))
(defn set-filter! [filter-type]
(swap! state assoc :filter filter-type))
;; Pure logic
(defn matches-search? [user query]
(str/includes? (str/lower-case (:name user))
(str/lower-case query)))
(defn matches-filter? [user filter-type]
(case filter-type
:all true
:active (:active user)
:inactive (not (:active user))))
(defn filtered-users [users search filter-type]
(->> users
(filter #(matches-search? % search))
(filter #(matches-filter? % filter-type))))
;; Usage
(add-user! {:name "Alice" :active true})
(add-user! {:name "Bob" :active false})
(set-search! "ali")
(set-filter! :active)
(let [{:keys [users search filter]} @state]
(filtered-users users search filter))
;; => ({:name "Alice" :active true})
After (Elm):
module Main exposing (main)
import Browser
import Html exposing (Html, button, div, input, text)
import Html.Attributes exposing (placeholder, value)
import Html.Events exposing (onClick, onInput)
-- MODEL
type alias User =
{ name : String
, active : Bool
}
type FilterType
= All
| Active
| Inactive
type alias Model =
{ users : List User
, search : String
, filter : FilterType
}
init : () -> ( Model, Cmd Msg )
init _ =
( { users = []
, search = ""
, filter = All
}
, Cmd.none
)
-- UPDATE
type Msg
= AddUser User
| SetSearch String
| SetFilter FilterType
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
AddUser user ->
( { model | users = model.users ++ [ user ] }, Cmd.none )
SetSearch query ->
( { model | search = query }, Cmd.none )
SetFilter filterType ->
( { model | filter = filterType }, Cmd.none )
-- VIEW HELPERS (Pure logic)
matchesSearch : String -> User -> Bool
matchesSearch query user =
String.contains
(String.toLower query)
(String.toLower user.name)
matchesFilter : FilterType -> User -> Bool
matchesFilter filterType user =
case filterType of
All ->
True
Active ->
user.active
Inactive ->
not user.active
filteredUsers : Model -> List User
filteredUsers model =
model.users
|> List.filter (matchesSearch model.search)
|> List.filter (matchesFilter model.filter)
-- VIEW
view : Model -> Html Msg
view model =
div []
[ div []
[ input
[ placeholder "Search users..."
, value model.search
, onInput SetSearch
]
[]
]
, div []
[ button [ onClick (SetFilter All) ] [ text "All" ]
, button [ onClick (SetFilter Active) ] [ text "Active" ]
, button [ onClick (SetFilter Inactive) ] [ text "Inactive" ]
]
, div []
[ button
[ onClick (AddUser { name = "Alice", active = True }) ]
[ text "Add Alice" ]
, button
[ onClick (AddUser { name = "Bob", active = False }) ]
[ text "Add Bob" ]
]
, div []
(filteredUsers model
|> List.map viewUser
)
]
viewUser : User -> Html Msg
viewUser user =
div []
[ text (user.name ++ if user.active then " ✓" else " ✗")
]
-- MAIN
main : Program () Model Msg
main =
Browser.element
{ init = init
, update = update
, view = view
, subscriptions = \_ -> Sub.none
}
See Also
For more examples and patterns, see:
-
meta-convert-dev- Foundational patterns with cross-language examples -
lang-clojure-dev- Clojure development patterns -
lang-elm-dev- Elm development patterns
Cross-cutting pattern skills:
-
patterns-concurrency-dev- Async, state management across languages -
patterns-serialization-dev- JSON, validation, encoding/decoding -
patterns-metaprogramming-dev- Compile-time abstractions across languages
Examples
Read the usage examples in the source repository.
View source ↗Source history
- Indexed on
- 2026-03-11
- Index updated
- 2026-03-11
- Branch
- main
Index and sync dates describe this directory. View the source repository for code history.
GitHub ↗chat Comments (0)
Sign in to join the discussion and leave a comment.
No comments yet. Be the first to share your thoughts!