Agents convert-clojure-roc

Bidirectional conversion between Clojure and Roc. Use when migrating projects between these languages in either direction. Extends meta-convert-dev with Clojure↔Roc specific patterns. Use when migrating Clojure applications to Roc's platform model, translating dynamic functional code to static functional style, or refactoring REPL-driven code to compile-time verified patterns. Extends meta-convert-dev with Clojure-to-Roc specific patterns.

install
source · Clone the upstream repo
git clone https://github.com/aRustyDev/agents
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/aRustyDev/agents "$T" && mkdir -p ~/.claude/skills && cp -r "$T/content/skills/convert-clojure-roc" ~/.claude/skills/arustydev-agents-convert-clojure-roc && rm -rf "$T"
manifest: content/skills/convert-clojure-roc/SKILL.md
source content

Clojure ↔ Roc Conversion

Bidirectional conversion between Clojure and Roc. This skill extends

meta-convert-dev
with Clojure↔Roc specific type mappings, idiom translations, and tooling for translating from dynamically-typed REPL-driven development to statically-typed platform-based architecture.

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's dynamic types → Roc's static types
  • Idiom translations: REPL-driven patterns → compile-time verified code
  • Error handling: Exception-based → Result type with pattern matching
  • Concurrency patterns: Atoms/refs/agents → platform-managed tasks
  • Platform architecture: JVM-based → platform/application separation
  • Paradigm shift: Dynamic functional → static functional with structural types

This Skill Does NOT Cover

  • General conversion methodology - see
    meta-convert-dev
  • Clojure language fundamentals - see
    lang-clojure-dev
  • Roc language fundamentals - see
    lang-roc-dev
  • ClojureScript frontend patterns - focus is on Clojure backend to Roc applications

Quick Reference

ClojureRocNotes
(defn f [x] ...)
f = \x -> ...
Function definition
String
Str
String type
Long
/
Integer
I64
/
U64
Integer types (specify signedness)
Double
F64
Floating point
Boolean
Bool
Boolean type
nil
Tag union with empty variantNo null; use
[Some a, None]
[1 2 3]
(vector)
List I64
Lists (immutable sequences)
{:a 1 :b 2}
{ a: 1, b: 2 }
Maps → Records (must have known shape)
#{1 2 3}
Set I64
Sets
(try ... (catch ...))
Result a err
Exception → Result type
(atom x)
Platform stateAtoms → platform-managed state
(fn [x] (* x x))
\x -> x * x
Anonymous functions
(map f coll)
List.map f coll
Map over collections

When Converting Code

  1. Analyze source thoroughly before writing target - understand dynamic behavior
  2. Map types first - convert dynamic runtime checks to static types
  3. Preserve semantics over syntax similarity - embrace Roc's type system
  4. Adopt target idioms - don't write "Clojure code in Roc syntax"
  5. Handle edge cases - nil → explicit Maybe, exceptions → Result
  6. Leverage platform model - separate pure logic from I/O
  7. Test equivalence - same inputs → same outputs
  8. Add compile-time validation - use Roc's type system to replace runtime checks

Type System Mapping

Primitive Types

ClojureRocNotes
String
Str
Direct mapping
Long
(default)
I64
64-bit signed integer
Integer
I32
32-bit signed integer
Double
F64
64-bit floating point
Float
F32
32-bit floating point
Boolean
(true/false)
Bool
Direct mapping
Character
U32
Unicode code point
nil
[None]
or
Result
No null; use tag unions
Keyword
Tag or
Str
:keyword
→ tag or string

Important numeric differences:

  • Clojure: Arbitrary precision integers (BigInt) available, automatic promotion
  • Roc: Fixed-size integers, explicit overflow behavior (wrapping vs saturating vs checking)
  • Clojure:
    (/ 1 3)
    returns exact ratio;
    (/ 1.0 3)
    returns float
  • Roc: Must choose F64 or Dec (decimal) for division

Collection Types

ClojureRocNotes
[a]
(vector)
List a
Immutable lists
'(a)
(list)
List a
Both map to List in Roc
{:k v}
(map with keyword keys)
{ k : V }
(record)
If keys are known at compile time
{k v}
(map with arbitrary keys)
Dict k v
For dynamic key sets
#{a}
(set)
Set a
Unique values
[a b]
(2-tuple as vector)
(a, b)
Explicit tuple type
[a b c]
(3-tuple as vector)
(a, b, c)
Roc has native tuples

Key difference:

  • Clojure: Maps are the primary composite data structure, keys can be anything
  • Roc: Records are typed with known fields at compile time; Dict is for dynamic keys

Composite Types

ClojureRocNotes
{:name "Alice" :age 30}
{ name: "Alice", age: 30 }
Map → Record (if shape is known)
(defrecord User [name age])
User : { name : Str, age : U32 }
Record type alias
:keyword
/
:another
[Keyword, Another]
(tags)
Union types for discriminated values
{:type :user :name "x"}
User { name: "x" }
(tag with payload)
Tagged unions
(Either :ok val :error err)
Result val err
or
[Ok val, Err err]
Result type pattern

Function Types

ClojureRocNotes
(fn [a] b)
a -> b
Function type signature
(fn [a b] c)
a, b -> c
Multi-argument function
(fn [& args] ...)
List a -> ...
Variadic → list parameter
Higher-order function
(a -> b) -> c
Functions as values

Idiom Translation

Pattern: nil Handling → Tag Unions

Clojure uses nil idiomatically. Roc requires explicit handling through tag unions.

Clojure:

(defn find-user [id]
  (when (= id 1)
    {:name "Alice" :age 30}))

(defn display-name [user]
  (if user
    (:name user)
    "Anonymous"))

;; Using some->
(def name
  (some-> (find-user 1)
          :name
          (or "Anonymous")))

Roc:

# Explicit Maybe pattern
findUser : I64 -> [Some { name : Str, age : U32 }, None]
findUser = \id ->
    if id == 1 then
        Some({ name: "Alice", age: 30 })
    else
        None

displayName : [Some { name : Str, age : U32 }, None] -> Str
displayName = \maybeUser ->
    when maybeUser is
        Some(user) -> user.name
        None -> "Anonymous"

# Using Result for error context
name =
    when findUser(1) is
        Some(user) -> user.name
        None -> "Anonymous"

Why this translation:

  • Roc eliminates null pointer errors at compile time
  • Pattern matching on tag unions is exhaustive (compiler checks all cases)
  • More explicit but prevents entire classes of runtime errors
  • [Some a, None]
    or
    Result a err
    replace nil idioms

Pattern: Keywords and Maps → Records and Tags

Clojure uses keywords and maps for both data and discriminated unions. Roc uses records for data and tags for unions.

Clojure:

;; Data with keyword keys
(def user {:name "Alice" :email "alice@example.com" :age 30})

;; Accessing fields
(:name user) ; => "Alice"
(get user :name) ; => "Alice"

;; Discriminated unions with keywords
(defn handle-message [msg]
  (case (:type msg)
    :increment (update-in model [:count] inc)
    :decrement (update-in model [:count] dec)
    :set-count (assoc model :count (:value msg))))

;; Usage
(handle-message {:type :increment})
(handle-message {:type :set-count :value 42})

Roc:

# Records for data (compile-time known fields)
user = { name: "Alice", email: "alice@example.com", age: 30 }

# Accessing fields
user.name # "Alice"

# Type alias for clarity
User : { name : Str, email : Str, age : U32 }

# Discriminated unions with tags
Message : [
    Increment,
    Decrement,
    SetCount(I64),
]

handleMessage : Message, Model -> Model
handleMessage = \msg, model ->
    when msg is
        Increment -> { model & count: model.count + 1 }
        Decrement -> { model & count: model.count - 1 }
        SetCount(value) -> { model & count: value }

# Usage
handleMessage(Increment, model)
handleMessage(SetCount(42), model)

Why this translation:

  • Records provide compile-time field checking (no typos in field names)
  • Tags are lightweight and type-safe for discriminated unions
  • Pattern matching ensures all message types are handled
  • More rigid but catches errors at compile time instead of runtime

Pattern: Dynamic Sequences → Typed Lists

Clojure's sequences are lazily evaluated and dynamically typed. Roc's lists are strictly typed.

Clojure:

;; Lazy sequence operations
(defn process-items [items]
  (->> items
       (map #(* % 2))
       (filter even?)
       (take 10)
       (reduce + 0)))

;; Infinite sequences
(def naturals (iterate inc 0))
(take 5 naturals) ; => (0 1 2 3 4)

;; Mixed type handling (not recommended but possible)
(map str [1 :two "three"]) ; => ("1" ":two" "three")

Roc:

# Strict typed list operations
processItems : List I64 -> I64
processItems = \items ->
    items
    |> List.map(\x -> x * 2)
    |> List.keepIf(\x -> x % 2 == 0)
    |> List.takeFirst(10)
    |> List.sum

# No lazy evaluation - compute eagerly
# For large data, use platform streaming

# Type safety - all elements must be same type
# This won't compile:
# mixedList = [1, "two", :three] # Error!

# Must use tag unions for heterogeneous data
Item : [Num I64, Text Str, Symbol Str]
mixedList : List Item
mixedList = [Num(1), Text("two"), Symbol("three")]

Why this translation:

  • Roc evaluates strictly, avoiding lazy evaluation pitfalls
  • Type safety prevents runtime type errors
  • Explicit tag unions for heterogeneous data
  • Performance is more predictable (no hidden thunks)

Pattern: Exception Handling → Result Type

Clojure uses exceptions for error handling (Java interop). Roc uses the Result type.

Clojure:

(defn parse-age [s]
  (try
    (let [age (Long/parseLong s)]
      (cond
        (neg? age) (throw (ex-info "Age must be non-negative" {:age age}))
        (>= age 150) (throw (ex-info "Age must be less than 150" {:age age}))
        :else age))
    (catch NumberFormatException e
      (throw (ex-info "Not a valid number" {:input s})))))

;; Calling code
(try
  (let [age (parse-age input)]
    (str "Age: " age))
  (catch Exception e
    (str "Error: " (.getMessage e))))

Roc:

# Result type for errors
parseAge : Str -> Result U32 Str
parseAge = \s ->
    when Str.toU32(s) is
        Ok(age) ->
            if age < 0 then
                Err("Age must be non-negative")
            else if age >= 150 then
                Err("Age must be less than 150")
            else
                Ok(age)
        Err(_) ->
            Err("Not a valid number")

# Calling code with pattern matching
result = parseAge(input)
message = when result is
    Ok(age) -> "Age: \(Num.toStr(age))"
    Err(error) -> "Error: \(error)"

# Chaining Results with try
validateAndDouble : Str -> Result U32 Str
validateAndDouble = \s ->
    age = try parseAge(s)
    if age > 50 then
        Ok(age * 2)
    else
        Err("Age must be greater than 50")

Why this translation:

  • Result type makes errors explicit in function signatures
  • Compiler enforces error handling (can't ignore Err case)
  • No hidden control flow (exceptions can jump anywhere)
  • try
    keyword unwraps Result or early-returns Err
  • More verbose but impossible to forget error handling

Pattern: Atoms and State → Platform State Management

Clojure uses atoms for shared mutable state. Roc pushes state to the platform layer.

Clojure:

;; Atom for application state
(def app-state (atom {:count 0 :users #{}}))

;; Pure update function
(defn increment-count [state]
  (update state :count inc))

;; Apply update
(swap! app-state increment-count)

;; Read state
@app-state

;; Watch state changes
(add-watch app-state :logger
  (fn [key atom old-state new-state]
    (println "State changed:" old-state "->" new-state)))

Roc:

# Application code is pure - no mutable state
# State is managed by the platform

# Define state type
Model : { count : I64, users : Set Str }

# Pure update functions
incrementCount : Model -> Model
incrementCount = \model ->
    { model & count: model.count + 1 }

# Platform integration (example with basic-cli)
app =
    { init: { count: 0, users: Set.empty() }
    , update: update
    , subscriptions: subscriptions
    }

update : Msg, Model -> Model
update = \msg, model ->
    when msg is
        Increment -> incrementCount(model)
        # ...other messages

# Platform handles state persistence, not application code

Why this translation:

  • Roc applications are pure functions of (state, message) → new state
  • Platform layer handles actual state mutation and effects
  • Clearer separation between pure logic and side effects
  • Makes testing trivial (just test pure functions)
  • State changes are tracked by platform, not manual watching

Paradigm Translation

Mental Model Shift: Dynamic REPL-Driven → Static Platform-Based

Clojure ApproachRoc ApproachKey Insight
REPL-driven developmentCompile-time verificationCatch errors before running
Runtime type checkingStatic type inferenceTypes are inferred, not annotated everywhere
Flexible data (maps with any keys)Structured data (records with known fields)Trade flexibility for safety
Atoms for statePlatform-managed stateSeparate pure logic from effects
Exceptions for errorsResult typeErrors are values, must be handled
Lazy sequencesStrict evaluationPredictable performance
Macros for abstractionFunctions + abilitiesLess metaprogramming, more composition

Architectural Shift: JVM Application → Platform/Application

Clojure ArchitectureRoc ArchitectureTranslation Strategy
JVM process with main functionApplication on platformMain → platform definition
Ring/HTTP serverPlatform provides HTTPUse http-server platform
File I/O anywherePlatform exposes I/O tasksCollect I/O at boundaries
Database access in functionsPlatform provides DB tasksTask-based database access
Manual dependency managementPlatform provides dependenciesPlatform includes needed capabilities

Error Handling

Clojure Error Model → Roc Error Model

Clojure's approach:

  • Exceptions (from Java) for error conditions
  • try/catch/finally
    blocks
  • Custom exception types with
    ex-info
  • Error data attached to exceptions

Roc's approach:

  • Result a err
    type for operations that can fail
  • Pattern matching on
    Ok
    and
    Err
    variants
  • try
    keyword for early returns from Result
  • Errors are just values (typically strings or custom tags)

Common Error Patterns

Clojure PatternRoc PatternExample
(throw (ex-info ...))
Err("...")
Err("Invalid input")
(try ... (catch ...))
when result is Ok(...) -> ... Err(...) -> ...
Pattern match on Result
Error propagation with
(when-let ...)
try
keyword
x = try parseNum(s)
Multiple error typesTag union for errors
[ValidationErr, ParseErr, DbErr]
Error dataRecord in Err variant
Err({ code: 404, msg: "Not found" })

Example: Error Propagation

Clojure:

(defn process-user [user-id]
  (try
    (let [user (fetch-user user-id)
          validated (validate-user user)
          updated (update-permissions validated)]
      {:success updated})
    (catch Exception e
      {:error (.getMessage e)})))

Roc:

processUser : I64 -> Result User Str
processUser = \userId ->
    user = try fetchUser(userId)
    validated = try validateUser(user)
    updated = try updatePermissions(validated)
    Ok(updated)

# The 'try' keyword automatically propagates Err
# If any step returns Err, the whole function returns that Err

Concurrency Patterns

Clojure Concurrency → Roc Platform Tasks

Clojure's approach:

  • Atoms, Refs, Agents for coordinated state
  • core.async
    for CSP-style channels
  • Java threads and futures
  • STM (Software Transactional Memory) with refs

Roc's approach:

  • Platform provides Task type for async operations
  • Tasks are descriptions of work (not running computations)
  • Platform handles scheduling and execution
  • No shared mutable state in application code

Task Model Translation

Clojure PatternRoc PatternNotes
(future ...)
Task.async ...
Async computation
@(future ...)
(deref)
Task.await task
Wait for result
(go (<! chan))
Platform-specific subscriptionsChannel → subscription
(swap! atom f)
Task.map model f
State update via task
Blocking I/O
Task.await (Http.get url)
I/O as tasks

Example: HTTP Request

Clojure:

(require '[clj-http.client :as http])

(defn fetch-data [url]
  (try
    (let [response (http/get url {:as :json})]
      (:body response))
    (catch Exception e
      (println "Error:" (.getMessage e))
      nil)))

;; Async version
(require '[clojure.core.async :refer [go <!]])

(defn fetch-data-async [url]
  (go
    (try
      (let [response (<! (http/get url {:as :json :async? true}))]
        (:body response))
      (catch Exception e
        (println "Error:" e)
        nil))))

Roc:

# Tasks are values describing work to be done
fetchData : Str -> Task (List User) [HttpErr Str]
fetchData = \url ->
    response = try Http.get(url) |> Task.mapErr(\_ -> HttpErr("Request failed"))
    bytes = response.body
    decoded = try Decode.fromBytes(bytes) |> Task.mapErr(\_ -> HttpErr("Decode failed"))
    Ok(decoded)

# Platform executes tasks, not application code
# Composing tasks
processUsers : Task (List Str) [HttpErr Str]
processUsers =
    users = try fetchData("https://api.example.com/users")
    names = List.map(users, \u -> u.name)
    Task.ok(names)

Why this translation:

  • Tasks are descriptions, not running code (referentially transparent)
  • Platform handles actual I/O execution
  • Error handling is explicit in the type signature
  • Easier to test (tasks are just data until executed by platform)

Platform Architecture

JVM Process → Platform/Application Separation

Key conceptual shift:

In Clojure, your application is a JVM process that directly performs I/O. In Roc, your application is a pure module that describes transformations, and the platform handles I/O.

Clojure:                    Roc:
┌─────────────────┐         ┌─────────────────┐
│   Application   │         │   Application   │
│   (impure)      │         │   (pure Roc)    │
│  ┌───────────┐  │         │                 │
│  │ Database  │  │         │  Pure functions │
│  │ HTTP      │  │         │  Type defs      │
│  │ File I/O  │  │         │  Logic only     │
│  └───────────┘  │         └────────┬────────┘
└─────────────────┘                  │ Pure interface
      JVM                            ▼
                              ┌─────────────────┐
                              │    Platform     │
                              │  (Roc + host)   │
                              │  ┌───────────┐  │
                              │  │ Database  │  │
                              │  │ HTTP      │  │
                              │  │ File I/O  │  │
                              │  └───────────┘  │
                              └─────────────────┘

Platform Selection

When converting a Clojure application, choose the appropriate Roc platform:

Clojure Application TypeRoc PlatformNotes
CLI tool
basic-cli
Task-based CLI apps
Web server
basic-webserver
HTTP server apps
Script
basic-cli
File processing, automation
LibraryNo platformPure Roc module, consumed by platform apps

Common Pitfalls

  1. Trying to use nil directly: Roc has no null. Use tag unions like

    [Some a, None]
    or
    Result a err
    .

    • Bad: Expecting nil to work as in Clojure
    • Good:
      when maybeValue is Some(v) -> ... None -> ...
  2. Using maps for everything: In Roc, use records when fields are known at compile time.

    • Bad:
      { "dynamicKey": value }
      (dynamic string keys)
    • Good:
      { knownField: value }
      (compile-time known) or
      Dict.fromList([("key", value)])
      for truly dynamic keys
  3. Forgetting to handle errors: Result forces you to handle both Ok and Err.

    • Bad: Only pattern matching on Ok case
    • Good:
      when result is Ok(v) -> ... Err(e) -> ...
      (exhaustive)
  4. Mixing pure logic with effects: Keep application code pure.

    • Bad: Calling I/O functions directly in application logic
    • Good: Return Task values, let platform execute them
  5. Over-using type annotations: Roc infers most types.

    • Bad: Annotating every single function when types are obvious
    • Good: Annotate public API boundaries and complex functions only
  6. Expecting lazy evaluation: Roc evaluates strictly.

    • Bad: Assuming
      List.map
      won't compute until needed
    • Good: Use platform streaming for large data
  7. Not leveraging pattern matching: Use

    when
    exhaustively instead of
    if/else
    chains.

    • Bad:
      if x == A then ... else if x == B then ...
    • Good:
      when x is A -> ... B -> ... C -> ...
  8. Ignoring numeric overflow: Roc has explicit overflow behavior.

    • Bad: Assuming automatic BigInt promotion like Clojure
    • Good: Choose appropriate integer size (I64, I32) and handle overflow explicitly

Limitations

Coverage Gaps

Pillarlang-clojure-devlang-roc-devMitigation
ModuleBoth skills have good coverage
Error~Clojure uses exceptions contextually; Roc has dedicated section
Concurrency~Clojure has state mgmt; Roc has tasks - this skill bridges gap
MetaprogrammingBoth covered (macros vs abilities)
Zero/Default~~Both mention in context; this skill provides explicit nil → Maybe translation
SerializationClojure covered; reference
patterns-serialization-dev
for Roc
BuildClojure covered; consult Roc platform documentation
TestingBoth covered
REPL/Workflow~Clojure REPL-centric; Roc compile-time focused

Combined Score: 14/17 (Good with noted gaps)

Gaps:

  • Roc serialization patterns not fully covered in lang-roc-dev
  • Roc build tooling not covered in lang-roc-dev
  • REPL workflow differences require paradigm shift

Mitigation:

  • Reference
    patterns-serialization-dev
    for encoding/decoding patterns
  • Consult official Roc platform documentation for build process
  • This skill documents REPL → compile-time workflow shift

Known Limitations

  1. Serialization: This skill has limited guidance on Roc's serialization patterns (Encode/Decode) because lang-roc-dev lacks comprehensive serialization coverage. For production serialization, consult

    patterns-serialization-dev
    and Roc platform documentation.

  2. Build tooling: Conversion patterns for build scripts (Leiningen/deps.edn → Roc platform config) may be incomplete. Refer to specific platform documentation.

  3. Advanced macros: Some complex Clojure macros may not have direct Roc equivalents. Consider whether the macro is solving a problem that Roc's type system already addresses.

External Resources Used

ResourceWhat It ProvidedReliability
lang-clojure-devClojure patterns (REPL, macros, sequences)High (internal skill)
lang-roc-devRoc patterns (records, tags, platform model)High (internal skill)
meta-convert-devAPTV workflow and general conversion methodologyHigh (internal skill)
Roc tutorialPlatform/application architectureHigh (official)

Tooling

ToolPurposeNotes
Roc compilerType checking and compilationProvides detailed error messages
roc check
Type check without buildingFast feedback loop
roc test
Run expect testsInline testing with
expect
roc repl
Interactive explorationLimited compared to Clojure REPL
roc format
Code formattingStandard formatter
roc docs
Generate documentationFrom type signatures

No direct Clojure → Roc transpiler exists. Conversion is manual but type-guided.


Examples

Example 1: Simple - Function with Optional Return

Before (Clojure):

(defn find-first-even [numbers]
  (first (filter even? numbers)))

;; Usage
(find-first-even [1 3 5 6 7]) ; => 6
(find-first-even [1 3 5])     ; => nil

After (Roc):

# Explicit Maybe return type
findFirstEven : List I64 -> [Some I64, None]
findFirstEven = \numbers ->
    when List.findFirst(numbers, \n -> n % 2 == 0) is
        Ok(n) -> Some(n)
        Err(_) -> None

# Usage
result1 = findFirstEven([1, 3, 5, 6, 7]) # Some(6)
result2 = findFirstEven([1, 3, 5])       # None

# Pattern match on result
message = when result1 is
    Some(n) -> "Found: \(Num.toStr(n))"
    None -> "No even number found"

Key changes:

  • nil → explicit
    [Some a, None]
    tag union
  • Return type documents possibility of no result
  • Compiler enforces handling both cases

Example 2: Medium - Error Handling and Validation

Before (Clojure):

(require '[clojure.spec.alpha :as s])

(s/def ::email (s/and string? #(re-matches #".+@.+\..+" %)))
(s/def ::age (s/and int? #(< 0 % 150)))
(s/def ::user (s/keys :req-un [::email ::age]))

(defn create-user [email age]
  (let [user {:email email :age age}]
    (if (s/valid? ::user user)
      {:ok user}
      {:error (s/explain-str ::user user)})))

;; Usage
(create-user "alice@example.com" 30)
;; => {:ok {:email "alice@example.com", :age 30}}

(create-user "invalid" 200)
;; => {:error "Spec validation failed..."}

After (Roc):

# Type-safe user record
User : { email : Str, age : U32 }

# Validation errors as tag union
ValidationErr : [InvalidEmail, AgeOutOfRange]

# Validation functions
validateEmail : Str -> Result Str ValidationErr
validateEmail = \email ->
    if Str.contains(email, "@") && Str.contains(email, ".") then
        Ok(email)
    else
        Err(InvalidEmail)

validateAge : U32 -> Result U32 ValidationErr
validateAge = \age ->
    if age > 0 && age < 150 then
        Ok(age)
    else
        Err(AgeOutOfRange)

# Create user with validation
createUser : Str, U32 -> Result User ValidationErr
createUser = \email, age ->
    validEmail = try validateEmail(email)
    validAge = try validateAge(age)
    Ok({ email: validEmail, age: validAge })

# Usage
result1 = createUser("alice@example.com", 30)
# Ok({ email: "alice@example.com", age: 30 })

result2 = createUser("invalid", 200)
# Err(InvalidEmail)

# Pattern match on result
message = when result1 is
    Ok(user) -> "Created user: \(user.email)"
    Err(InvalidEmail) -> "Invalid email format"
    Err(AgeOutOfRange) -> "Age must be between 0 and 150"

Key changes:

  • Runtime spec validation → compile-time types + Result validation
  • Error strings → typed error variants
  • try
    keyword chains validations, short-circuits on first error
  • Pattern matching provides exhaustive error handling

Example 3: Complex - State Management and Updates

Before (Clojure):

(defrecord TodoItem [id text completed])

(def app-state
  (atom {:todos []
         :next-id 0
         :filter :all}))

(defn add-todo [state text]
  (let [id (:next-id state)
        todo (->TodoItem id text false)]
    (-> state
        (update :todos conj todo)
        (update :next-id inc))))

(defn toggle-todo [state id]
  (update state :todos
    (fn [todos]
      (mapv #(if (= (:id %) id)
              (update % :completed not)
              %)
            todos))))

(defn set-filter [state filter]
  (assoc state :filter filter))

(defn visible-todos [state]
  (let [todos (:todos state)
        filter (:filter state)]
    (case filter
      :all todos
      :active (filterv (complement :completed) todos)
      :completed (filterv :completed todos))))

;; Usage with atom
(swap! app-state add-todo "Learn Roc")
(swap! app-state toggle-todo 0)
(swap! app-state set-filter :completed)
(visible-todos @app-state)

After (Roc):

# Types
TodoItem : { id : U64, text : Str, completed : Bool }

Filter : [All, Active, Completed]

Model : {
    todos : List TodoItem,
    nextId : U64,
    filter : Filter,
}

# Messages for updates
Msg : [
    AddTodo Str,
    ToggleTodo U64,
    SetFilter Filter,
]

# Pure update functions
addTodo : Model, Str -> Model
addTodo = \model, text ->
    newTodo = { id: model.nextId, text: text, completed: Bool.false }
    { model &
        todos: List.append(model.todos, newTodo),
        nextId: model.nextId + 1,
    }

toggleTodo : Model, U64 -> Model
toggleTodo = \model, id ->
    updatedTodos = List.map(model.todos, \todo ->
        if todo.id == id then
            { todo & completed: !todo.completed }
        else
            todo
    )
    { model & todos: updatedTodos }

setFilter : Model, Filter -> Model
setFilter = \model, filter ->
    { model & filter: filter }

# Query function
visibleTodos : Model -> List TodoItem
visibleTodos = \model ->
    when model.filter is
        All -> model.todos
        Active -> List.keepIf(model.todos, \t -> !t.completed)
        Completed -> List.keepIf(model.todos, \t -> t.completed)

# Main update dispatcher
update : Msg, Model -> Model
update = \msg, model ->
    when msg is
        AddTodo(text) -> addTodo(model, text)
        ToggleTodo(id) -> toggleTodo(model, id)
        SetFilter(filter) -> setFilter(model, filter)

# Initial model
init : Model
init = {
    todos: [],
    nextId: 0,
    filter: All,
}

# Usage (in platform context, not shown)
# model1 = update(AddTodo("Learn Roc"), init)
# model2 = update(ToggleTodo(0), model1)
# model3 = update(SetFilter(Completed), model2)
# visible = visibleTodos(model3)

Key changes:

  • Atom with mutable state → immutable Model type
  • Keywords for actions → typed Msg tag union
  • swap!
    updates → pure
    update
    function
  • State managed by platform, not application
  • All updates are pure functions: (Msg, Model) → Model
  • Easier to test (no atoms, just pure functions)
  • Type system prevents invalid messages or state shapes

See Also

For more examples and patterns, see:

  • meta-convert-dev
    - Foundational patterns with cross-language examples
  • lang-clojure-dev
    - Clojure development patterns (REPL, macros, sequences)
  • lang-roc-dev
    - Roc development patterns (platform model, records, abilities)
  • convert-elm-roc
    - Similar functional to functional conversion (Elm → Roc)
  • convert-haskell-roc
    - Another ML-family language conversion

Cross-cutting pattern skills (for areas not fully covered by lang-*-dev):

  • patterns-serialization-dev
    - Encode/Decode patterns across languages
  • patterns-concurrency-dev
    - Async, tasks, channels across languages