Agents convert-elixir-fsharp
Bidirectional conversion between Elixir and Fsharp. Use when migrating projects between these languages in either direction. Extends meta-convert-dev with Elixir↔Fsharp specific patterns.
git clone https://github.com/aRustyDev/agents
T=$(mktemp -d) && git clone --depth=1 https://github.com/aRustyDev/agents "$T" && mkdir -p ~/.claude/skills && cp -r "$T/content/skills/convert-elixir-fsharp" ~/.claude/skills/arustydev-agents-convert-elixir-fsharp && rm -rf "$T"
content/skills/convert-elixir-fsharp/SKILL.mdConvert Elixir to F#
Convert Elixir code to idiomatic F#. This skill extends
meta-convert-dev with Elixir-to-F# specific type mappings, idiom translations, and tooling for transforming dynamic, actor-based Elixir code into functional-first, statically-typed F# on the .NET platform.
This Skill Extends
- Foundational conversion patterns (APTV workflow, testing strategies)meta-convert-dev
For general concepts like the Analyze → Plan → Transform → Validate workflow, testing strategies, and common pitfalls, see the meta-skill first.
This Skill Adds
- Type mappings: Elixir dynamic types → F# static types with inference
- Idiom translations: Elixir pipelines/pattern matching → F# functional patterns
- Error handling: Tagged tuples {:ok, value} → Result<'T,'E>/Option<'T>
- Concurrency patterns: GenServer/processes → MailboxProcessor/async workflows
- Runtime translation: BEAM VM → .NET CLR with async/await
- Collection patterns: Elixir lists/maps → F# immutable collections
This Skill Does NOT Cover
- General conversion methodology - see
meta-convert-dev - Elixir language fundamentals - see
lang-elixir-dev - F# language fundamentals - see
lang-fsharp-dev - OTP distributed systems - requires architectural redesign for .NET
Quick Reference
| Elixir | F# | Notes |
|---|---|---|
| Discriminated union or | :ok → Ok case, :error → Error case |
| , , | F# int is 32-bit |
| | 64-bit double precision |
| or | UTF-8 binary → UTF-16 string |
| | Both immutable, F# is singly-linked |
| | Direct mapping |
| | Immutable maps |
| | List of tuples |
| | Actor-style processing |
| + state loop | Actor pattern with message handling |
| or | Async computations |
| | Lazy sequences |
| (Result type) | Success case |
| (Result type) | Error case |
| (Option type) | Explicit nullable |
When Converting Code
- Analyze source thoroughly before writing target
- Map types first - create type equivalence table, especially for atoms
- Preserve functional semantics - both languages are functional-first
- Translate actor patterns carefully - GenServer → MailboxProcessor requires state management
- Use F# type inference - let compiler infer types when possible
- Railway-oriented programming - translate {:ok, _}/{:error, _} to Result<'T,'E>
- Leverage pipe operator - Elixir |> maps directly to F# |>
- Test equivalence - same inputs → same outputs
Type System Mapping
Primitive Types
| Elixir | F# | Notes |
|---|---|---|
(small) | | 32-bit signed integer |
(large) | | 64-bit signed integer |
(arbitrary) | | Arbitrary precision |
| | 64-bit IEEE 754 |
| | true/false |
| Discriminated union | :ok, :error, etc. → DU cases |
(text) | | UTF-8 → UTF-16 |
(bytes) | | Raw bytes |
| Custom type | No direct equivalent |
| in | Must be wrapped |
| - | No direct equivalent |
| - | BEAM-specific |
Critical Note on Atoms: Elixir atoms are compile-time constants used extensively for tagging (
:ok, :error, :atom_name). In F#, translate to:
- Discriminated unions for fixed sets (
/:ok
→:error
type)Result - Strings for dynamic/user-defined atoms
- Enums for simple value sets
Collection Types
| Elixir | F# | Notes |
|---|---|---|
| | Both immutable, singly-linked |
pattern | pattern | List destructuring |
| | Fixed-size product types |
| | Immutable tree-based map |
| Record type | Atom-keyed maps → records |
| | String-keyed maps |
| | → list of tuples |
| | Immutable set |
| | Lazy range |
| | Lazy sequences |
Composite Types
| Elixir | F# | Notes |
|---|---|---|
| | Elixir structs → F# records |
| alias | Type aliases |
| Tagged tuple | Discriminated union | → |
| Interface | Module behaviors → interfaces |
| Interface or type class | Protocol → interface |
| Interface implementation | Implementation |
Idiom Translation
Pattern 1: Pipe Operator (Direct Translation)
Elixir:
# Pipeline processing "hello world" |> String.upcase() |> String.reverse() |> String.split("") |> Enum.join("-")
F#:
// Direct translation - pipe works the same! "hello world" |> String.toUpper |> String.rev |> Seq.toArray |> String.concat "-" // Or with built-in functions "hello world" |> (fun s -> s.ToUpper()) |> Seq.rev |> String.concat "-"
Why this translation:
- Both languages use
for left-to-right function chaining|> - F# and Elixir share functional pipeline philosophy
- Minor differences in standard library function names
Pattern 2: Pattern Matching on Tagged Tuples
Elixir:
# Pattern matching on result tuples case File.read("config.json") do {:ok, contents} -> Jason.decode(contents) {:error, :enoent} -> {:error, "File not found"} {:error, reason} -> {:error, "Read error: #{inspect(reason)}"} end
F#:
// Using Result type with pattern matching match System.IO.File.ReadAllText("config.json") |> tryRead with | Ok contents -> Json.decode contents | Error FileNotFound -> Error "File not found" | Error (ReadError reason) -> Error $"Read error: {reason}" // Helper to convert exceptions to Result let tryRead path = try Ok (System.IO.File.ReadAllText(path)) with | :? System.IO.FileNotFoundException -> Error FileNotFound | ex -> Error (ReadError ex.Message)
Why this translation:
- Elixir's
/{:ok, value}
maps directly to F#'s{:error, reason}Result<'T, 'E> - Pattern matching syntax is similar in both languages
- F# requires explicit error type definition
Pattern 3: with Statement → Result Computation Expression
Elixir:
# Chain operations that can fail def create_user(params) do with {:ok, validated} <- validate_params(params), {:ok, user} <- insert_user(validated), {:ok, email} <- send_welcome_email(user) do {:ok, user} else {:error, reason} -> {:error, reason} end end
F#:
// 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 createUser params = result { let! validated = validateParams params let! user = insertUser validated let! email = sendWelcomeEmail user return user } // Or using built-in Result.bind (more verbose) let createUser params = validateParams params |> Result.bind insertUser |> Result.bind (fun user -> sendWelcomeEmail user |> Result.map (fun _ -> user))
Why this translation:
- Elixir's
statement is railway-oriented programmingwith - F# computation expressions provide the same short-circuiting behavior
in F# unwraps Result likelet!
unwraps tuples in Elixir<-
Pattern 4: GenServer → MailboxProcessor
Elixir:
defmodule Counter do use GenServer # Client API def start_link(initial_value) do GenServer.start_link(__MODULE__, initial_value, name: __MODULE__) end def increment do GenServer.call(__MODULE__, :increment) end def get do GenServer.call(__MODULE__, :get) end # Server Callbacks @impl true def init(initial_value) do {:ok, initial_value} end @impl true def handle_call(:increment, _from, state) do {:reply, state + 1, state + 1} end def handle_call(:get, _from, state) do {:reply, state, state} end end
F#:
module Counter = // Message types (discriminated union) type Message = | Increment of AsyncReplyChannel<int> | Get of AsyncReplyChannel<int> // Agent wrapping MailboxProcessor type CounterAgent(initialValue: int) = let agent = MailboxProcessor.Start(fun inbox -> let rec loop state = async { let! msg = inbox.Receive() match msg with | Increment reply -> let newState = state + 1 reply.Reply(newState) return! loop newState | Get reply -> reply.Reply(state) return! loop state } loop initialValue ) member _.Increment() = agent.PostAndReply(Increment) member _.Get() = agent.PostAndReply(Get) // Client API let start initialValue = CounterAgent(initialValue)
Why this translation:
- Both GenServer and MailboxProcessor implement the actor pattern
- F# uses discriminated unions for message types (typed vs Elixir's atoms)
in F# replaces Elixir'sAsyncReplyChannel
parameter for synchronous callsfrom- Recursive
function maintains state like Elixir's callback chainloop
Pattern 5: Enum Functions → List/Seq Functions
Elixir:
# Enum operations numbers = [1, 2, 3, 4, 5] doubled = Enum.map(numbers, fn x -> x * 2 end) evens = Enum.filter(numbers, fn x -> rem(x, 2) == 0 end) sum = Enum.reduce(numbers, 0, fn x, acc -> x + acc end) # With pipe operator result = [1, 2, 3, 4, 5] |> Enum.filter(&(rem(&1, 2) == 0)) |> Enum.map(&(&1 * 2)) |> Enum.sum()
F#:
// List operations let numbers = [1; 2; 3; 4; 5] let doubled = numbers |> List.map (fun x -> x * 2) let evens = numbers |> List.filter (fun x -> x % 2 = 0) let sum = numbers |> List.fold (+) 0 // With pipe operator (idiomatic F#) let result = [1; 2; 3; 4; 5] |> List.filter (fun x -> x % 2 = 0) |> List.map (fun x -> x * 2) |> List.sum // Using Seq for lazy evaluation (like Elixir Stream) let lazyResult = seq { 1..5 } |> Seq.filter (fun x -> x % 2 = 0) |> Seq.map (fun x -> x * 2) |> Seq.sum
Why this translation:
- Elixir
→ F#Enum
for eager evaluationList - Elixir
→ F#Stream
for lazy evaluationSeq - Function names are similar:
,map
,filter
/reducefold - Both languages use pipe operator idiomatically
Pattern 6: Structs → Records
Elixir:
defmodule User do defstruct [:name, :email, age: 0, active: true] def new(name, email) do %User{name: name, email: email} end def activate(%User{} = user) do %{user | active: true} end end # Usage user = %User{name: "Alice", email: "alice@example.com"} user = User.activate(user)
F#:
module User = // Record type (immutable by default) type User = { Name: string Email: string Age: int Active: bool } // Constructor with defaults let create name email = { Name = name Email = email Age = 0 Active = true } // Update function (returns new record) let activate user = { user with Active = true } // Usage let user = User.create "Alice" "alice@example.com" let activeUser = User.activate user
Why this translation:
- Elixir structs and F# records are both immutable by default
- Copy-and-update syntax is similar:
vs%{user | ...}{ user with ... } - F# records have structural equality automatically (like Elixir structs)
Error Handling
Elixir Tagged Tuples → F# Result Type
Elixir's error model:
# Return tagged tuples def divide(a, b) when b != 0, do: {:ok, a / b} def divide(_, 0), do: {:error, :division_by_zero} # Pattern match on result case divide(10, 2) do {:ok, result} -> "Result: #{result}" {:error, :division_by_zero} -> "Cannot divide by zero" end # With operator for chaining with {:ok, a} <- divide(10, 2), {:ok, b} <- divide(20, 4), {:ok, c} <- divide(a, b) do {:ok, c} else {:error, reason} -> {:error, reason} end
F# Result type (idiomatic):
// Define error type type MathError = | DivisionByZero // Function returns Result<'T, 'E> let divide a b = if b = 0.0 then Error DivisionByZero else Ok (a / b) // Pattern match on result let message = match divide 10.0 2.0 with | Ok result -> $"Result: {result}" | Error DivisionByZero -> "Cannot divide by zero" // Result computation expression for chaining let result = result { let! a = divide 10.0 2.0 let! b = divide 20.0 4.0 let! c = divide a b return c }
Translation guidelines:
→{:ok, value}Ok value
→{:error, reason}Error reason- Atom errors (
,:not_found
) → discriminated union cases:timeout - String errors remain strings or become union cases with data
statement →with
computation expressionresult { }
Option Type for Nullable Values
Elixir:
# nil represents absence def find_user(id) do case Repo.get(User, id) do nil -> nil user -> user end end # Pattern matching on nil case find_user(123) do nil -> "User not found" user -> "Found: #{user.name}" end
F#:
// Option type for nullable let findUser id = match Repo.get<User> id with | null -> None | user -> Some user // Pattern matching on Option match findUser 123 with | None -> "User not found" | Some user -> $"Found: {user.Name}" // Or with Option.map findUser 123 |> Option.map (fun user -> $"Found: {user.Name}") |> Option.defaultValue "User not found"
Translation guidelines:
→nil
(but wrapped inNone
)Option<'T>- Present value →
Some value - Pattern match or use
module functionsOption
Concurrency Patterns
Elixir Processes → F# MailboxProcessor
Elixir process communication:
# Spawn process pid = spawn(fn -> receive do {:hello, sender} -> send(sender, {:ok, "Hello back!"}) end end) # Send message send(pid, {:hello, self()}) # Receive response receive do {:ok, message} -> IO.puts(message) after 1000 -> IO.puts("Timeout!") end
F# MailboxProcessor:
// Message type type Message = | Hello of AsyncReplyChannel<string> // Create mailbox processor let processor = MailboxProcessor.Start(fun inbox -> async { let! msg = inbox.Receive() match msg with | Hello reply -> reply.Reply("Hello back!") } ) // Send and receive (synchronous) let message = processor.PostAndReply(Hello) printfn "%s" message // Or asynchronous with timeout let! result = processor.PostAndAsyncReply(Hello, timeout = 1000) match result with | Some message -> printfn "%s" message | None -> printfn "Timeout!"
Translation guidelines:
→spawn(fn -> ... end)MailboxProcessor.Start(fun inbox -> async { ... })
→receive do ... endlet! msg = inbox.Receive()
→send(pid, msg)
(async) orprocessor.Post(msg)
(sync)PostAndReply- Elixir atoms for message tags → F# discriminated union cases
Task/async-await Pattern
Elixir:
# Async task task1 = Task.async(fn -> fetch_data("url1") end) task2 = Task.async(fn -> fetch_data("url2") end) # Await results result1 = Task.await(task1) result2 = Task.await(task2) # Parallel map results = ["url1", "url2", "url3"] |> Task.async_stream(&fetch_data/1) |> Enum.to_list()
F#:
// Async computation let task1 = async { return fetchData "url1" } let task2 = async { return fetchData "url2" } // Await results let! result1 = task1 let! result2 = task2 // Parallel execution let results = ["url1"; "url2"; "url3"] |> List.map (fun url -> async { return fetchData url }) |> Async.Parallel |> Async.RunSynchronously // Or with Task<'T> (closer to .NET idioms) let task1 = Task.Run(fun () -> fetchData "url1") let task2 = Task.Run(fun () -> fetchData "url2") let results = Task.WaitAll([| task1; task2 |])
Translation guidelines:
→Task.async(fn -> ... end)
orasync { ... }Task.Run
→Task.await(task)
(in async block)let! result = task
→Task.async_stream
or parallel LINQAsync.Parallel- Elixir Task is eager; F#
is lazy (runs when awaited)Async<'T>
Supervision Pattern
Elixir:
defmodule MyApp.Application do use Application def start(_type, _args) do children = [ {Stack, []}, {MyWorker, name: MyWorker, restart: :transient} ] opts = [strategy: :one_for_one, name: MyApp.Supervisor] Supervisor.start_link(children, opts) end end
F# (using Akka.NET or custom supervision):
// Custom supervision with restart logic module Supervisor = type SupervisedAgent<'State, 'Msg>( initialState: 'State, handler: MailboxProcessor<'Msg> -> 'State -> 'Msg -> Async<'State>) = let mutable agent = None let rec start() = let a = MailboxProcessor.Start(fun inbox -> let rec loop state = async { try let! msg = inbox.Receive() let! newState = handler inbox state msg return! loop newState with | ex -> printfn "Agent crashed: %s" ex.Message // Restart with initial state return! loop initialState } loop initialState ) agent <- Some a a member _.Agent = agent |> Option.defaultWith start // Or use Akka.NET for production OTP-like supervision open Akka.Actor open Akka.FSharp let system = System.create "my-system" (Configuration.load()) let supervisor = spawn system "supervisor" (fun mailbox -> // Akka.NET provides OTP-like supervision strategies actorOf2 (fun mailbox -> actor { // Actor logic }) )
Translation guidelines:
- Simple supervision: custom restart logic with MailboxProcessor
- Complex supervision: use Akka.NET (F#-friendly OTP alternative)
- Restart strategies map:
,:one_for_one
,:one_for_all:rest_for_one - F# lacks built-in supervision; requires libraries or custom implementation
Common Pitfalls
-
Atom explosion: Elixir atoms are lightweight; F# strings are not. Use discriminated unions for fixed sets of atoms to get compile-time safety.
-
Dynamic typing assumptions: Elixir's dynamic typing allows
and%{id: 1, name: "Alice"}
in the same list. F# requires consistent types—use discriminated unions for heterogeneous data.%{id: 2, age: 30} -
Process registry: Elixir's process registry (named processes) doesn't have a direct F# equivalent. Use dependency injection or singleton patterns for named services.
-
Hot code swapping: BEAM's hot code swapping is not available on .NET. Plan for deployment downtime or use blue-green deployment strategies.
-
String encoding: Elixir binaries are UTF-8; .NET strings are UTF-16. Be careful with string/binary conversion, especially for protocols.
-
Pattern matching on strings: Elixir can pattern match on binary structure (
). F# requires imperative slicing or active patterns.<<a, b, rest::binary>> -
Lazy evaluation differences: Elixir
is pull-based lazy; F#Stream
is also lazy but behaves slightly differently with side effects.Seq -
Async cancellation: Elixir processes can be killed easily; F# async requires CancellationToken propagation.
Tooling
| Tool | Purpose | Notes |
|---|---|---|
| dotnet CLI | Build, run, test F# projects | , |
| Ionide | VS Code F# extension | Provides IntelliSense, linting |
| Paket | Alternative package manager | Deterministic dependency resolution |
| FAKE | Build automation | F# Make (like Mix tasks) |
| Expecto | Testing framework | Similar to ExUnit |
| FsCheck | Property-based testing | Like Elixir's StreamData |
| Akka.NET | Actor framework | OTP-like supervision and distribution |
| Giraffe | Web framework | Functional HTTP (like Phoenix) |
Examples
Example 1: Simple - Pattern Matching and Recursion
Before (Elixir):
defmodule Math do # Factorial using pattern matching def factorial(0), do: 1 def factorial(n) when n > 0, do: n * factorial(n - 1) # FizzBuzz with pattern matching def fizzbuzz(n) do case {rem(n, 3), rem(n, 5)} do {0, 0} -> "FizzBuzz" {0, _} -> "Fizz" {_, 0} -> "Buzz" _ -> to_string(n) end end end
After (F#):
module Math = // Factorial using pattern matching let rec factorial n = match n with | 0 -> 1 | n when n > 0 -> n * factorial (n - 1) | _ -> failwith "Negative input not allowed" // FizzBuzz with pattern matching let fizzbuzz n = match (n % 3, n % 5) with | (0, 0) -> "FizzBuzz" | (0, _) -> "Fizz" | (_, 0) -> "Buzz" | _ -> string n
Example 2: Medium - Error Handling with Result
Before (Elixir):
defmodule UserService do def register_user(params) do with {:ok, email} <- validate_email(params["email"]), {:ok, password} <- validate_password(params["password"]), {:ok, user} <- create_user(email, password), {:ok, _} <- send_welcome_email(user) do {:ok, user} else {:error, :invalid_email} -> {:error, "Invalid email format"} {:error, :weak_password} -> {:error, "Password too weak"} {:error, :email_taken} -> {:error, "Email already registered"} {:error, reason} -> {:error, "Registration failed: #{reason}"} end end defp validate_email(email) when is_binary(email) do if String.contains?(email, "@") do {:ok, email} else {:error, :invalid_email} end end defp validate_password(password) when byte_size(password) >= 8 do {:ok, password} end defp validate_password(_), do: {:error, :weak_password} defp create_user(email, password) do # Simulate database insert if email == "taken@example.com" do {:error, :email_taken} else {:ok, %{id: 1, email: email, password_hash: hash_password(password)}} end end defp send_welcome_email(user) do # Simulate email sending {:ok, user} end defp hash_password(password), do: :crypto.hash(:sha256, password) end
After (F#):
module UserService = // Error type type RegistrationError = | InvalidEmail | WeakPassword | EmailTaken | RegistrationFailed of string // User type type User = { Id: int Email: string PasswordHash: byte[] } // 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() // Validation functions let validateEmail email = if String.contains email "@" then Ok email else Error InvalidEmail let validatePassword password = if String.length password >= 8 then Ok password else Error WeakPassword let hashPassword password = use sha256 = System.Security.Cryptography.SHA256.Create() Text.Encoding.UTF8.GetBytes(password) |> sha256.ComputeHash let createUser email password = // Simulate database insert if email = "taken@example.com" then Error EmailTaken else Ok { Id = 1; Email = email; PasswordHash = hashPassword password } let sendWelcomeEmail user = // Simulate email sending Ok user // Main registration function let registerUser params = result { let! email = Map.tryFind "email" params |> Option.toResult InvalidEmail let! validEmail = validateEmail email let! password = Map.tryFind "password" params |> Option.toResult WeakPassword let! validPassword = validatePassword password let! user = createUser validEmail validPassword let! _ = sendWelcomeEmail user return user } |> Result.mapError (fun error -> match error with | InvalidEmail -> "Invalid email format" | WeakPassword -> "Password too weak" | EmailTaken -> "Email already registered" | RegistrationFailed reason -> $"Registration failed: {reason}" ) // Helper extension module Option = let toResult error option = match option with | Some value -> Ok value | None -> Error error
Example 3: Complex - GenServer to MailboxProcessor
Before (Elixir):
defmodule ChatRoom do use GenServer # Client API def start_link(room_name) do GenServer.start_link(__MODULE__, room_name, name: via_tuple(room_name)) end def join(room_name, user_name) do GenServer.call(via_tuple(room_name), {:join, user_name}) end def leave(room_name, user_name) do GenServer.cast(via_tuple(room_name), {:leave, user_name}) end def send_message(room_name, user_name, message) do GenServer.cast(via_tuple(room_name), {:message, user_name, message}) end def get_users(room_name) do GenServer.call(via_tuple(room_name), :get_users) end # Server Callbacks @impl true def init(room_name) do state = %{ room_name: room_name, users: MapSet.new(), messages: [] } {:ok, state} end @impl true def handle_call({:join, user_name}, _from, state) do if MapSet.member?(state.users, user_name) do {:reply, {:error, :already_joined}, state} else new_state = %{state | users: MapSet.put(state.users, user_name)} broadcast_message(new_state, "#{user_name} joined the room") {:reply, {:ok, :joined}, new_state} end end def handle_call(:get_users, _from, state) do {:reply, MapSet.to_list(state.users), state} end @impl true def handle_cast({:leave, user_name}, state) do new_state = %{state | users: MapSet.delete(state.users, user_name)} broadcast_message(new_state, "#{user_name} left the room") {:noreply, new_state} end def handle_cast({:message, user_name, message}, state) do formatted = "#{user_name}: #{message}" new_state = %{state | messages: [formatted | state.messages]} broadcast_message(new_state, formatted) {:noreply, new_state} end # Private Helpers defp via_tuple(room_name) do {:via, Registry, {ChatRegistry, room_name}} end defp broadcast_message(state, message) do # In real app, would use PubSub IO.puts("[#{state.room_name}] #{message}") end end
After (F#):
module ChatRoom = // Types type User = string type Message = string type State = { RoomName: string Users: Set<User> Messages: Message list } // Message types type ChatMessage = | Join of user: User * AsyncReplyChannel<Result<unit, string>> | Leave of user: User | SendMessage of user: User * message: Message | GetUsers of AsyncReplyChannel<User list> // Chat room agent type ChatRoomAgent(roomName: string) = let agent = MailboxProcessor.Start(fun inbox -> let broadcastMessage state message = printfn "[%s] %s" state.RoomName message let rec loop state = async { let! msg = inbox.Receive() match msg with | Join (user, reply) -> if Set.contains user state.Users then reply.Reply(Error "Already joined") return! loop state else let newState = { state with Users = Set.add user state.Users } broadcastMessage newState $"{user} joined the room" reply.Reply(Ok ()) return! loop newState | Leave user -> let newState = { state with Users = Set.remove user state.Users } broadcastMessage newState $"{user} left the room" return! loop newState | SendMessage (user, message) -> let formatted = $"{user}: {message}" let newState = { state with Messages = formatted :: state.Messages } broadcastMessage newState formatted return! loop newState | GetUsers reply -> reply.Reply(Set.toList state.Users) return! loop state } let initialState = { RoomName = roomName Users = Set.empty Messages = [] } loop initialState ) // Client API member _.Join(user) = agent.PostAndReply(fun ch -> Join(user, ch)) member _.Leave(user) = agent.Post(Leave user) member _.SendMessage(user, message) = agent.Post(SendMessage(user, message)) member _.GetUsers() = agent.PostAndReply(GetUsers) // Room registry (simple dictionary-based) module Registry = open System.Collections.Concurrent let private rooms = ConcurrentDictionary<string, ChatRoomAgent>() let getOrCreate roomName = rooms.GetOrAdd(roomName, fun name -> ChatRoomAgent(name)) let tryGet roomName = match rooms.TryGetValue(roomName) with | true, room -> Some room | false, _ -> None // Usage example let room = ChatRoom.Registry.getOrCreate "lobby" match room.Join("Alice") with | Ok () -> printfn "Alice joined successfully" | Error msg -> printfn "Join failed: %s" msg room.SendMessage("Alice", "Hello everyone!") let users = room.GetUsers() printfn "Current users: %A" users room.Leave("Alice")
See Also
For more examples and patterns, see:
- Foundational patterns with cross-language examplesmeta-convert-dev
- Similar BEAM → .NET conversion (Elixir's foundation)convert-erlang-fsharp
- Elixir development patternslang-elixir-dev
- F# development patternslang-fsharp-dev
Cross-cutting pattern skills:
- Actor patterns, async workflows across languagespatterns-concurrency-dev
- JSON handling, validation across languagespatterns-serialization-dev
- Compile-time code generation patternspatterns-metaprogramming-dev