Agent Skills: Clojure ↔ Erlang Conversion

Bidirectional conversion between Clojure and Erlang. Use when migrating projects between these languages in either direction. Extends meta-convert-dev with Clojure↔Erlang specific patterns. Use when migrating Clojure projects to Erlang, translating JVM-based functional code to BEAM, or refactoring Clojure codebases to leverage Erlang's fault tolerance and actor model. Extends meta-convert-dev with Clojure-to-Erlang specific patterns.

UncategorizedID: arustydev/ai/convert-clojure-erlang

Repository

aRustyDevLicense: AGPL-3.0
72

Install this agent skill to your local

pnpm dlx add-skill https://github.com/aRustyDev/agents/tree/HEAD/content/skills/convert-clojure-erlang

Skill Files

Browse the full folder contents for convert-clojure-erlang.

Download Skill

Loading file tree…

content/skills/convert-clojure-erlang/SKILL.md

Skill Metadata

Name
convert-clojure-erlang
Description
Bidirectional conversion between Clojure and Erlang. Use when migrating projects between these languages in either direction. Extends meta-convert-dev with Clojure↔Erlang specific patterns. Use when migrating Clojure projects to Erlang, translating JVM-based functional code to BEAM, or refactoring Clojure codebases to leverage Erlang's fault tolerance and actor model. Extends meta-convert-dev with Clojure-to-Erlang specific patterns.

Clojure ↔ Erlang Conversion

Convert Clojure code to idiomatic Erlang, translating from JVM/Lisp-based functional programming to BEAM/Prolog-style functional programming with actors.

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: Clojure persistent data structures → Erlang immutable data
  • Idiom translations: Lisp-style functional → Prolog-style functional
  • Concurrency model: STM + core.async → Actor model (OTP processes)
  • Error handling: Exceptions → Let-it-crash philosophy
  • REPL workflow: REPL-driven development translation patterns (9th pillar)
  • Runtime platform: JVM → BEAM VM migration

This Skill Does NOT Cover

  • General conversion methodology - see meta-convert-dev
  • Clojure language fundamentals - see lang-clojure-dev
  • Erlang language fundamentals - see lang-erlang-dev
  • ClojureScript to Erlang - handle as two-step: ClojureScript → Clojure → Erlang

Quick Reference

| Clojure | Erlang | Notes | |---------|--------|-------| | (def x 42) | X = 42. | Variables are immutable in both | | (defn f [x] body) | f(X) -> Body. | Function definition | | {:key "value"} | #{key => <<"value">>} | Maps (keywords → atoms, strings → binaries) | | [1 2 3] | [1, 2, 3] | Lists (vectors → lists) | | #{:a :b} | sets:from_list([a, b]) | Sets | | (atom 0) | spawn(fun() -> loop(0) end) | Mutable state → Process | | (swap! a inc) | Pid ! increment | State update | | @a | Pid ! {self(), get}; receive {Pid, V} -> V end | State read | | (dosync ...) | gen_server:call(...) | Transaction → Synchronous call | | (future ...) | spawn(fun() -> ... end) | Async execution | | (promise) | Message passing pattern | Promise-like behavior |


When Converting Code

  1. Analyze Clojure semantics - Understand STM, lazy sequences, JVM interop
  2. Map concurrency first - STM/refs/atoms/agents → gen_server/processes
  3. Preserve functional purity - Both are functional, maintain referential transparency
  4. Adopt Erlang idioms - Pattern matching, guards, let-it-crash
  5. Handle laziness explicitly - Clojure's lazy seqs → Erlang generators/streams
  6. Test equivalence - Property-based testing with PropEr matching test.check

Type System Mapping

Primitive Types

| Clojure | Erlang | Notes | |---------|--------|-------| | nil | undefined or nil atom | Erlang convention: undefined | | true/false | true/false | Boolean atoms | | 42 | 42 | Integers (arbitrary precision in Clojure → big integers in Erlang) | | 3.14 | 3.14 | Floats | | :keyword | atom | Keywords become atoms | | "string" | <<"binary">> or "list" | Prefer binaries for efficiency | | \c | $c or <<"c">> | Character → integer or binary | | Symbol | Atom | Both are interned identifiers |

Collection Types

| Clojure | Erlang | Notes | |---------|--------|-------| | [1 2 3] | [1, 2, 3] | Vectors → Lists (note: O(1) vs O(n) append) | | '(1 2 3) | [1, 2, 3] | Lists → Lists (direct mapping) | | {:a 1 :b 2} | #{a => 1, b => 2} | Maps → Maps | | #{:a :b :c} | sets:from_list([a, b, c]) or #{a, b, c} (MapSet pattern) | Sets | | Lazy seq | Generator fun or lists:seq/2 | Explicit laziness needed | | (range) | lists:seq(0, ...) or generator | Infinite sequences require explicit handling | | (iterate f x) | Generator function | Create custom generator |

Composite Types

| Clojure | Erlang | Notes | |---------|--------|-------| | defrecord | -record(...) or map with type tag | Records or typed maps | | deftype | Module with constructor | Encapsulation via module | | defprotocol | -behavior(...) callback module | Polymorphism | | Namespace | Module | Direct mapping | | Var | Process or ETS table | For mutable global state |


Idiom Translation

Pattern: Function Definition

Clojure:

(defn factorial
  "Calculates factorial of n"
  [n]
  (if (<= n 1)
    1
    (* n (factorial (dec n)))))

Erlang:

% Calculates factorial of n
factorial(N) when N =< 1 -> 1;
factorial(N) -> N * factorial(N - 1).

Why this translation:

  • Erlang uses guards (when) instead of explicit if
  • Pattern matching in function clauses is more idiomatic than conditionals
  • Multiple function clauses replace Clojure's single function with conditional body

Pattern: Map Operations

Clojure:

(defn update-user [user]
  (-> user
      (assoc :updated-at (System/currentTimeMillis))
      (update :age inc)
      (dissoc :temp-field)))

Erlang:

update_user(User) ->
    User1 = maps:put(updated_at, erlang:system_time(millisecond), User),
    User2 = maps:update_with(age, fun(Age) -> Age + 1 end, User1),
    maps:remove(temp_field, User2).

% Or with pattern matching
update_user(#{age := Age} = User) ->
    User#{
        updated_at => erlang:system_time(millisecond),
        age => Age + 1
    }.

Why this translation:

  • Threading macro -> becomes sequential variable binding
  • assocmaps:put or map update syntax
  • updatemaps:update_with or pattern match + update
  • dissocmaps:remove

Pattern: Collection Processing

Clojure:

(defn process-users [users]
  (->> users
       (filter :active)
       (map #(update % :name str/upper-case))
       (take 10)
       (group-by :department)))

Erlang:

process_users(Users) ->
    Active = lists:filter(fun(#{active := A}) -> A end, Users),
    Uppercased = lists:map(
        fun(#{name := Name} = U) ->
            U#{name => string:uppercase(Name)}
        end,
        Active
    ),
    TopTen = lists:sublist(Uppercased, 10),
    group_by(department, TopTen).

group_by(Key, List) ->
    lists:foldl(
        fun(#{Key := Val} = Item, Acc) ->
            maps:update_with(Val, fun(Items) -> [Item | Items] end, [Item], Acc)
        end,
        #{},
        List
    ).

Why this translation:

  • Threading last ->> becomes explicit function composition
  • filter, map, take map to lists:filter/2, lists:map/2, lists:sublist/2
  • group-by requires custom implementation (no stdlib equivalent)
  • Clojure's lazy evaluation becomes eager in Erlang

Pattern: Destructuring

Clojure:

(defn greet [{:keys [name age] :or {age 0}}]
  (str "Hello " name ", you are " age))

(let [[first second & rest] items]
  (process first second rest))

Erlang:

greet(#{name := Name, age := Age}) ->
    lists:flatten(io_lib:format("Hello ~s, you are ~p", [Name, Age]));
greet(#{name := Name}) ->
    greet(#{name => Name, age => 0}).

process_items([First, Second | Rest]) ->
    do_process(First, Second, Rest).

Why this translation:

  • Map destructuring in function arguments is similar
  • Default values require separate function clause
  • List destructuring [H|T] is similar to Clojure's [first & rest]

Pattern: Recursion and Accumulation

Clojure:

(defn sum [coll]
  (reduce + 0 coll))

(defn sum-recursive [coll]
  (loop [items coll acc 0]
    (if (empty? items)
      acc
      (recur (rest items) (+ acc (first items))))))

Erlang:

% Using fold
sum(Coll) ->
    lists:foldl(fun(X, Acc) -> X + Acc end, 0, Coll).

% Using tail recursion
sum_recursive(Coll) ->
    sum_recursive(Coll, 0).

sum_recursive([], Acc) -> Acc;
sum_recursive([H|T], Acc) ->
    sum_recursive(T, Acc + H).

Why this translation:

  • reducelists:foldl
  • loop/recur → tail-recursive function with accumulator
  • Both optimize tail calls, so performance is similar

Pattern: Lazy Sequences

Clojure:

(defn fibonacci []
  (map first (iterate (fn [[a b]] [b (+ a b)]) [0 1])))

(take 10 (fibonacci))

Erlang:

% Generator pattern
fibonacci() ->
    fun Loop(A, B) ->
        fun() -> {A, Loop(B, A + B)} end
    end(0, 1).

take(0, _Generator) -> [];
take(N, Generator) ->
    {Value, Next} = Generator(),
    [Value | take(N - 1, Next)].

% Usage
Fib = fibonacci(),
take(10, Fib).

Why this translation:

  • Clojure's lazy sequences don't have direct equivalent
  • Generator pattern (function returning function) provides laziness
  • Explicit take function to realize values
  • Erlang processes can also model infinite sequences

Concurrency Model Translation

STM (Refs) → gen_server

Clojure:

(def account-a (ref 100))
(def account-b (ref 200))

(defn transfer [from to amount]
  (dosync
    (alter from - amount)
    (alter to + amount)))

(transfer account-a account-b 50)

Erlang:

% Using gen_server for coordinated state
-module(account_server).
-behaviour(gen_server).

start_link(Initial) ->
    gen_server:start_link(?MODULE, Initial, []).

transfer(From, To, Amount) ->
    % Withdraw from source
    ok = gen_server:call(From, {withdraw, Amount}),
    try
        % Deposit to destination
        ok = gen_server:call(To, {deposit, Amount})
    catch
        error:Reason ->
            % Rollback on failure
            gen_server:call(From, {deposit, Amount}),
            {error, Reason}
    end.

handle_call({withdraw, Amount}, _From, Balance) when Balance >= Amount ->
    {reply, ok, Balance - Amount};
handle_call({withdraw, _Amount}, _From, Balance) ->
    {reply, {error, insufficient_funds}, Balance};
handle_call({deposit, Amount}, _From, Balance) ->
    {reply, ok, Balance + Amount}.

Why this translation:

  • STM transactions become explicit message passing
  • dosync coordination becomes manual two-phase approach or single coordinator
  • Erlang emphasizes explicit rollback vs automatic retry
  • Alternative: Use a single coordinator gen_server managing both accounts

Atoms → Process Mailbox

Clojure:

(def counter (atom 0))

(swap! counter inc)
(swap! counter + 5)
@counter  ; => 6

Erlang:

% Process-based counter
start_counter(Initial) ->
    spawn(fun() -> counter_loop(Initial) end).

counter_loop(Count) ->
    receive
        {From, increment} ->
            From ! {ok, Count + 1},
            counter_loop(Count + 1);
        {From, {add, N}} ->
            From ! {ok, Count + N},
            counter_loop(Count + N);
        {From, get} ->
            From ! {ok, Count},
            counter_loop(Count);
        stop ->
            ok
    end.

% Client functions
increment(Pid) ->
    Pid ! {self(), increment},
    receive {ok, NewValue} -> NewValue end.

add(Pid, N) ->
    Pid ! {self(), {add, N}},
    receive {ok, NewValue} -> NewValue end.

get_value(Pid) ->
    Pid ! {self(), get},
    receive {ok, Value} -> Value end.

Why this translation:

  • Atoms are lightweight in Clojure but require process in Erlang
  • Message passing replaces direct state mutation
  • swap! becomes send-receive pattern
  • @atom (deref) becomes query message

core.async Channels → Erlang Processes/Messages

Clojure:

(require '[clojure.core.async :as async])

(defn producer [ch]
  (async/go
    (dotimes [i 10]
      (async/>! ch i))
    (async/close! ch)))

(defn consumer [ch]
  (async/go-loop []
    (when-let [value (async/<! ch)]
      (println "Got:" value)
      (recur))))

(let [ch (async/chan 10)]
  (producer ch)
  (consumer ch))

Erlang:

producer(ConsumerPid) ->
    spawn(fun() ->
        lists:foreach(
            fun(I) ->
                ConsumerPid ! {value, I}
            end,
            lists:seq(0, 9)
        ),
        ConsumerPid ! done
    end).

consumer() ->
    spawn(fun() -> consumer_loop() end).

consumer_loop() ->
    receive
        {value, Value} ->
            io:format("Got: ~p~n", [Value]),
            consumer_loop();
        done ->
            ok
    end.

% Usage
Consumer = consumer(),
producer(Consumer).

Why this translation:

  • Channels become direct message passing between processes
  • go blocks become spawned processes
  • <! (take) becomes receive
  • >! (put) becomes ! (send)
  • Channel closing becomes sentinel message (done)

Error Handling Translation

Exceptions → Let-It-Crash

Clojure:

(defn process-data [data]
  (try
    (validate-data data)
    (transform-data data)
    (save-data data)
    (catch Exception e
      (log-error e)
      {:error (.getMessage e)})))

Erlang (Anti-pattern - too defensive):

% DON'T DO THIS - too defensive
process_data(Data) ->
    try
        validate_data(Data),
        Transformed = transform_data(Data),
        save_data(Transformed)
    catch
        error:Reason ->
            log_error(Reason),
            {error, Reason}
    end.

Erlang (Idiomatic - let it crash):

% DO THIS - let supervisor handle failures
process_data(Data) ->
    validate_data(Data),  % Crash if invalid
    Transformed = transform_data(Data),  % Crash if transform fails
    save_data(Transformed).  % Crash if save fails

% Supervisor will restart the worker on crash
init([]) ->
    SupFlags = #{
        strategy => one_for_one,
        intensity => 5,
        period => 60
    },
    ChildSpecs = [
        #{
            id => worker,
            start => {worker, start_link, []},
            restart => permanent,
            shutdown => 5000,
            type => worker
        }
    ],
    {ok, {SupFlags, ChildSpecs}}.

Why this translation:

  • Clojure's exceptions become crashes in Erlang
  • Try-catch only for expected errors, not exceptional conditions
  • Supervisors handle process failure and restart
  • Defensive programming discouraged in favor of supervision trees

Error Tuples Pattern

Clojure:

(defn find-user [id users]
  (if-let [user (first (filter #(= id (:id %)) users))]
    {:ok user}
    {:error :not-found}))

Erlang:

find_user(Id, Users) ->
    case lists:keyfind(Id, #user.id, Users) of
        {user, Id, _, _} = User ->
            {ok, User};
        false ->
            {error, not_found}
    end.

% Or with list comprehension
find_user(Id, Users) ->
    case [U || #{id := UserId} = U <- Users, UserId =:= Id] of
        [User | _] -> {ok, User};
        [] -> {error, not_found}
    end.

Why this translation:

  • Both use tagged tuples for error handling
  • Erlang's pattern matching makes error handling cleaner
  • {ok, Value} and {error, Reason} are Erlang conventions

REPL-Driven Development Translation

Both Clojure and Erlang are REPL-centric languages, making workflow translation smoother than compiled → REPL conversions.

Development Workflow Comparison

| Clojure Workflow | Erlang Equivalent | Translation Notes | |------------------|-------------------|-------------------| | Start REPL (lein repl) | Start shell (erl or rebar3 shell) | Both provide interactive environments | | Send form to REPL (editor integration) | Compile module (c(module)) | Erlang reloads entire modules | | Hot reload namespace | Hot code loading (c:l(module)) | Erlang's hot swapping is production-ready | | REPL-driven design | REPL-driven design | Both support incremental development | | (doc fn) | h(module) or module:module_info() | Documentation lookup | | (source fn) | Decompile with erlang:load_module/2 | Less common in Erlang |

Interactive Development Example

Clojure:

;; In REPL
user=> (defn process-user [user]
         (-> user
             (update :name str/upper-case)
             (assoc :processed true)))

user=> (process-user {:name "alice" :age 30})
;; => {:name "ALICE", :age 30, :processed true}

;; Refine in REPL
user=> (defn process-user [user]
         (-> user
             (update :name str/upper-case)
             (assoc :processed-at (System/currentTimeMillis))))

;; Test immediately
user=> (process-user {:name "alice"})

Erlang:

% In shell
1> c(user_processor).
{ok,user_processor}

2> user_processor:process_user(#{name => "alice", age => 30}).
#{name => "ALICE", age => 30, processed => true}

% Edit user_processor.erl, then reload
3> c(user_processor).
{ok,user_processor}

4> user_processor:process_user(#{name => "alice"}).
#{name => "ALICE",
  processed_at => 1704067200000}

Translation notes:

  • Clojure allows redefining individual functions in REPL
  • Erlang recompiles entire modules
  • Both support rapid iteration and testing
  • Erlang's hot code loading works in production (Clojure requires restart)

REPL Testing Patterns

Clojure:

;; Quick test in REPL
(defn validate-email [email]
  (re-matches #".+@.+\..+" email))

;; Test interactively
user=> (validate-email "test@example.com")
;; => "test@example.com"

user=> (validate-email "invalid")
;; => nil

Erlang:

% Quick test in shell
validate_email(Email) ->
    case re:run(Email, ".+@.+\\..+") of
        {match, _} -> {ok, Email};
        nomatch -> {error, invalid_email}
    end.

% Test interactively
1> c(validator).
{ok,validator}

2> validator:validate_email(<<"test@example.com">>).
{ok,<<"test@example.com">>}

3> validator:validate_email(<<"invalid">>).
{error,invalid_email}

Module System Translation

Clojure:

(ns myapp.user-service
  "User service with CRUD operations"
  (:require [clojure.string :as str]
            [myapp.database :as db]
            [myapp.validation :refer [validate-email]]))

(defn create-user [user-data]
  (let [validated (validate-email (:email user-data))]
    (db/insert :users validated)))

Erlang:

-module(user_service).
-export([create_user/1]).

% No docstring at module level, use comments
% User service with CRUD operations

create_user(UserData) ->
    Email = maps:get(email, UserData),
    case validation:validate_email(Email) of
        {ok, ValidEmail} ->
            database:insert(users, ValidEmail);
        {error, Reason} ->
            {error, Reason}
    end.

Translation notes:

  • Namespaces → Modules (one-to-one mapping)
  • :require → module calls (always qualified)
  • :refer → direct function calls (no import mechanism, use full module name)
  • Private functions: prefix with internal convention or don't export

Macros and Metaprogramming

Clojure Macros → Erlang Parse Transforms

Clojure:

(defmacro unless [condition & body]
  `(if (not ~condition)
     (do ~@body)))

(unless false
  (println "This runs"))

Erlang:

% Parse transforms are more complex, typically use macros instead
-define(UNLESS(Cond, Body),
    case (not Cond) of
        true -> Body;
        false -> ok
    end).

% Usage
?UNLESS(false, io:format("This runs~n")).

% For true metaprogramming, use parse transforms (advanced)
% See lang-erlang-dev for parse transform examples

Translation notes:

  • Clojure macros are more powerful and easier to write
  • Erlang macros are textual substitution (C-style)
  • Complex metaprogramming requires parse transforms (AST manipulation)
  • Most Clojure macro use cases can be solved with higher-order functions in Erlang

Build System and Dependencies

project.clj → rebar.config

Clojure (Leiningen):

(defproject myapp "0.1.0-SNAPSHOT"
  :description "My Clojure application"
  :dependencies [[org.clojure/clojure "1.11.1"]
                 [ring/ring-core "1.10.0"]
                 [cheshire "5.12.0"]]
  :main myapp.core)

Erlang (Rebar3):

{erl_opts, [debug_info]}.

{deps, [
    {cowboy, "2.10.0"},  % Ring equivalent for Erlang
    {jsx, "3.1.0"}       % JSON library (Cheshire equivalent)
]}.

{relx, [
    {release, {myapp, "0.1.0"}, [
        myapp,
        sasl
    ]},
    {mode, dev}
]}.

deps.edn → rebar.config

Clojure (tools.deps):

{:deps {org.clojure/clojure {:mvn/version "1.11.1"}
        ring/ring-core {:mvn/version "1.10.0"}
        cheshire/cheshire {:mvn/version "5.12.0"}}}

Erlang (Rebar3):

{deps, [
    {cowboy, "2.10.0"},
    {jsx, "3.1.0"}
]}.

Testing Strategy Translation

test.check → PropEr

Clojure:

(require '[clojure.test.check :as tc]
         '[clojure.test.check.generators :as gen]
         '[clojure.test.check.properties :as prop])

(def prop-reverse
  (prop/for-all [v (gen/vector gen/small-integer)]
    (= v (reverse (reverse v)))))

(tc/quick-check 100 prop-reverse)

Erlang:

-include_lib("proper/include/proper.hrl").

prop_reverse() ->
    ?FORALL(List, list(integer()),
            lists:reverse(lists:reverse(List)) =:= List).

% Run
proper:quickcheck(prop_reverse(), [{numtests, 100}]).

clojure.test → EUnit

Clojure:

(ns myapp.core-test
  (:require [clojure.test :refer [deftest is testing]]
            [myapp.core :as core]))

(deftest addition-test
  (testing "Basic addition"
    (is (= 4 (core/add 2 2)))
    (is (= 0 (core/add -1 1)))))

Erlang:

-module(core_tests).
-include_lib("eunit/include/eunit.hrl").

addition_test() ->
    ?assertEqual(4, core:add(2, 2)),
    ?assertEqual(0, core:add(-1, 1)).

Common Patterns

Polymorphism: Protocols → Behaviors

Clojure:

(defprotocol Storage
  (save [this data])
  (load [this id]))

(defrecord FileStorage [path]
  Storage
  (save [this data] (spit (:path this) data))
  (load [this id] (slurp (str (:path this) "/" id))))

(defrecord DbStorage [conn]
  Storage
  (save [this data] (db/insert (:conn this) data))
  (load [this id] (db/query (:conn this) id)))

Erlang:

% Define behavior
-module(storage).
-callback save(State :: term(), Data :: term()) -> {ok, term()} | {error, term()}.
-callback load(State :: term(), Id :: term()) -> {ok, term()} | {error, term()}.

% File storage implementation
-module(file_storage).
-behaviour(storage).
-export([save/2, load/2]).

save(#{path := Path}, Data) ->
    file:write_file(Path, term_to_binary(Data)).

load(#{path := Path}, Id) ->
    Filename = filename:join(Path, Id),
    case file:read_file(Filename) of
        {ok, Binary} -> {ok, binary_to_term(Binary)};
        Error -> Error
    end.

% DB storage implementation
-module(db_storage).
-behaviour(storage).
-export([save/2, load/2]).

save(#{conn := Conn}, Data) ->
    database:insert(Conn, Data).

load(#{conn := Conn}, Id) ->
    database:query(Conn, Id).

Performance Considerations

Lazy Evaluation

Issue: Clojure's lazy sequences are evaluated on-demand; Erlang is eager by default.

Impact: Memory usage and performance characteristics differ.

Solution:

% Use generator pattern for lazy evaluation
lazy_range(Start, End) when Start > End ->
    fun() -> done end;
lazy_range(Start, End) ->
    fun() -> {Start, lazy_range(Start + 1, End)} end.

take_lazy(0, _Gen) -> [];
take_lazy(_, Gen) when is_function(Gen, 0) ->
    case Gen() of
        done -> [];
        {Value, Next} -> [Value | take_lazy(N - 1, Next)]
    end.

Persistent Data Structures

Both Clojure and Erlang use persistent (immutable) data structures, but implementations differ:

  • Clojure: Hash Array Mapped Tries (HAMT) for vectors and maps
  • Erlang: Balanced trees for ordered sets, hash tables for maps

Performance:

  • Clojure vectors: O(log32 n) access and update
  • Erlang lists: O(n) access, O(1) prepend
  • Both maps: O(log n) average

Translation guideline:

  • Clojure vector → Erlang list (accept O(n) or use arrays for random access)
  • Frequent appends: Build reversed list, then reverse once at end

Common Pitfalls

1. Atom Table Exhaustion

Pitfall: Converting Clojure keywords to Erlang atoms dynamically

% BAD - can exhaust atom table
process_json(Json) ->
    maps:map(
        fun(K, V) -> {list_to_atom(binary_to_list(K)), V} end,
        Json
    ).

% GOOD - keep keys as binaries or use known atoms
process_json(Json) ->
    % Keep keys as binaries, or whitelist known atoms
    Json.

2. String vs Binary Confusion

Pitfall: Clojure strings become Erlang lists by default (inefficient)

% BAD - string as list
"hello" = [$h, $e, $l, $l, $o].  % Inefficient

% GOOD - use binaries
<<"hello">>.  % Efficient

3. STM Coordination Assumptions

Pitfall: Assuming automatic conflict resolution like Clojure's STM

% NO automatic retry in Erlang
% Must handle conflicts explicitly or use gen_server for coordination

4. Lazy Sequence Realization

Pitfall: Assuming lazy evaluation (Clojure default)

% Erlang is eager - realize sequences immediately
% Use generators for large or infinite sequences

5. Namespace Confusion

Pitfall: Expecting Clojure-style namespace aliasing

% NO automatic aliasing
% Must use full module names: module:function()
% Or use -import (rarely used in Erlang)

Tooling

| Tool | Purpose | Notes | |------|---------|-------| | rebar3 | Build tool | Equivalent to Leiningen/tools.deps | | erlang.mk | Alternative build | Makefile-based build system | | dialyzer | Static analysis | Type checking for Erlang | | PropEr | Property testing | Equivalent to test.check | | Common Test | Integration testing | More comprehensive than EUnit | | observer | Runtime inspection | GUI tool for debugging, profiling | | recon | Production debugging | Runtime inspection and debugging |


Examples

Example 1: Simple - Function with Pattern Matching

Before (Clojure):

(defn greet
  ([name] (greet name "Hello"))
  ([name greeting]
   (str greeting ", " name "!")))

(greet "Alice")          ;; => "Hello, Alice!"
(greet "Bob" "Hi")       ;; => "Hi, Bob!"

After (Erlang):

greet(Name) ->
    greet(Name, "Hello").

greet(Name, Greeting) ->
    lists:flatten(io_lib:format("~s, ~s!", [Greeting, Name])).

% Usage
greet("Alice").          % => "Hello, Alice!"
greet("Bob", "Hi").      % => "Hi, Bob!"

Example 2: Medium - Map Processing with Error Handling

Before (Clojure):

(defn validate-user [user]
  (let [errors (cond-> []
                 (empty? (:name user)) (conj :name-required)
                 (< (:age user 0) 18) (conj :age-too-young)
                 (not (re-matches #".+@.+" (:email user "")))
                   (conj :invalid-email))]
    (if (empty? errors)
      {:ok user}
      {:error errors})))

(defn create-user [user-data]
  (let [{:keys [ok error]} (validate-user user-data)]
    (if ok
      (db/insert ok)
      {:error error})))

After (Erlang):

validate_user(User) ->
    Errors = validate_user_fields(User, []),
    case Errors of
        [] -> {ok, User};
        _ -> {error, Errors}
    end.

validate_user_fields(#{name := Name, age := Age, email := Email} = User, Errors) ->
    Errors1 = case Name of
        <<>> -> [name_required | Errors];
        _ -> Errors
    end,
    Errors2 = case Age of
        A when A < 18 -> [age_too_young | Errors1];
        _ -> Errors1
    end,
    case re:run(Email, ".+@.+") of
        {match, _} -> Errors2;
        nomatch -> [invalid_email | Errors2]
    end;
validate_user_fields(_, Errors) ->
    [missing_fields | Errors].

create_user(UserData) ->
    case validate_user(UserData) of
        {ok, User} ->
            database:insert(User);
        {error, Errors} ->
            {error, Errors}
    end.

Example 3: Complex - Concurrent Worker Pool with Supervision

Before (Clojure):

(require '[clojure.core.async :as async])

(defn worker [id tasks results]
  (async/go-loop []
    (when-let [task (async/<! tasks)]
      (try
        (let [result (process-task task)]
          (async/>! results {:worker id :result result}))
        (catch Exception e
          (async/>! results {:worker id :error (.getMessage e)})))
      (recur))))

(defn start-worker-pool [n-workers]
  (let [tasks (async/chan 100)
        results (async/chan 100)
        workers (doall (map #(worker % tasks results) (range n-workers)))]
    {:tasks tasks
     :results results
     :workers workers}))

(defn submit-task [pool task]
  (async/>!! (:tasks pool) task))

(defn get-result [pool]
  (async/<!! (:results pool)))

;; Usage
(def pool (start-worker-pool 5))
(submit-task pool {:type :compute :data [1 2 3]})
(get-result pool)

After (Erlang):

-module(worker_pool).
-behaviour(supervisor).

%% API
-export([start_link/1, submit_task/2, get_results/1]).

%% Supervisor callbacks
-export([init/1]).

%% Worker functions
-export([worker_loop/2]).

%%% API Functions

start_link(NumWorkers) ->
    supervisor:start_link({local, ?MODULE}, ?MODULE, NumWorkers).

submit_task(Task, ResultsPid) ->
    % Get random worker
    Workers = supervisor:which_children(?MODULE),
    {_, WorkerPid, _, _} = lists:nth(rand:uniform(length(Workers)), Workers),
    WorkerPid ! {task, Task, ResultsPid}.

get_results(ResultsPid) ->
    receive
        {result, Result} -> {ok, Result};
        {error, Reason} -> {error, Reason}
    after 5000 ->
        {error, timeout}
    end.

%%% Supervisor Callbacks

init(NumWorkers) ->
    SupFlags = #{
        strategy => one_for_one,
        intensity => 10,
        period => 60
    },

    WorkerSpecs = [
        #{
            id => {worker, Id},
            start => {?MODULE, worker_loop, [Id, self()]},
            restart => permanent,
            shutdown => 5000,
            type => worker
        } || Id <- lists:seq(1, NumWorkers)
    ],

    {ok, {SupFlags, WorkerSpecs}}.

%%% Worker Functions

worker_loop(Id, SupervisorPid) ->
    receive
        {task, Task, ResultsPid} ->
            try process_task(Task) of
                Result ->
                    ResultsPid ! {result, #{worker => Id, result => Result}},
                    worker_loop(Id, SupervisorPid)
            catch
                error:Reason ->
                    ResultsPid ! {error, #{worker => Id, error => Reason}},
                    worker_loop(Id, SupervisorPid)
            end;
        stop ->
            ok
    end.

process_task(#{type := compute, data := Data}) ->
    % Process task
    lists:sum(Data).

%%% Usage Example

% Start pool
{ok, PoolPid} = worker_pool:start_link(5).

% Submit task
ResultsPid = spawn(fun() ->
    receive
        {result, R} -> io:format("Got result: ~p~n", [R])
    end
end),

worker_pool:submit_task(#{type => compute, data => [1,2,3]}, ResultsPid).

See Also

For more examples and patterns, see:

  • meta-convert-dev - Foundational patterns with cross-language examples
  • convert-clojure-elixir - Similar conversion (JVM LISP → BEAM LISP)
  • convert-clojure-haskell - Dynamic FP → Static FP patterns
  • convert-python-erlang - Imperative → Actor model patterns
  • lang-clojure-dev - Clojure development patterns
  • lang-erlang-dev - Erlang development patterns

Cross-cutting pattern skills:

  • patterns-concurrency-dev - Async, channels, processes across languages
  • patterns-serialization-dev - JSON, validation across languages
  • patterns-metaprogramming-dev - Macros, parse transforms across languages