Agents 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.
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-clojure-elm" ~/.claude/skills/arustydev-agents-convert-clojure-elm && rm -rf "$T"
content/skills/convert-clojure-elm/SKILL.mdClojure ↔ 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
- 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: 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 |
|---|---|---|
| | Explicit Maybe type |
| | Records are explicit types |
| <br/> | Type signatures required |
| | Module-qualified |
| type | No exceptions |
| Model in TEA | State in architecture |
| | Record update syntax |
| | Field access |
| | Explicit Maybe handling |
| | 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 | ✓ , | ✓ , | Green |
| Error Handling | ✓ , | ✓ , | 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 |
|---|---|---|
| | Part of Maybe type |
/ | / | Capitalized in Elm |
| | Strings are identical |
(integer) | | Explicit type |
(double) | | Explicit type |
| No direct equivalent | Use String or custom type |
| No equivalent | Use String or custom type |
Collection Types
| Clojure | Elm | Notes |
|---|---|---|
(vector) | | Lists are linked, not vectors |
(list) | | Same as vector in Elm |
(map) | | Record with type alias |
(set) | | Import Set module |
(tuple) | | Parentheses, max 3 elements |
| | All lists are lazy-ish in Elm |
Composite Types
| Clojure | Elm | Notes |
|---|---|---|
| | Explicit type required |
| | Type alias for records |
| | Custom type |
| Tagged literal | | Union types |
-able value | | Explicit optional |
Function Types
| Clojure | Elm | Notes |
|---|---|---|
| | Type signature required |
| | Anonymous function |
| | Lambda syntax |
Multi-arity | Separate functions | No multi-arity |
Variadic | 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
comprehension - compose map/filter insteadfor
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
for sequential error handlingResult.andThen - Use
for combining multiple ResultsResult.map2/map3 - 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 |
|---|---|---|
| Auto-format Elm code | Like cljfmt |
| Unit and property testing | Similar to test.check |
| Custom linting rules | Like clj-kondo |
| Package manager | Like lein or deps.edn |
| Development server with hot reload | Like figwheel |
(deprecated) | Code analysis | Use elm-review instead |
| 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:
- Foundational patterns with cross-language examplesmeta-convert-dev
- Clojure development patternslang-clojure-dev
- Elm development patternslang-elm-dev
Cross-cutting pattern skills:
- Async, state management across languagespatterns-concurrency-dev
- JSON, validation, encoding/decodingpatterns-serialization-dev
- Compile-time abstractions across languagespatterns-metaprogramming-dev