| name | convert-clojure-fsharp |
| description | Convert Clojure code to idiomatic F#. Use when migrating Clojure projects to F#, translating Clojure patterns to idiomatic F#, or refactoring Clojure codebases to the .NET platform. Extends meta-convert-dev with Clojure-to-F# specific patterns. |
Convert Clojure to F#
Convert Clojure code to idiomatic F#. This skill extends meta-convert-dev with Clojure-to-F# specific type mappings, idiom translations, and tooling for converting functional code from JVM/Lisp to .NET/ML platforms.
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 dynamic types → F# static types with type inference
- Idiom translations: Clojure Lisp-style patterns → idiomatic F# ML-style
- Error handling: Clojure exception model → F# Result type and railway-oriented programming
- Async patterns: Clojure core.async and futures → F# async workflows and tasks
- Platform translation: JVM ecosystem → .NET CLR ecosystem
- REPL workflow: Clojure REPL-driven development → F# FSI and interactive development
This Skill Does NOT Cover
- General conversion methodology - see
meta-convert-dev - Clojure language fundamentals - see
lang-clojure-dev - F# language fundamentals - see
lang-fsharp-dev - Reverse conversion (F# → Clojure) - see
convert-fsharp-clojure
Quick Reference
| Clojure | F# | Notes |
|---|---|---|
String |
string |
Direct mapping (both use platform strings) |
Long |
int64 / int |
Clojure integers are longs; F# defaults to int32 |
Double |
float |
Clojure floats are doubles; F# float is double |
Boolean |
bool |
true/false in both |
[...] vector |
list<'T> |
Clojure vector → F# list (immutable) |
| lazy seq | seq<'T> |
Both are lazy, composable sequences |
| Java array | 'T[] array |
Use F# arrays for mutable indexed access |
{...} map |
Map<'K,'V> |
Clojure hash-map → F# immutable Map |
#{...} set |
Set<'T> |
Clojure hash-set → F# immutable Set |
nil |
None |
Clojure nil → F# Option type |
{:ok ...} / {:error ...} |
Result<'T,'E> |
Convention-based → type-safe discriminated union |
defrecord / map |
Record type | Tagged map → strongly-typed record |
Tagged map with :type |
Discriminated union | Runtime dispatch → compile-time variant |
future |
async { } |
JVM futures → F# async workflows |
fn or defn |
fun or let |
Lambda/function definition |
Thread-last ->> |
Pipe |> |
Data threading |
When Converting Code
- Analyze source thoroughly before writing target
- Map types first - plan dynamic → static type strategy
- Preserve semantics over syntax similarity
- Adopt F# idioms - don't write "Clojure code in F# syntax"
- Handle edge cases - nil-safety, error paths, lazy evaluation differences
- Test equivalence - same inputs → same outputs
- Embrace static typing - use F#'s type system to catch errors at compile time
- Leverage type inference - F# can infer most types, annotations optional
Type System Mapping
Primitive Types
| Clojure | F# | Notes |
|---|---|---|
Boolean |
bool |
true/false (same in both) |
Byte |
byte |
8-bit unsigned (F# has sbyte for signed) |
Short |
int16 |
16-bit signed |
Integer |
int / int32 |
32-bit signed (F# default int) |
Long |
int64 |
64-bit signed (Clojure default integer type) |
Float |
single / float32 |
32-bit floating point |
Double |
double / float |
64-bit floating point (F# default float) |
BigInteger |
bigint |
Arbitrary precision integers |
BigDecimal |
decimal |
Arbitrary precision decimals |
Character |
char |
UTF-16 code unit |
String |
string |
Immutable strings (both platforms) |
nil |
- | Use Option<'T> (None for nil, Some for value) |
Collection Types
| Clojure | F# | Notes |
|---|---|---|
[...] vector |
list<'T> |
Persistent vector → immutable list |
'(...) list |
list<'T> |
Clojure list → F# list (both linked lists) |
| lazy seq | seq<'T> |
Lazy sequences in both |
| Java array / vector | 'T[] |
Use F# arrays for mutable indexed access |
{...} hash-map |
Map<'K,'V> |
Persistent map → immutable Map |
#{...} hash-set |
Set<'T> |
Persistent set → immutable Set |
(atom [...]) |
'T ref / mutable |
Atom-wrapped vector → mutable reference |
nil or value |
Option<'T> |
None/Some wrapping |
{:ok v} / {:error e} |
Result<'T,'E> |
Convention → discriminated union |
[a b] tuple |
'A * 'B |
Clojure vector → F# tuple |
Composite Types
| Clojure | F# | Notes |
|---|---|---|
defrecord |
Record type | When protocols/polymorphism needed |
Plain map {...} |
Record type | Data structure → strongly-typed record |
Tagged map {:type :variant ...} |
Discriminated union | Runtime tag → compile-time variant |
| Protocol | Interface / Abstract class | Behavior contracts |
defmulti/defmethod |
Discriminated union + pattern match | Dynamic dispatch → static dispatch |
Map with :type key |
Single-case union | Type safety wrapper |
| Nested maps | Nested record types | Structure becomes explicit |
Function Types
| Clojure | F# | Notes |
|---|---|---|
(fn [a] b) |
'a -> 'b |
Single-argument function |
(fn [a b] c) |
'a -> 'b -> 'c |
Multi-argument → curried |
(fn [] a) |
unit -> 'a |
Nullary function/thunk |
Multi-arity defn |
Multiple let bindings / overloads |
Arity dispatch → separate functions |
Variadic & args |
params 'a[] or list |
Rest args → array or list parameter |
| Generic (no types) | Generic 'a |
Dynamic → parameterized types |
| Runtime type check | Type constraint | 'a when 'a : IComparable |
Idiom Translation
Pattern 1: Nil Handling to Option Type
Clojure:
;; User as map
(def user {:name "Alice" :email "alice@example.com"})
(defn get-email-domain [user]
(if-let [email (:email user)]
(second (clojure.string/split email #"@"))
"no-domain"))
;; Using some-> threading (stops on nil)
(some-> user :email (clojure.string/split #"@") second)
F#:
// User as record
type User = { Name: string; Email: string option }
let getEmailDomain user =
user.Email
|> Option.map (fun email -> email.Split('@').[1])
|> Option.defaultValue "no-domain"
// Pattern matching
let getEmailDomain' user =
match user.Email with
| Some email -> email.Split('@').[1]
| None -> "no-domain"
Why this translation:
- Clojure
nil→ F#None(explicit absence) - Clojure
if-let/when-let→ F#Option.map/Option.bind - Clojure
some->threading → F# Option combinators - F# makes nullability explicit in the type system
- Pattern matching provides exhaustive checking
Pattern 2: Exception-Based to Result Type Error Handling
Clojure:
;; Exception-based (idiomatic Clojure)
(defn divide [x y]
(when (zero? y)
(throw (ex-info "Division by zero" {:x x :y y})))
(/ x y))
(defn compute [a b c]
(try
(* (/ (/ a b) c) 2)
(catch Exception e
{:error (.getMessage e)})))
;; Or convention-based error handling
(defn divide-safe [x y]
(if (zero? y)
{:error "Division by zero"}
{:ok (/ x y)}))
F#:
// Result type (idiomatic F#)
let divide x y =
if y = 0 then
Error "Division by zero"
else
Ok (x / y)
// Railway-oriented programming
let compute a b c =
result {
let! step1 = divide a b
let! step2 = divide step1 c
return step2 * 2
}
// Or using Result.bind
let compute' a b c =
divide a b
|> Result.bind (fun x -> divide x c)
|> Result.map (fun x -> x * 2)
Why this translation:
- Clojure exceptions → F# Result type (errors as values)
- Clojure
{:ok/:error}conventions → F# discriminated unions - Explicit error handling at compile time
- Railway-oriented programming for chaining fallible operations
- Type safety prevents forgetting error cases
Pattern 3: Vector Processing with Threading Macros to Pipe Operator
Clojure:
(defn process-items [items]
(->> items
(filter :is-active)
(map :value)
(reduce +)))
;; Or using tranducers
(defn process-items-xf [items]
(transduce
(comp (filter :is-active)
(map :value))
+
items))
F#:
// Using pipe operator
let processItems items =
items
|> List.filter (fun x -> x.IsActive)
|> List.map (fun x -> x.Value)
|> List.sum
// More concise with accessor functions
let processItems' items =
items
|> List.filter _.IsActive
|> List.map _.Value
|> List.sum
// Lazy evaluation with sequences
let processItemsLazy items =
items
|> Seq.filter (fun x -> x.IsActive)
|> Seq.map (fun x -> x.Value)
|> Seq.sum
Why this translation:
- Clojure
->>(thread-last) → F#|>(pipe forward) - Clojure
filter/map/reduce→ F#List.filter/List.map/List.sum - Both support lazy evaluation (seqs in Clojure,
Seqin F#) - F# pipe operator is data-first (same as thread-last)
- Tranducers → F# doesn't have direct equivalent, use sequences
Pattern 4: Tagged Maps to Discriminated Unions
Clojure:
;; Constructor functions
(defn circle [radius]
{:type :circle :radius radius})
(defn rectangle [width height]
{:type :rectangle :width width :height height})
(defn triangle [base height]
{:type :triangle :base base :height height})
;; Using multimethods for dispatch
(defmulti area :type)
(defmethod area :circle [{:keys [radius]}]
(* Math/PI radius radius))
(defmethod area :rectangle [{:keys [width height]}]
(* width height))
(defmethod area :triangle [{:keys [base height]}]
(* 0.5 base height))
;; Usage
(area (circle 5.0)) ;; => 78.53981633974483
(area (rectangle 4 5)) ;; => 20
F#:
// Discriminated union
type Shape =
| Circle of radius: float
| Rectangle of width: float * height: float
| Triangle of baseLen: float * height: float
// Pattern matching for dispatch
let area shape =
match shape with
| Circle r -> System.Math.PI * r * r
| Rectangle (w, h) -> w * h
| Triangle (b, h) -> 0.5 * b * h
// Usage
let shapes = [
Circle 5.0
Rectangle (4.0, 5.0)
Triangle (6.0, 8.0)
]
shapes |> List.map area
// [78.54; 20.0; 24.0]
Why this translation:
- Clojure tagged maps → F# discriminated unions
- Runtime
:typetag → compile-time variant type defmulti/defmethod→ pattern matching- Exhaustive pattern matching ensures all cases handled
- Type safety prevents typos in variant names
Pattern 5: Immutable Data Updates
Clojure:
;; Plain map (most common)
(def person {:first-name "Alice" :last-name "Smith" :age 30})
(def older-person (assoc person :age 31))
;; Or using update
(def older-person (update person :age inc))
;; Nested updates
(def user {:profile {:name "Alice" :email "alice@example.com"}})
(def updated-user (assoc-in user [:profile :email] "newemail@example.com"))
(def incremented-user (update-in user [:profile :age] inc))
F#:
// Record type
type Person = {
FirstName: string
LastName: string
Age: int
}
let person = { FirstName = "Alice"; LastName = "Smith"; Age = 30 }
let olderPerson = { person with Age = 31 }
// Nested records
type Profile = { Name: string; Email: string }
type User = { Profile: Profile }
let user = { Profile = { Name = "Alice"; Email = "alice@example.com" } }
let updatedUser = { user with Profile = { user.Profile with Email = "newemail@example.com" } }
// Helper functions for complex updates
let updateEmail newEmail user =
{ user with Profile = { user.Profile with Email = newEmail } }
let updatedUser' = user |> updateEmail "newemail@example.com"
Why this translation:
- Clojure
assoc→ F# copy-and-update{ r with ... } - Clojure
update→ F# update with function - Clojure
assoc-in/update-in→ F# nested copy-and-update or helper functions - Both are immutable by default
- F# records have named fields vs. Clojure keyword keys
Pattern 6: Lazy Sequences
Clojure:
;; Infinite sequence
(def naturals (iterate inc 0))
(take 5 naturals) ;; => (0 1 2 3 4)
;; Lazy evaluation
(def evens (filter even? naturals))
(take 3 evens) ;; => (0 2 4)
;; List comprehension
(def squares (for [x (range 10)] (* x x)))
;; Realized only when consumed
(take 5 squares) ;; => (0 1 4 9 16)
F#:
// Infinite sequence
let naturals = Seq.initInfinite id
Seq.take 5 naturals |> Seq.toList
// [0; 1; 2; 3; 4]
// Lazy evaluation
let evens = Seq.filter (fun x -> x % 2 = 0) naturals
Seq.take 3 evens |> Seq.toList
// [0; 2; 4]
// Sequence expression (lazy)
let squares = seq { for x in 0..9 -> x * x }
// Realized only when enumerated
Seq.take 5 squares |> Seq.toList
// [0; 1; 4; 9; 16]
// Infinite Fibonacci
let fibonacci =
Seq.unfold (fun (a, b) -> Some(a, (b, a + b))) (0, 1)
Seq.take 10 fibonacci |> Seq.toList
// [0; 1; 1; 2; 3; 5; 8; 13; 21; 34]
Why this translation:
- Clojure lazy seqs → F#
seq<'T>(IEnumerable) iterate→Seq.initInfinite/Seq.unfoldforcomprehension → F#seq { }expression- Both evaluate lazily on demand
- Both support infinite sequences safely
Pattern 7: Async and Concurrency
Clojure:
;; Using futures (simple parallelism)
(defn fetch-user [user-id]
(Thread/sleep 100)
{:id user-id :name (str "User" user-id)})
(defn process-users [user-ids]
(let [futures (map #(future (fetch-user %)) user-ids)
users (map deref futures)]
(reduce + (map :id users))))
;; Using core.async (CSP-style)
(require '[clojure.core.async :as async :refer [go <! >!]])
(defn fetch-user-async [user-id]
(go
(<! (async/timeout 100))
{:id user-id :name (str "User" user-id)}))
(defn process-users-async [user-ids]
(go
(let [channels (map fetch-user-async user-ids)
users (<! (async/merge channels))]
(reduce + (map :id users)))))
F#:
// Async workflows
let fetchUser userId = async {
do! Async.Sleep 100
return { Id = userId; Name = $"User{userId}" }
}
let processUsers userIds = async {
let! users =
userIds
|> List.map fetchUser
|> Async.Parallel
return users |> Array.sumBy (fun u -> u.Id)
}
// Run async
processUsers [1; 2; 3; 4; 5]
|> Async.RunSynchronously
// Returns: 15
// Task-based (more .NET-idiomatic)
let fetchUserTask userId = task {
do! Task.Delay 100
return { Id = userId; Name = $"User{userId}" }
}
let processUsersTask userIds = task {
let! users =
userIds
|> List.map fetchUserTask
|> Task.WhenAll
return users |> Array.sumBy (fun u -> u.Id)
}
Why this translation:
- Clojure
future→ F#async { }workflows ortask { } - Clojure
deref(@) → F#Async.RunSynchronouslyorawait - Clojure core.async channels → F# MailboxProcessor or async workflows
- F#
Async.Parallelfor parallel execution - F# has both async (F# workflows) and Task (.NET tasks)
Pattern 8: Macros to Computation Expressions
Clojure:
;; Macros for DSL creation
(defmacro when-let [bindings & body]
`(let ~bindings
(when ~(first bindings)
~@body)))
;; Threading macros (built-in)
(-> x f g h)
(->> coll (map f) (filter pred))
;; Custom control flow
(defmacro unless [condition & body]
`(if (not ~condition)
(do ~@body)))
F#:
// Computation expressions (similar to macros for DSL)
type MaybeBuilder() =
member _.Bind(x, f) = Option.bind f x
member _.Return(x) = Some x
member _.ReturnFrom(x) = x
let maybe = MaybeBuilder()
let validateAge age = maybe {
let! validAge =
if age >= 0 && age <= 120 then Some age
else None
return validAge
}
// Result computation expression
type ResultBuilder() =
member _.Bind(x, f) = Result.bind f x
member _.Return(x) = Ok x
member _.ReturnFrom(x) = x
let result = ResultBuilder()
let divideBy x y = maybe {
let! result =
if y <> 0 then Some (x / y)
else None
return result
}
// Pipe operators (built-in, not macros)
let result =
x
|> f
|> g
|> h
Why this translation:
- Clojure macros → F# computation expressions (for DSLs)
- Thread macros → F# pipe operators (built-in, not meta-programming)
- Clojure compile-time code generation → F# computation builders
- F# computation expressions are more constrained but type-safe
- F# favors built-in language features over custom syntax
Paradigm Translation
Mental Model Shift: Dynamic Lisp → Static ML
| Clojure Concept | F# Approach | Key Insight |
|---|---|---|
| Dynamic typing | Static with inference | Types inferred at compile time |
| Data-driven design | Type-driven design | Types guide design and prevent errors |
| Maps with keyword keys | Records with named fields | Structure defined by types vs. convention |
defmulti/defmethod |
Discriminated unions + pattern matching | Dynamic dispatch → static exhaustive matching |
| S-expressions | ML syntax | Prefix notation → infix/pipeline notation |
| REPL-first | Type-first with FSI | Interactive but type-guided development |
| Macros for DSL | Computation expressions | Compile-time code gen → type-safe builders |
| Lazy by default (seqs) | Explicit lazy (seq) | Lazy sequences explicit in F# |
Concurrency Mental Model
| Clojure Model | F# Model | Conceptual Translation |
|---|---|---|
future |
async { } |
JVM future → F# async workflow |
pmap |
Async.Parallel / Array.Parallel.map |
Parallel map → parallel execution |
@future |
Async.RunSynchronously |
Dereference → blocking wait |
| Agent | MailboxProcessor | Agent-based → message-passing actor |
| core.async channels | MailboxProcessor / async channels | CSP channels → F# mailbox or async |
| Atoms/Refs | ref<'T> / mutable |
Managed state → mutable references |
Error Handling
Clojure Error Model → F# Error Model
Clojure primarily uses exceptions with some convention-based error handling. F# strongly favors Result types and railway-oriented programming.
Clojure Exception Pattern:
(defn parse-age [input]
(try
(let [age (Integer/parseInt input)]
(if (>= age 0)
age
(throw (ex-info "Age cannot be negative" {:input input}))))
(catch NumberFormatException e
(throw (ex-info "Invalid number" {:input input} e)))))
;; Or return error map
(defn parse-age-safe [input]
(try
(let [age (Integer/parseInt input)]
(if (>= age 0)
{:ok age}
{:error "Age cannot be negative"}))
(catch NumberFormatException e
{:error "Invalid number"})))
F# Result Pattern (Idiomatic):
// Result type (built-in discriminated union)
type ParseError =
| InvalidNumber of string
| NegativeAge of string
let parseAge input =
match System.Int32.TryParse(input) with
| false, _ -> Error (InvalidNumber input)
| true, age when age < 0 -> Error (NegativeAge input)
| true, age -> Ok age
// Railway-oriented programming
let validateAndProcess input =
result {
let! age = parseAge input
let! category =
if age < 18 then Ok "minor"
elif age < 65 then Ok "adult"
else Ok "senior"
return (age, category)
}
Error Propagation:
| Clojure | F# | Notes |
|---|---|---|
try/catch |
Result.bind |
Exception propagation → explicit Result chaining |
Manual if checks on {:ok/:error} |
Pattern matching on Result | Convention → type-safe |
| Nested try/catch | Computation expression | Imperative → declarative |
ex-info with data |
Custom error types | Exception with map → discriminated union |
| Throw/catch | Result/Option | Exceptional control flow → values |
F# Option vs Result:
// Use Option for absence vs. presence
let findUser id =
if id = 1 then Some { Id = 1; Name = "Alice" }
else None
// Use Result for success vs. failure with error info
let validateEmail email =
if email.Contains("@") then Ok email
else Error "Invalid email format"
// Combining both
let getUser id =
match findUser id with
| None -> Error "User not found"
| Some user -> Ok user
Concurrency Patterns
Clojure Async → F# Async
Simple async operation:
;; Clojure with future
(defn fetch-data [url]
(future
(slurp url)))
;; Clojure with core.async
(require '[clojure.core.async :as async :refer [go <!]])
(defn fetch-data-async [url]
(go
(:body (http/get url))))
// F# async workflow
let fetchData url = async {
use client = new System.Net.Http.HttpClient()
let! response = client.GetStringAsync(url) |> Async.AwaitTask
return response
}
// F# task (more .NET-idiomatic)
let fetchDataTask url = task {
use client = new System.Net.Http.HttpClient()
let! response = client.GetStringAsync(url)
return response
}
Parallel execution:
;; Clojure with pmap (parallel map)
(defn fetch-all [urls]
(pmap fetch-data urls))
;; Clojure with futures
(defn fetch-all-futures [urls]
(let [futures (map #(future (fetch-data %)) urls)]
(map deref futures)))
// F# Async.Parallel
let fetchAll urls = async {
let! results =
urls
|> List.map fetchData
|> Async.Parallel
return results |> Array.toList
}
// F# Array.Parallel for CPU-bound work
let processAll items =
items
|> Array.Parallel.map expensiveComputation
Agent/Actor Pattern:
;; Clojure with agent
(def counter (agent 0))
(defn increment! []
(send counter inc))
(defn get-value []
@counter)
;; Or with core.async
(defn counter-loop [initial-state]
(let [ch (chan)]
(go
(loop [state initial-state]
(let [msg (<! ch)]
(case (:type msg)
:increment (recur (inc state))
:get-value (do
(>! (:reply msg) state)
(recur state))))))
ch))
// F# MailboxProcessor (actor)
type CounterMessage =
| Increment
| GetValue of AsyncReplyChannel<int>
let createCounter initialValue =
MailboxProcessor.Start(fun inbox ->
let rec loop count = async {
let! msg = inbox.Receive()
match msg with
| Increment ->
return! loop (count + 1)
| GetValue reply ->
reply.Reply count
return! loop count
}
loop initialValue)
// Usage
let counter = createCounter 0
counter.Post Increment
counter.Post Increment
let count = counter.PostAndReply GetValue // Returns 2
Memory & Platform Translation
JVM → .NET CLR
Both Clojure and F# run on managed runtimes with garbage collection, but there are platform differences:
| Aspect | Clojure (JVM) | F# (.NET) | Translation |
|---|---|---|---|
| Memory model | JVM GC | CLR GC | Both are GC'd; no ownership concerns |
| Value types | Primitives (boxed in collections) | Structs (stack-allocated) | Use value types where beneficial |
| Reference types | Objects (heap) | Classes (heap) | Direct mapping |
| Nullability | nil everywhere |
Can be null | Use Option to prevent null |
| Generics | Type erasure | Reified generics | Full type info at runtime |
| Primitive types | Java types (Long, Double) | .NET types (int64, float) | Different defaults, similar semantics |
No explicit memory management needed in either language. Focus on:
- Avoiding excessive allocations
- Using appropriate data structures (mutable when needed)
- Leveraging persistent data structures (both languages)
Platform Library Mapping:
| Category | Clojure (JVM) | F# (.NET) |
|---|---|---|
| HTTP | clj-http, http-kit | System.Net.Http, HttpClient |
| JSON | cheshire, jsonista | System.Text.Json, Newtonsoft.Json |
| Date/Time | java.time, clj-time | System.DateTime, NodaTime |
| Regex | java.util.regex (#"...") |
System.Text.RegularExpressions |
| Collections | clojure.core | System.Collections, FSharp.Collections |
| Async | future, core.async | async/await, Task, MailboxProcessor |
| Testing | clojure.test, Midje | Expecto, xUnit, FsUnit |
| Build | Leiningen, tools.deps | dotnet CLI, Paket, FAKE |
Common Pitfalls
Preserving Dynamic Typing Mentality
- Clojure: Maps with keyword keys everywhere
- Pitfall: Using F# Map everywhere instead of records
- Better: Define record types for domain models; Map for truly dynamic data
Missing Type Annotations
- Clojure: No type annotations
- Pitfall: Relying entirely on type inference in public APIs
- Better: Annotate function signatures in modules; helps documentation and compile errors
Ignoring Lazy Evaluation Differences
- Clojure: Sequences are lazy by default
- F#: Lists are eager, seqs are lazy
- Watch for: Side effects in lazy sequences
- Solution: Use
Seq.cacheor convert to list when side effects matter
Exception-Heavy Code
- Clojure: Exceptions for control flow are common
- Pitfall: Translating all exception handling directly
- Better: Use Result type for expected errors, exceptions only for truly exceptional cases
Missing Nil vs None Differences
- Clojure:
nilis pervasive and used as false - F#:
Noneis explicit;nullexists but discouraged - Use
Option.defaultValue,Option.defaultWithto handle None safely
- Clojure:
Multimethods vs Pattern Matching
- Clojure:
defmulti/defmethodfor dynamic dispatch - Pitfall: Looking for equivalent runtime dispatch in F#
- Better: Use discriminated unions with pattern matching; compile-time exhaustiveness checking
- Clojure:
Namespace vs Module Confusion
- Clojure: Namespaces are runtime entities
- F#: Modules are compile-time organizational units
- Be aware: F# requires explicit module/namespace declarations; files don't auto-create them
REPL Workflow Assumptions
- Clojure: REPL-first development, hot-reload everything
- F#: FSI (F# Interactive) exists but compile-first workflow more common
- Adapt: Use FSI for exploration, but expect to recompile more often
Keyword Keys vs Named Fields
- Clojure: Keywords
:key-namefor map keys - F#: Named fields in records
- Watch for: Typos in keywords → Typos in field names caught at compile time
- Clojure: Keywords
Threading Macro Overuse
- Clojure:
->>and->everywhere - Pitfall: Trying to pipe everything in F#
- Better: Use pipe when it improves readability; F# also has composition (
>>)
- Clojure:
Tooling
| Tool | Purpose | Notes |
|---|---|---|
| dotnet CLI | Build, run, test, publish | Standard .NET tooling |
| Paket | Alternative package manager | Like Leiningen for F# |
| FAKE | Build automation | F# DSL for build scripts |
| FSI | F# Interactive (REPL) | Similar to Clojure REPL |
| Fantomas | Code formatter | Like cljfmt for F# |
| FSharpLint | Linter | Static analysis for F# |
| Ionide | VS Code extension | F# support with IntelliSense |
| JetBrains Rider | IDE | Full F# and .NET support |
| Expecto | Testing framework | BDD-style testing |
| FsCheck | Property-based testing | Like test.check for F# |
Examples
Example 1: Simple - Nil Handling to Option Type
Before (Clojure):
;; User as map
(def users
[{:name "Alice" :age 30}
{:name "Bob" :age nil}])
(defn get-age [user]
(or (:age user) 0))
;; Average age of users with age
(defn average-age [users]
(let [ages (keep :age users)]
(if (seq ages)
(/ (reduce + ages) (count ages))
0)))
(average-age users) ;; => 30
After (F#):
// User as record
type User = {
Name: string
Age: int option
}
let users = [
{ Name = "Alice"; Age = Some 30 }
{ Name = "Bob"; Age = None }
]
let getAge user =
user.Age |> Option.defaultValue 0
// Average age of users with age
let averageAge users =
let ages = users |> List.choose (fun u -> u.Age)
if List.isEmpty ages then
0.0
else
ages |> List.map float |> List.average
averageAge users // 30.0
Example 2: Medium - Tagged Maps to Discriminated Union
Before (Clojure):
;; Constructor functions
(defn credit-card [card-number cvv]
{:type :credit-card :card-number card-number :cvv cvv})
(defn paypal [email]
{:type :paypal :email email})
(defn bitcoin [address]
{:type :bitcoin :address address})
;; Multimethod for polymorphic dispatch
(defmulti process-payment (fn [payment] (:type (:method payment))))
(defmethod process-payment :credit-card [payment]
(let [{:keys [card-number]} (:method payment)]
(str "Processing card " card-number)))
(defmethod process-payment :paypal [payment]
(let [{:keys [email]} (:method payment)]
(str "Processing PayPal for " email)))
(defmethod process-payment :bitcoin [payment]
(let [{:keys [address]} (:method payment)]
(str "Processing Bitcoin to " address)))
;; Usage
(def payment
{:amount 100.0
:method (credit-card "1234-5678" "123")})
(process-payment payment)
;; => "Processing card 1234-5678"
After (F#):
// Discriminated union
type PaymentMethod =
| CreditCard of cardNumber: string * cvv: string
| PayPal of email: string
| Bitcoin of address: string
type Payment = {
Amount: decimal
Method: PaymentMethod
}
// Pattern matching for dispatch
let processPayment payment =
match payment.Method with
| CreditCard (number, cvv) ->
$"Processing card {number}"
| PayPal email ->
$"Processing PayPal for {email}"
| Bitcoin address ->
$"Processing Bitcoin to {address}"
// Usage
let payment = {
Amount = 100.0m
Method = CreditCard ("1234-5678", "123")
}
processPayment payment
// "Processing card 1234-5678"
Example 3: Complex - Async Workflow Conversion
Before (Clojure):
;; Using core.async
(require '[clojure.core.async :as async :refer [go <! >! chan timeout]])
(defn fetch-user [user-id]
(go
(<! (timeout 100))
{:data {:id user-id :name (str "User" user-id)}
:status-code 200}))
(defn fetch-orders [user-id]
(go
(<! (timeout 150))
{:data [1 2 3]
:status-code 200}))
(defn get-user-dashboard [user-id]
(go
(let [user-response (<! (fetch-user user-id))]
(if (not= (:status-code user-response) 200)
{:error "Failed to fetch user"}
(let [orders-response (<! (fetch-orders user-id))]
(if (not= (:status-code orders-response) 200)
{:error "Failed to fetch orders"}
{:ok {:user (:data user-response)
:orders (:data orders-response)
:order-count (count (:data orders-response))}}))))))
;; Usage
(let [dashboard-chan (get-user-dashboard 42)
dashboard (async/<!! dashboard-chan)]
(if (:ok dashboard)
(println "Dashboard:" (:ok dashboard))
(println "Error:" (:error dashboard))))
After (F#):
// Types
type ApiResponse<'T> = {
Data: 'T
StatusCode: int
}
type User = { Id: int; Name: string }
type Dashboard = {
User: User
Orders: int list
OrderCount: int
}
// Async functions
let fetchUser userId = async {
do! Async.Sleep 100
return { Data = { Id = userId; Name = $"User{userId}" }; StatusCode = 200 }
}
let fetchOrders userId = async {
do! Async.Sleep 150
return { Data = [1; 2; 3]; StatusCode = 200 }
}
// Result-based error handling with async
let getUserDashboard userId = async {
let! userResponse = fetchUser userId
if userResponse.StatusCode <> 200 then
return Error "Failed to fetch user"
else
let! ordersResponse = fetchOrders userId
if ordersResponse.StatusCode <> 200 then
return Error "Failed to fetch orders"
else
return Ok {
User = userResponse.Data
Orders = ordersResponse.Data
OrderCount = List.length ordersResponse.Data
}
}
// Usage
let dashboard = getUserDashboard 42 |> Async.RunSynchronously
match dashboard with
| Ok data -> printfn $"Dashboard: {data}"
| Error msg -> printfn $"Error: {msg}"
See Also
For more examples and patterns, see:
meta-convert-dev- Foundational patterns with cross-language examplesconvert-fsharp-clojure- F# → Clojure (reverse conversion)convert-typescript-fsharp- TypeScript → F# (similar static target)lang-clojure-dev- Clojure development patternslang-fsharp-dev- F# development patterns
Cross-cutting pattern skills (for areas not fully covered by lang-*-dev):
patterns-concurrency-dev- Async, channels, actors across languagespatterns-serialization-dev- JSON, validation, type providers across languagespatterns-metaprogramming-dev- Macros, computation expressions, quotations across languages