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
| Clojure | Roc | Notes |
|---------|-----|-------|
| (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 variant | No 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 state | Atoms → 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
- Analyze source thoroughly before writing target - understand dynamic behavior
- Map types first - convert dynamic runtime checks to static types
- Preserve semantics over syntax similarity - embrace Roc's type system
- Adopt target idioms - don't write "Clojure code in Roc syntax"
- Handle edge cases - nil → explicit Maybe, exceptions → Result
- Leverage platform model - separate pure logic from I/O
- Test equivalence - same inputs → same outputs
- Add compile-time validation - use Roc's type system to replace runtime checks
Type System Mapping
Primitive Types
| Clojure | Roc | Notes |
|---------|-----|-------|
| 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
| Clojure | Roc | Notes |
|---------|-----|-------|
| [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
| Clojure | Roc | Notes |
|---------|-----|-------|
| {: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
| Clojure | Roc | Notes |
|---------|-----|-------|
| (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]orResult a errreplace 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)
trykeyword 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 Approach | Roc Approach | Key Insight | |------------------|--------------|-------------| | REPL-driven development | Compile-time verification | Catch errors before running | | Runtime type checking | Static type inference | Types are inferred, not annotated everywhere | | Flexible data (maps with any keys) | Structured data (records with known fields) | Trade flexibility for safety | | Atoms for state | Platform-managed state | Separate pure logic from effects | | Exceptions for errors | Result type | Errors are values, must be handled | | Lazy sequences | Strict evaluation | Predictable performance | | Macros for abstraction | Functions + abilities | Less metaprogramming, more composition |
Architectural Shift: JVM Application → Platform/Application
| Clojure Architecture | Roc Architecture | Translation Strategy | |---------------------|------------------|---------------------| | JVM process with main function | Application on platform | Main → platform definition | | Ring/HTTP server | Platform provides HTTP | Use http-server platform | | File I/O anywhere | Platform exposes I/O tasks | Collect I/O at boundaries | | Database access in functions | Platform provides DB tasks | Task-based database access | | Manual dependency management | Platform provides dependencies | Platform includes needed capabilities |
Error Handling
Clojure Error Model → Roc Error Model
Clojure's approach:
- Exceptions (from Java) for error conditions
try/catch/finallyblocks- Custom exception types with
ex-info - Error data attached to exceptions
Roc's approach:
Result a errtype for operations that can fail- Pattern matching on
OkandErrvariants trykeyword for early returns from Result- Errors are just values (typically strings or custom tags)
Common Error Patterns
| Clojure Pattern | Roc Pattern | Example |
|----------------|-------------|---------|
| (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 types | Tag union for errors | [ValidationErr, ParseErr, DbErr] |
| Error data | Record 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.asyncfor 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 Pattern | Roc Pattern | Notes |
|----------------|-------------|-------|
| (future ...) | Task.async ... | Async computation |
| @(future ...) (deref) | Task.await task | Wait for result |
| (go (<! chan)) | Platform-specific subscriptions | Channel → 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 Type | Roc Platform | Notes |
|--------------------------|--------------|-------|
| CLI tool | basic-cli | Task-based CLI apps |
| Web server | basic-webserver | HTTP server apps |
| Script | basic-cli | File processing, automation |
| Library | No platform | Pure Roc module, consumed by platform apps |
Common Pitfalls
-
Trying to use nil directly: Roc has no null. Use tag unions like
[Some a, None]orResult a err.- Bad: Expecting nil to work as in Clojure
- Good:
when maybeValue is Some(v) -> ... None -> ...
-
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) orDict.fromList([("key", value)])for truly dynamic keys
- Bad:
-
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)
-
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
-
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
-
Expecting lazy evaluation: Roc evaluates strictly.
- Bad: Assuming
List.mapwon't compute until needed - Good: Use platform streaming for large data
- Bad: Assuming
-
Not leveraging pattern matching: Use
whenexhaustively instead ofif/elsechains.- Bad:
if x == A then ... else if x == B then ... - Good:
when x is A -> ... B -> ... C -> ...
- Bad:
-
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
| Pillar | lang-clojure-dev | lang-roc-dev | Mitigation |
|--------|------------------|--------------|------------|
| Module | ✓ | ✓ | Both 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 |
| Metaprogramming | ✓ | ✓ | Both covered (macros vs abilities) |
| Zero/Default | ~ | ~ | Both mention in context; this skill provides explicit nil → Maybe translation |
| Serialization | ✓ | ✗ | Clojure covered; reference patterns-serialization-dev for Roc |
| Build | ✓ | ✗ | Clojure covered; consult Roc platform documentation |
| Testing | ✓ | ✓ | Both 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-devfor encoding/decoding patterns - Consult official Roc platform documentation for build process
- This skill documents REPL → compile-time workflow shift
Known Limitations
-
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-devand Roc platform documentation. -
Build tooling: Conversion patterns for build scripts (Leiningen/deps.edn → Roc platform config) may be incomplete. Refer to specific platform documentation.
-
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
| Resource | What It Provided | Reliability | |----------|------------------|-------------| | lang-clojure-dev | Clojure patterns (REPL, macros, sequences) | High (internal skill) | | lang-roc-dev | Roc patterns (records, tags, platform model) | High (internal skill) | | meta-convert-dev | APTV workflow and general conversion methodology | High (internal skill) | | Roc tutorial | Platform/application architecture | High (official) |
Tooling
| Tool | Purpose | Notes |
|------|---------|-------|
| Roc compiler | Type checking and compilation | Provides detailed error messages |
| roc check | Type check without building | Fast feedback loop |
| roc test | Run expect tests | Inline testing with expect |
| roc repl | Interactive exploration | Limited compared to Clojure REPL |
| roc format | Code formatting | Standard formatter |
| roc docs | Generate documentation | From 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
trykeyword 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 → pureupdatefunction- 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 exampleslang-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 languagespatterns-concurrency-dev- Async, tasks, channels across languages