| name | convert-fsharp-erlang |
| description | Convert F# code to idiomatic Erlang. Use when migrating F# projects to Erlang/OTP, translating .NET functional patterns to BEAM/OTP patterns, or refactoring F# codebases to leverage Erlang's fault-tolerance and distribution. Extends meta-convert-dev with F#-to-Erlang specific patterns. |
Convert F# to Erlang
Convert F# code to idiomatic Erlang. This skill extends meta-convert-dev with F#-to-Erlang specific type mappings, idiom translations, and tooling for migrating functional .NET code to the BEAM VM and OTP framework.
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: F# types → Erlang types and records
- Idiom translations: F# patterns → idiomatic Erlang/OTP
- Error handling: F# Result/Option → Erlang tuples and let-it-crash
- Concurrency patterns: F# async/Task → Erlang processes and OTP behaviors
- Computation expressions: F# workflows → Erlang gen_server/gen_statem
- Platform migration: .NET/CLR → BEAM VM and OTP
This Skill Does NOT Cover
- General conversion methodology - see
meta-convert-dev - F# language fundamentals - see
lang-fsharp-dev - Erlang language fundamentals - see
lang-erlang-dev - Reverse conversion (Erlang → F#) - see
convert-erlang-fsharp
Quick Reference
| F# | Erlang | Notes |
|---|---|---|
string |
binary() / list() |
UTF-8 binary or char list |
int |
integer() |
Arbitrary precision |
float |
float() |
IEEE 754 double |
bool |
true / false |
Atoms |
'a option |
{ok, Value} | error |
Tagged tuple |
Result<'T,'E> |
{ok, Value} | {error, Reason} |
Tagged tuple |
'a list |
list() |
Linked list |
'a [] |
tuple() / array() |
Fixed-size tuple or array module |
Map<'K,'V> |
map() / #{K => V} |
Map literal or maps module |
Set<'T> |
sets:set() / ordsets |
Sets module |
type Record |
-record(name, {...}) |
Record definition |
type Union |
Tagged tuples | Discriminated union via tuples |
async { } |
spawn() / gen_server |
Lightweight process |
Task<'T> |
pid() |
Process identifier |
seq<'T> |
Lazy list or stream | Process-based streaming |
When Converting Code
- Analyze source thoroughly before writing target
- Map types first - create type equivalence table
- Preserve semantics over syntax similarity
- Adopt Erlang/OTP idioms - don't write "F# code in Erlang syntax"
- Embrace let-it-crash - replace defensive programming with supervision
- Handle edge cases - null safety, error paths, process lifecycle
- Test equivalence - same inputs → same outputs
Type System Mapping
Primitive Types
| F# | Erlang | Notes |
|---|---|---|
string |
binary() |
UTF-8 binary (most common): <<"Hello">> |
string |
string() |
Character list (for compatibility): "Hello" |
int |
integer() |
Arbitrary precision integer |
int8 / int16 / int32 / int64 |
integer() |
All map to integer(); note in comments |
uint8 / uint16 / uint32 / uint64 |
integer() |
Erlang integers are signed; add guards |
float / double |
float() |
IEEE 754 double precision |
decimal |
float() or custom |
No native decimal; use library or tuples |
bool |
true / false |
Atoms (lowercase) |
char |
integer() |
Unicode codepoint |
byte |
integer() |
0-255 range |
unit |
ok |
Atom representing success |
obj |
any() / term() |
Any Erlang term |
Option and Result Types
| F# | Erlang | Notes |
|---|---|---|
None |
undefined / error |
Atom for absence |
Some x |
{ok, X} |
Tagged tuple for presence |
'a option |
{ok, Value} | error | undefined |
Common pattern |
Ok x |
{ok, X} |
Success tuple |
Error e |
{error, Reason} |
Error tuple with reason |
Result<'T,'E> |
{ok, Value} | {error, Reason} |
Standard error pattern |
Collection Types
| F# | Erlang | Notes |
|---|---|---|
'a list |
list() |
Linked list: [1, 2, 3] |
'a [] (array) |
tuple() |
Fixed-size: {1, 2, 3} |
'a [] (array) |
array:array() |
Mutable array module |
seq<'a> |
Lazy list / gen_server |
Stream via process |
Map<'K,'V> |
#{K => V} |
Map literal (Erlang 17+) |
Map<'K,'V> |
dict:dict() |
Legacy dict module |
Set<'T> |
sets:set() |
Unordered set |
Set<'T> |
ordsets:ordset() |
Ordered set (list-based) |
'a * 'b (tuple) |
{A, B} |
Tuple literal |
'a * 'b * 'c |
{A, B, C} |
N-tuple |
Record and Union Types
| F# | Erlang | Notes |
|---|---|---|
type Person = { Name: string; Age: int } |
-record(person, {name :: binary(), age :: integer()}). |
Record definition |
{ Name = "Alice"; Age = 30 } |
#person{name = <<"Alice">>, age = 30} |
Record creation |
person.Name |
Person#person.name |
Field access |
{ person with Age = 31 } |
Person#person{age = 31} |
Record update |
type Shape = Circle of float | Rectangle of float * float |
Tagged tuples | {circle, Radius} or {rectangle, Width, Height} |
| Discriminated union | Pattern matching on tuple tag | Match first element as discriminator |
Function Types
| F# | Erlang | Notes |
|---|---|---|
'a -> 'b |
fun((A) -> B) |
Anonymous function |
'a -> 'b -> 'c |
fun((A, B) -> C) |
Multi-param (uncurried) |
| Curried function | Nested funs | fun(A) -> fun(B) -> C end end (uncommon) |
Func<'a,'b> |
fun((A) -> B) |
Function type |
Action<'a> |
fun((A) -> ok) |
Side-effect function |
Generic Types
| F# | Erlang | Notes |
|---|---|---|
'a |
term() |
Any type (runtime polymorphism) |
'a list |
list(A) |
Parameterized type spec |
'a option |
{ok, A} | error |
Type spec pattern |
| Type constraint | Guard clause | when is_integer(X) in function clause |
Idiom Translation
Pattern 1: Option Handling
F#:
let findUser (id: string) : User option =
users |> List.tryFind (fun u -> u.Id = id)
let name =
findUser "123"
|> Option.map (fun u -> u.Name)
|> Option.defaultValue "Unknown"
Erlang:
-spec find_user(binary()) -> {ok, user()} | error.
find_user(Id) ->
case lists:search(fun(U) -> maps:get(id, U) =:= Id end, users()) of
{value, User} -> {ok, User};
false -> error
end.
get_name(UserId) ->
case find_user(UserId) of
{ok, User} -> maps:get(name, User);
error -> <<"Unknown">>
end.
Why this translation:
- F#'s
Option.mapbecomes pattern matching in Erlang Option.defaultValuebecomes the error clause in case expression- Erlang uses
{ok, Value}|errortuples instead ofSome/None - Type specs replace F# type annotations
Pattern 2: Result-Based Error Handling
F#:
type Error = DivisionByZero | InvalidInput of string
let divide (x: float) (y: float) : Result<float, Error> =
if y = 0.0 then Error DivisionByZero
else Ok (x / y)
let calculate = result {
let! a = divide 10.0 2.0
let! b = divide 20.0 4.0
let! c = divide a b
return c
}
Erlang:
-type error_reason() :: division_by_zero | {invalid_input, binary()}.
-spec divide(float(), float()) -> {ok, float()} | {error, error_reason()}.
divide(_X, 0.0) ->
{error, division_by_zero};
divide(X, Y) ->
{ok, X / Y}.
-spec calculate() -> {ok, float()} | {error, error_reason()}.
calculate() ->
case divide(10.0, 2.0) of
{ok, A} ->
case divide(20.0, 4.0) of
{ok, B} ->
divide(A, B);
{error, Reason} -> {error, Reason}
end;
{error, Reason} -> {error, Reason}
end.
Why this translation:
- F# computation expressions become nested case statements
Result<'T,'E>maps to{ok, Value} | {error, Reason}tuples- F# discriminated unions become atoms or tagged tuples
- Pattern matching on error tuples replaces monadic bind
Pattern 3: List Processing
F#:
let result =
items
|> List.filter (fun x -> x.Active)
|> List.map (fun x -> x.Value)
|> List.sum
Erlang:
calculate_result(Items) ->
lists:foldl(
fun(X, Acc) -> Acc + X end,
0,
[maps:get(value, X) || X <- Items, maps:get(active, X)]
).
% Alternative: using lists module functions
calculate_result_alt(Items) ->
Active = lists:filter(fun(X) -> maps:get(active, X) end, Items),
Values = lists:map(fun(X) -> maps:get(value, X) end, Active),
lists:sum(Values).
Why this translation:
- F# pipe operator becomes list comprehension or nested function calls
- List comprehension is more idiomatic for filter+map in Erlang
lists:sum/1directly replacesList.sum- Both approaches are valid; comprehension is more concise
Pattern 4: Record Pattern Matching
F#:
type Person = { FirstName: string; LastName: string; Age: int }
let getFullName person =
match person with
| { FirstName = f; LastName = l } -> $"{f} {l}"
let isAdult = function
| { Age = age } when age >= 18 -> true
| _ -> false
Erlang:
-record(person, {
first_name :: binary(),
last_name :: binary(),
age :: integer()
}).
get_full_name(#person{first_name = F, last_name = L}) ->
<<F/binary, " ", L/binary>>.
is_adult(#person{age = Age}) when Age >= 18 ->
true;
is_adult(_) ->
false.
Why this translation:
- F# record patterns map to Erlang record patterns
- Guards (
when) work similarly in both languages - F# string interpolation becomes binary concatenation
- Function clauses with pattern matching replace match expressions
Paradigm Translation
Mental Model Shift: .NET Functional → BEAM/OTP
| F# Concept | Erlang/OTP Approach | Key Insight |
|---|---|---|
| Computation expression | gen_server / process loop | Stateful workflow → process with message loop |
| async/Task | spawn / gen_server | Async operation → lightweight process |
| MailboxProcessor | gen_server | Agent pattern → OTP behavior |
| Mutable state | Process state / ETS | Mutation → process-local state or shared ETS table |
| Exception | Let-it-crash + supervisor | Try/catch → supervision tree restart |
| Type provider | Parse transform / macro | Compile-time metaprogramming |
| Assembly/Module | Application / OTP app | .NET assembly → OTP application |
Concurrency Mental Model
| F# Pattern | Erlang/OTP Pattern | Conceptual Translation |
|---|---|---|
async { } |
spawn(fun() -> ... end) |
Async block → process spawn |
Async.Parallel |
Multiple spawn + receive | Parallel tasks → concurrent processes |
Async.RunSynchronously |
Synchronous call or receive | Block until result |
Task.Run |
spawn/1 |
Fire-and-forget task → process |
MailboxProcessor |
gen_server |
Stateful agent → OTP gen_server |
MailboxProcessor.Post |
gen_server:cast/2 |
Async message → cast |
MailboxProcessor.PostAndReply |
gen_server:call/2 |
Sync request → call |
Error Handling
F# Error Model → Erlang Error Model
F# Approach: Railway-Oriented Programming
type Result<'T, 'E> = Ok of 'T | Error of 'E
let validateAge age =
if age >= 0 && age <= 120 then Ok age
else Error "Invalid age"
let createUser name age = result {
let! validAge = validateAge age
return { Name = name; Age = validAge }
}
Erlang Approach: Let-It-Crash + Tagged Tuples
% Defensive: return error tuple
-spec validate_age(integer()) -> {ok, integer()} | {error, binary()}.
validate_age(Age) when Age >= 0, Age =< 120 ->
{ok, Age};
validate_age(_) ->
{error, <<"Invalid age">>}.
% Let-it-crash: use pattern matching and let supervisor handle failure
-spec create_user(binary(), integer()) -> user().
create_user(Name, Age) when Age >= 0, Age =< 120 ->
#{name => Name, age => Age}.
% Invalid age will cause function clause error, caught by supervisor
Key Differences:
- F# uses Result types everywhere - Explicit error handling in types
- Erlang uses let-it-crash - Supervisors restart failed processes
- When to use {ok, _} vs crash:
- Use
{ok, Value} | {error, Reason}for expected errors (user input, network) - Use pattern matching + crash for programming errors (invalid state)
- Use
Exception Translation
| F# | Erlang | Strategy |
|---|---|---|
try...with |
try...catch |
Rare; prefer {error, Reason} tuples |
raise / failwith |
error(Reason) / exit(Reason) |
Crash the process |
try...finally |
try...after |
Resource cleanup |
| Result type | {ok, _} | {error, _} |
Expected errors |
| Option type | {ok, _} | error | undefined |
Absence of value |
F#:
try
let result = riskyOperation()
result
with
| :? IOException as ex -> Error $"IO error: {ex.Message}"
| ex -> Error $"Unexpected: {ex.Message}"
Erlang:
% Approach 1: Catch and return error tuple
safe_risky_operation() ->
try risky_operation() of
Result -> {ok, Result}
catch
error:Reason -> {error, {operation_failed, Reason}};
exit:Reason -> {error, {process_exited, Reason}}
end.
% Approach 2: Let it crash and supervisor restarts
risky_operation() ->
% Just do the operation; supervisor will restart on failure
perform_io(),
{ok, result}.
Concurrency Patterns
F# Async → Erlang Process
F# async computation:
let fetchData url = async {
printfn $"Fetching {url}..."
do! Async.Sleep 1000
return $"Data from {url}"
}
let processUrls urls = async {
let! results =
urls
|> List.map fetchData
|> Async.Parallel
return results |> Array.toList
}
Erlang process-based:
fetch_data(Url) ->
io:format("Fetching ~s...~n", [Url]),
timer:sleep(1000),
{ok, iolist_to_binary(["Data from ", Url])}.
process_urls(Urls) ->
Self = self(),
% Spawn a process for each URL
Pids = [spawn(fun() ->
Result = fetch_data(Url),
Self ! {result, Url, Result}
end) || Url <- Urls],
% Collect results
collect_results(length(Pids), []).
collect_results(0, Acc) ->
{ok, lists:reverse(Acc)};
collect_results(N, Acc) ->
receive
{result, _Url, Result} ->
collect_results(N - 1, [Result | Acc])
after 5000 ->
{error, timeout}
end.
Why this translation:
- F# async blocks map to spawned Erlang processes
Async.Parallelbecomes multiple spawn + receive pattern- Each async operation is a lightweight process
- Results collected via message passing
MailboxProcessor → gen_server
F# MailboxProcessor:
type CounterMsg =
| Increment
| GetCount of AsyncReplyChannel<int>
let counter = MailboxProcessor.Start(fun inbox ->
let rec loop count = async {
let! msg = inbox.Receive()
match msg with
| Increment -> return! loop (count + 1)
| GetCount channel ->
channel.Reply count
return! loop count
}
loop 0
)
counter.Post Increment
let count = counter.PostAndReply GetCount
Erlang gen_server:
-module(counter_server).
-behaviour(gen_server).
-export([start_link/0, increment/0, get_count/0]).
-export([init/1, handle_call/3, handle_cast/2, terminate/2, code_change/3]).
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
increment() ->
gen_server:cast(?MODULE, increment).
get_count() ->
gen_server:call(?MODULE, get_count).
init([]) ->
{ok, 0}. % Initial state
handle_call(get_count, _From, Count) ->
{reply, Count, Count}.
handle_cast(increment, Count) ->
{noreply, Count + 1}.
terminate(_Reason, _State) ->
ok.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
Why this translation:
- F#
MailboxProcessor.Post→gen_server:cast(async) - F#
PostAndReply→gen_server:call(sync) - F# discriminated union messages → Erlang atoms/tuples
- gen_server provides supervision, hot code reload, debugging
Computation Expression → gen_statem
F# stateful computation:
type DoorState = Locked | Unlocked
type DoorEvent = Lock | Unlock | Open
let door = MailboxProcessor.Start(fun inbox ->
let rec locked() = async {
let! event = inbox.Receive()
match event with
| Unlock -> return! unlocked()
| _ -> return! locked()
}
and unlocked() = async {
let! event = inbox.Receive()
match event with
| Lock -> return! locked()
| Open ->
printfn "Door opened"
return! unlocked()
| _ -> return! unlocked()
}
locked()
)
Erlang gen_statem:
-module(door_fsm).
-behaviour(gen_statem).
-export([start_link/0, lock/0, unlock/0, open/0]).
-export([init/1, callback_mode/0, locked/3, unlocked/3, terminate/3]).
start_link() ->
gen_statem:start_link({local, ?MODULE}, ?MODULE, [], []).
lock() -> gen_statem:cast(?MODULE, lock).
unlock() -> gen_statem:cast(?MODULE, unlock).
open() -> gen_statem:cast(?MODULE, open).
init([]) ->
{ok, locked, #{}}.
callback_mode() ->
state_functions.
locked(cast, unlock, Data) ->
{next_state, unlocked, Data};
locked(cast, _, Data) ->
{keep_state, Data}.
unlocked(cast, lock, Data) ->
{next_state, locked, Data};
unlocked(cast, open, Data) ->
io:format("Door opened~n"),
{keep_state, Data};
unlocked(cast, _, Data) ->
{keep_state, Data}.
terminate(_Reason, _State, _Data) ->
ok.
Why this translation:
- F# recursive state functions → gen_statem state functions
- State transitions explicit in both
- gen_statem adds supervision, introspection, and hot code reload
- Erlang state machines are first-class OTP pattern
Memory & Platform Differences
.NET CLR → BEAM VM
| Aspect | F# (.NET/CLR) | Erlang (BEAM) | Migration Strategy |
|---|---|---|---|
| Memory model | Garbage collected, shared heap | Process-isolated heaps | Data copying between processes |
| Concurrency | Thread pool, shared memory | Lightweight processes, message passing | Replace threads with processes |
| Mutability | Immutable by default, mutable allowed | Immutable only | Remove mutable state or use ETS |
| Type system | Static, compile-time | Dynamic, runtime + Dialyzer | Use type specs, rely on Dialyzer |
| Distribution | Remote .NET Remoting (rare) | Built-in distributed Erlang | Use distributed Erlang primitives |
| Hot code reload | AppDomain reload (heavy) | Module reload (lightweight) | Leverage OTP code_change callbacks |
Shared State Translation
F# mutable state:
let mutable counter = 0
let increment() =
counter <- counter + 1
counter
Erlang alternatives:
% Option 1: Process-local state (gen_server)
-module(counter).
-behaviour(gen_server).
% ... (see gen_server example above)
% Option 2: ETS table (shared, concurrent)
-module(counter_ets).
init() ->
ets:new(counter, [named_table, public, set]),
ets:insert(counter, {value, 0}).
increment() ->
ets:update_counter(counter, value, 1).
get_value() ->
[{value, V}] = ets:lookup(counter, value),
V.
When to use each:
- gen_server: Sequential access, state changes are ordered
- ETS: Concurrent reads/writes, higher throughput
- Process dictionary: Rarely (per-process global variables)
Common Pitfalls
Trying to share state between processes
- F# allows shared mutable state via
mutableorref - Erlang processes are isolated; use message passing or ETS
- Solution: Send data via messages or use gen_server for coordination
- F# allows shared mutable state via
Expecting static type safety
- F# has compile-time type checking
- Erlang is dynamically typed; Dialyzer provides static analysis but doesn't prevent runtime errors
- Solution: Use type specs (
-spec), rely on pattern matching and guards, run Dialyzer
Over-using try/catch
- F# uses exceptions for control flow
- Erlang prefers let-it-crash with supervisors
- Solution: Use
{ok, Value} | {error, Reason}for expected errors, let supervisors handle crashes
Direct port of OOP patterns
- F# can interop with C# classes and objects
- Erlang has no objects; use records, maps, and processes
- Solution: Model objects as records/maps for data, processes for stateful entities
Ignoring process lifecycles
- F# Tasks clean up automatically
- Erlang processes must be explicitly linked/monitored
- Solution: Use supervision trees, link processes, handle EXIT messages
String type mismatch
- F# string is always UTF-16
- Erlang has binaries (UTF-8) and lists (codepoints)
- Solution: Prefer binaries (
<<"Hello">>) for strings, useunicodemodule for conversions
Expecting LINQ-style laziness
- F#
seq<'T>is lazy - Erlang lists are strict; laziness requires process-based streams
- Solution: Use list comprehensions for small data, gen_server or gen_stage for large streams
- F#
Missing supervision
- F# async errors propagate to caller
- Erlang crashes should be handled by supervisors
- Solution: Always wrap gen_servers in a supervision tree
Tooling
| Tool | Purpose | Notes |
|---|---|---|
| rebar3 | Build tool and package manager | Equivalent to dotnet CLI |
| Dialyzer | Static analysis tool | Type checking via success typing |
| Erlang shell | REPL | Interactive testing (like F# Interactive) |
| Observer | GUI for process inspection | No direct F# equivalent |
| recon | Production debugging | Runtime introspection library |
| PropEr | Property-based testing | Similar to FsCheck |
| Common Test | Testing framework | Similar to xUnit/NUnit |
| EUnit | Unit testing | Simpler than Common Test |
| erlang.mk | Alternative build tool | Makefile-based (alternative to rebar3) |
| relx | Release management | Bundled with rebar3 |
Build System Migration
.NET Project → OTP Application
F# project (.fsproj):
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Types.fs" />
<Compile Include="Logic.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FSharp.Core" Version="8.0.0" />
</ItemGroup>
</Project>
Erlang rebar3 (rebar.config):
{erl_opts, [debug_info]}.
{deps, [
% Dependencies from hex.pm
]}.
{relx, [
{release, {myapp, "0.1.0"},
[myapp, sasl]},
{dev_mode, true},
{include_erts, false}
]}.
Application resource file (src/myapp.app.src):
{application, myapp,
[{description, "My OTP application"},
{vsn, "0.1.0"},
{registered, []},
{mod, {myapp_app, []}},
{applications, [kernel, stdlib]},
{env, []},
{modules, []},
{licenses, ["Apache-2.0"]},
{links, []}
]}.
Migration mapping:
.fsproj→rebar.config+.app.src- NuGet packages → hex.pm dependencies
- Assembly entry point → OTP application module
- Build output →
_build/directory
Examples
Example 1: Simple - Type and Function Translation
Before (F#):
type Point = { X: float; Y: float }
let distance (p1: Point) (p2: Point) : float =
let dx = p2.X - p1.X
let dy = p2.Y - p1.Y
sqrt (dx * dx + dy * dy)
let origin = { X = 0.0; Y = 0.0 }
let point = { X = 3.0; Y = 4.0 }
let dist = distance origin point // 5.0
After (Erlang):
-module(geometry).
-export([distance/2]).
-record(point, {x :: float(), y :: float()}).
-spec distance(#point{}, #point{}) -> float().
distance(#point{x = X1, y = Y1}, #point{x = X2, y = Y2}) ->
Dx = X2 - X1,
Dy = Y2 - Y1,
math:sqrt(Dx * Dx + Dy * Dy).
% Usage
origin() -> #point{x = 0.0, y = 0.0}.
example() ->
Origin = origin(),
Point = #point{x = 3.0, y = 4.0},
Dist = distance(Origin, Point), % 5.0
Dist.
Example 2: Medium - Option and Result Handling
Before (F#):
type User = { Id: string; Name: string; Email: string }
type UserError = NotFound | InvalidEmail of string
let validateEmail (email: string) : Result<string, UserError> =
if email.Contains("@") then Ok email
else Error (InvalidEmail email)
let findUserById (id: string) : User option =
// Simulate database lookup
if id = "123" then Some { Id = id; Name = "Alice"; Email = "alice@example.com" }
else None
let getUserEmail (id: string) : Result<string, UserError> =
match findUserById id with
| Some user ->
validateEmail user.Email
| None ->
Error NotFound
After (Erlang):
-module(user_service).
-export([get_user_email/1]).
-type user() :: #{
id := binary(),
name := binary(),
email := binary()
}.
-type user_error() :: not_found | {invalid_email, binary()}.
-spec validate_email(binary()) -> {ok, binary()} | {error, user_error()}.
validate_email(Email) ->
case binary:match(Email, <<"@">>) of
nomatch -> {error, {invalid_email, Email}};
_ -> {ok, Email}
end.
-spec find_user_by_id(binary()) -> {ok, user()} | error.
find_user_by_id(<<"123">>) ->
{ok, #{
id => <<"123">>,
name => <<"Alice">>,
email => <<"alice@example.com">>
}};
find_user_by_id(_) ->
error.
-spec get_user_email(binary()) -> {ok, binary()} | {error, user_error()}.
get_user_email(Id) ->
case find_user_by_id(Id) of
{ok, User} ->
Email = maps:get(email, User),
validate_email(Email);
error ->
{error, not_found}
end.
Example 3: Complex - Async Workflow to gen_server
Before (F#):
type Message =
| Fetch of url: string
| Process of data: string
| GetResults of AsyncReplyChannel<string list>
type WorkerState = {
Results: string list
}
let worker = MailboxProcessor.Start(fun inbox ->
let rec loop state = async {
let! msg = inbox.Receive()
match msg with
| Fetch url ->
let! data = async {
do! Async.Sleep 100 // Simulate network delay
return $"Data from {url}"
}
inbox.Post (Process data)
return! loop state
| Process data ->
let processed = data.ToUpper()
let newState = { Results = processed :: state.Results }
return! loop newState
| GetResults channel ->
channel.Reply (List.rev state.Results)
return! loop state
}
loop { Results = [] }
)
// Usage
worker.Post (Fetch "http://example.com")
worker.Post (Fetch "http://test.com")
let results = worker.PostAndReply GetResults
After (Erlang):
-module(worker_server).
-behaviour(gen_server).
-export([start_link/0, fetch/1, get_results/0]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-record(state, {
results = [] :: [binary()]
}).
%%% API
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
-spec fetch(binary()) -> ok.
fetch(Url) ->
gen_server:cast(?MODULE, {fetch, Url}).
-spec get_results() -> {ok, [binary()]}.
get_results() ->
gen_server:call(?MODULE, get_results).
%%% Callbacks
init([]) ->
{ok, #state{}}.
handle_call(get_results, _From, State) ->
Results = lists:reverse(State#state.results),
{reply, {ok, Results}, State}.
handle_cast({fetch, Url}, State) ->
% Spawn async fetch process
Self = self(),
spawn(fun() ->
timer:sleep(100), % Simulate network delay
Data = iolist_to_binary(["Data from ", Url]),
gen_server:cast(Self, {process, Data})
end),
{noreply, State};
handle_cast({process, Data}, State) ->
Processed = string:uppercase(Data),
NewResults = [Processed | State#state.results],
{noreply, State#state{results = NewResults}}.
handle_info(_Info, State) ->
{noreply, State}.
terminate(_Reason, _State) ->
ok.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
%%% Usage
% worker_server:start_link().
% worker_server:fetch(<<"http://example.com">>).
% worker_server:fetch(<<"http://test.com">>).
% {ok, Results} = worker_server:get_results().
Translation notes:
- F#
MailboxProcessor→ Erlanggen_server - F# discriminated union messages → Erlang tuples
- F#
PostAndReply→gen_server:call - F#
Post→gen_server:cast - F#
async { }for network call →spawnfor concurrent task - State management identical in concept
See Also
For more examples and patterns, see:
meta-convert-dev- Foundational patterns with cross-language exampleslang-fsharp-dev- F# development patternslang-erlang-dev- Erlang development patternsconvert-elixir-fsharp- Reverse direction (Elixir is related to Erlang)
Cross-cutting pattern skills (for areas not fully covered by lang-*-dev):
patterns-concurrency-dev- Async, actors, processes across languagespatterns-serialization-dev- JSON, validation, encoding across languages