PONY λ M2 Modula-2

Erlang.CodeCompared.To/F#

An interactive executable cheatsheet comparing Erlang and F#

Erlang/OTP 26 F# (.NET 9)
Output & Running
Hello, World
io:format("Hello, World!~n").
printfn "Hello, World!"
Erlang prints with io:format/1 and the ~n directive for the newline. F#'s printfn appends the newline for you. Neither language requires wrapping this in a class, module, or main function — both run top-level code directly, top to bottom, one of the strongest structural parallels on the whole site.
Printing a value for inspection
Value = {ok, [1, 2, 3]}, io:format("~p~n", [Value]).
let value = Some [1; 2; 3] printfn "%A" value
Erlang's ~p ("pretty print") directive renders any term for humans. F#'s %A format specifier does the equivalent job for any value, including nested options, lists, and records — both exist precisely so you never have to hand-write a formatter for ordinary data.
Dynamic vs. Static Types
Runtime checking vs. compile-time checking
% No type declaration anywhere — this only fails when it actually RUNS: Add = fun(X, Y) -> X + Y end, io:format("~p~n", [Add(2, 3)]).
let add (x: int) (y: int) : int = x + y printfn "%d" (add 2 3)
Erlang has no compile-time type checking at all — Add("two", 3) would compile fine and only fail when that exact line executes. F#'s int -> int -> int signature means add "two" 3 is rejected before the program runs — caught by the compiler, not discovered in production.
Strong inference makes annotations mostly optional
Double = fun(X) -> X * 2 end, io:format("~p~n", [Double(21)]).
let double x = x * 2 printfn "%d" (double 21)
F# rarely needs the explicit annotations shown in the previous example — its Hindley-Milner-style inference deduces double: int -> int from how double is used, the same way Erlang never needed a type for Double at all. The difference: once F# infers a type, it locks it in and enforces it everywhere the function is called.
Pattern Matching
The closest `case`/`match` parallel on the site
Describe = fun Describe(0) -> "zero"; Describe(N) when N > 0 -> "positive"; Describe(_) -> "negative" end, io:format("~s~n", [Describe(-5)]).
let describe n = match n with | 0 -> "zero" | n when n > 0 -> "positive" | _ -> "negative" printfn "%s" (describe -5)
F#'s match expression and Erlang's function clauses are close enough to read as dialects of the same idea: patterns tried top-to-bottom, a when guard attached to a specific pattern, a wildcard _ catch-all. Of every language in this comparison, F# maps onto Erlang's pattern-matching instincts the most directly.
Destructuring a tuple
Result = {ok, 42}, {ok, Value} = Result, io:format("~p~n", [Value]).
let result = (true, 42) let (_, value) = result printfn "%d" value
Both languages destructure a tuple directly in a binding. Erlang tuples are untyped and any arity; F# tuples like bool * int are typed by position, so a mismatched destructure is caught at compile time instead of failing on an unlucky input at runtime.
The compiler checks your match is exhaustive
% Erlang: an incomplete case only fails when the missed input % actually arrives at runtime — a "no matching clause" crash: Classify = fun(N) -> case N of 0 -> zero; N when N > 0 -> positive % missing the negative case — Erlang won't warn you end end, io:format("~p~n", [Classify(5)]).
let classify n = match n with | 0 -> "zero" | n when n > 0 -> "positive" | _ -> "negative" // F# would emit a compiler WARNING here if the "_" case were missing — // an incomplete match is caught before the program ever runs. printfn "%s" (classify 5)
This is a genuine safety upgrade. An Erlang case missing a clause only reveals the gap when a real input finally hits it — a {case_clause, Value} crash in production. F#'s compiler statically analyzes whether a match covers every possibility and emits a warning (elevated to an error under strict settings) for an incomplete one, catching the exact bug Erlang can only discover at runtime.
Tagged Tuples vs. Discriminated Unions
Modeling "one of several shapes"
Shapes = [{circle, 5}, {rectangle, 3, 4}], Areas = lists:map(fun ({circle, Radius}) -> 3.14159 * Radius * Radius; ({rectangle, Width, Height}) -> Width * Height end, Shapes), io:format("~p~n", [Areas]).
type Shape = | Circle of float | Rectangle of float * float let area shape = match shape with | Circle radius -> 3.14159 * radius * radius | Rectangle (width, height) -> width * height let shapes = [Circle 5.0; Rectangle (3.0, 4.0)] printfn "%A" (List.map area shapes)
Erlang models "one of several shapes" as differently-tagged tuples, distinguished by their first element and arity at match time. F#'s type Shape = Circle of float | Rectangle of float * float formalizes exactly this pattern as a genuine, closed discriminated union — the compiler knows every possible case, and a match that misses one is a compile-time warning, not a runtime surprise.
Atoms vs. no-data union cases
Status = ok, io:format("~p~n", [Status]).
type Status = Ok | Failed let status = Ok printfn "%A" status
An Erlang atom like ok is an unstructured constant from a global, unbounded namespace of possible atoms. F#'s type Status = Ok | Failed gives the same "one fixed named value" idea, but scoped and typed — a Status can only ever be Ok or Failed, never an unrelated value from elsewhere in the program that happens to share the same name.
{ok/error} vs. Option/Result
`{ok, X}` becomes `Some x`
Lookup = fun(Key, List) -> case lists:keyfind(Key, 1, List) of {Key, Value} -> {ok, Value}; false -> error end end, io:format("~p~n", [Lookup(b, [{a, 1}, {b, 2}])]).
let entries = Map.ofList [("a", 1); ("b", 2)] printfn "%A" (Map.tryFind "b" entries)
Erlang's convention of returning {ok, Value} on success and a bare error on failure is exactly what F#'s built-in Option formalizes: Some value is {ok, Value}, and None is the failure case. Map.tryFind already returns an Option, baking the convention into the standard library rather than something you build by hand.
`{error, Reason}` becomes `Error reason`
Divide = fun(_, 0) -> {error, divide_by_zero}; (X, Y) -> {ok, X div Y} end, io:format("~p~n", [Divide(10, 0)]).
let divide x y = if y = 0 then Error "divide by zero" else Ok (x / y) printfn "%A" (divide 10 0)
Where Option only says "did it work," F#'s Result<'T, 'Error> carries a reason on failure — Error reason corresponds directly to Erlang's {error, Reason}, and Ok value corresponds to {ok, Value}. F# even reuses the bare word Ok, the closest naming match of any language in this comparison.
Skipping the failure case is a compile error
Divide = fun(_, 0) -> {error, divide_by_zero}; (X, Y) -> {ok, X div Y} end, % Nothing stops you from assuming success — this crashes with a % badmatch if Divide returns {error, _} instead of {ok, _}: try {ok, Value} = Divide(10, 0), io:format("~p~n", [Value]) catch error:{badmatch, _} -> io:format("crashed: badmatch~n") end.
let divide x y = if y = 0 then Error "divide by zero" else Ok (x / y) match divide 10 0 with | Ok value -> printfn "%d" value | Error reason -> printfn "Error: %s" reason
Nothing in Erlang forces you to handle the failure branch — skipping it just defers the crash until a bad input eventually reaches that assumption. Treating an F# Result<int, string> as a bare int is a type error the compiler catches immediately; matching both Ok and Error is the idiomatic way to satisfy it.
Records vs. Records
A record that is actually a distinct type
% Erlang records are compile-time sugar for tagged tuples — no runtime % checking beyond field-name shape: Point = {point, 3, 4}, {point, X, Y} = Point, io:format("~p and ~p~n", [X, Y]).
type Point = { X: int; Y: int } let point = { X = 3; Y = 4 } printfn "%d and %d" point.X point.Y
Erlang's -record(point, {x, y}). desugars to a tagged tuple at compile time — a naming convenience only, indistinguishable at runtime from any other tuple of the same shape. F#'s type Point = { X: int; Y: int } is a genuinely distinct type with structural equality and immutability built in, and — unlike Erlang — the compiler will never let a Point be confused with an unrelated two-field record.
"Updating" a record makes a new one
% Erlang: rebuild the tuple by hand, since nothing can be mutated in place: Point = {point, 3, 4}, {point, X, _} = Point, NewPoint = {point, X, 99}, io:format("~p~n", [NewPoint]).
type Point = { X: int; Y: int } let point = { X = 3; Y = 4 } let newPoint = { point with Y = 99 } printfn "%A" newPoint
Since neither an Erlang tuple nor an F# record can be mutated in place, "changing" one field always means producing a new value. F#'s { point with Y = 99 } copy-and-update syntax does this in one expression, copying every other field automatically — Erlang has no equivalent shorthand, so the same operation on a record means rebuilding the whole tuple by hand (or using the Point#point.y record-update syntax, which only exists inside a compiled module, not at shell-expression level).
Lists
Lists and the cons pattern
[Head | Tail] = [1, 2, 3], io:format("~p and ~p~n", [Head, Tail]).
let (head :: tail) = [1; 2; 3] printfn "%d and %A" head tail
Both languages model lists as singly-linked cons cells. Erlang spells the pattern [Head | Tail]; F# spells it head :: tail, the same :: cons operator F# lists are built from. F# lists must be homogeneous (one element type throughout); Erlang lists happily mix atoms, numbers, and tuples.
Map and filter
Doubled = lists:map(fun(N) -> N * 2 end, [1, 2, 3, 4, 5]), Evens = lists:filter(fun(N) -> N rem 2 =:= 0 end, Doubled), io:format("~p~n", [Evens]).
let doubled = List.map (fun n -> n * 2) [1; 2; 3; 4; 5] let evens = List.filter (fun n -> n % 2 = 0) doubled printfn "%A" evens
Both languages call these map and filter, with a near-identical argument order: (function, list) in both lists:map/2 and F#'s List.map. This is one of the closer standard-library vocabulary matches across the whole comparison.
Folding a list
Total = lists:foldl(fun(N, Accumulator) -> N + Accumulator end, 0, [1, 2, 3, 4, 5]), io:format("~p~n", [Total]).
let total = List.fold (fun accumulator n -> n + accumulator) 0 [1; 2; 3; 4; 5] printfn "%d" total
Erlang's lists:foldl/3 and F#'s List.fold take the same three pieces — function, initial accumulator, list — in the same order, another close standard-library match. Note the accumulator comes first inside F#'s lambda, opposite the element, exactly the convention lists:foldl's function argument also uses.
Recursion
Factorial
Factorial = fun Factorial(0) -> 1; Factorial(N) when N > 0 -> N * Factorial(N - 1) end, io:format("~p~n", [Factorial(5)]).
let rec factorial n = match n with | 0 -> 1 | n when n > 0 -> n * factorial (n - 1) printfn "%d" (factorial 5)
A base-case clause and a recursive clause, in the same order — nearly a mechanical translation. The one new keyword: F# requires rec to explicitly mark a function as recursive (let rec factorial), since ordinary let bindings in F# cannot refer to themselves — a small but real syntactic difference from Erlang, where every fun can always call itself by name.
Tail recursion with an accumulator
SumList = fun SumList([], Accumulator) -> Accumulator; SumList([Head | Tail], Accumulator) -> SumList(Tail, Head + Accumulator) end, io:format("~p~n", [SumList([1, 2, 3, 4, 5], 0)]).
let rec sumList numbers accumulator = match numbers with | [] -> accumulator | head :: tail -> sumList tail (head + accumulator) printfn "%d" (sumList [1; 2; 3; 4; 5] 0)
The accumulator-passing style every Erlang developer already relies on transfers unchanged. F# on .NET, like Erlang's BEAM, guarantees tail-call optimization for calls in tail position, so this runs in constant stack space in both languages — one of the few guarantees that carries over exactly, not just in spirit.
Higher-Order Functions & Currying
Functions as values
Apply = fun(Function, Value) -> Function(Value) end, Increment = fun(X) -> X + 1 end, io:format("~p~n", [Apply(Increment, 41)]).
let apply f value = f value let increment x = x + 1 printfn "%d" (apply increment 41)
Both languages treat functions as ordinary values, freely passed and stored. Notice F#'s apply needs no type annotations at all here — inference figures out that f must be a function from increment's usage, staying just as terse as the Erlang original.
Every function is curried automatically — like Haskell, unlike Erlang
% Erlang funs have a fixed arity — building a partial application means % wrapping it in a new fun by hand: Add = fun(X, Y) -> X + Y end, AddFive = fun(Y) -> Add(5, Y) end, io:format("~p~n", [AddFive(10)]).
let add x y = x + y let addFive = add 5 printfn "%d" (addFive 10)
Here the languages genuinely diverge. Erlang requires writing the wrapping fun by hand to get a one-argument version of a two-argument function. In F#, add 5 alone is already valid — every function is secretly a chain of one-argument functions, so supplying fewer arguments than expected simply returns another function, with no wrapping required.
Pipelines
The `|>` pipeline operator
% Erlang has no pipe operator — nested calls read inside-out, % or a temporary variable is introduced for each step: Numbers = [1, 2, 3, 4, 5], Doubled = lists:map(fun(N) -> N * 2 end, Numbers), Evens = lists:filter(fun(N) -> N rem 2 =:= 0 end, Doubled), io:format("~p~n", [Evens]).
[1; 2; 3; 4; 5] |> List.map (fun n -> n * 2) |> List.filter (fun n -> n % 2 = 0) |> printfn "%A"
This is a real ergonomic gap. Erlang has no pipe operator at all — a chain of transformations either nests calls inside-out or introduces a fresh temporary variable per step, as shown. F#'s |> threads a value through a sequence of function calls left to right, reading in the same order the transformations actually happen — closer to Elixir's |> than anything native to Erlang itself.
Immutability by Default
`let` is close to, but not identical to, Erlang binding
X = 40, % X = 41 would be a {badmatch,41} error — Erlang variables bind once, period: io:format("~p~n", [X]).
let x = 40 // x <- 41 below would need x declared "mutable" first — plain // "let x = 41" here would just SHADOW x in a new scope: printfn "%d" x
Erlang variables genuinely cannot be rebound in the same scope — it is a match failure, enforced everywhere. F#'s let gives the same immutability by default, but a second let x = ... in a nested scope shadows rather than rebinds — a subtly different failure mode than Erlang's, though the net effect (the original value is never mutated) is the same.
Mutation exists, but must be requested explicitly
% Erlang has no mutable variable at all — "changing" a value always % means a new binding or a new recursive call argument: Count = 0, NewCount = Count + 1, io:format("~p~n", [NewCount]).
let mutable count = 0 count <- count + 1 printfn "%d" count
This is a genuine capability gap: F#'s mutable keyword and <- assignment operator allow real, in-place mutation — something no Erlang construct can do, even Erlang's process state is really "a new recursive call with a new argument." F# requires opting into mutability explicitly per binding; Erlang structurally cannot offer it at all.
MailboxProcessor: F#'s Erlang-Inspired Agents
F# Agents were explicitly inspired by Erlang
% Erlang: spawn an isolated process with its own mailbox self() ! {greeting, "hello"}, receive {greeting, Message} -> io:format("~s~n", [Message]) end.
let agent = MailboxProcessor.Start(fun inbox -> let rec loop () = async { let! message = inbox.Receive() printfn "%s" message return! loop () } loop ()) agent.Post("hello") System.Threading.Thread.Sleep(100)
F#'s MailboxProcessor ("F# Agents") is explicitly documented as bringing Erlang-style actors to .NET: each agent has its own mailbox, processes messages sequentially with inbox.Receive(), and is sent messages asynchronously with Post — a direct structural echo of Erlang's spawn/!/receive. Unlike Erlang processes, though, .NET agents still share the same memory space, so the isolation Erlang gets for free is not automatic here.
Gotchas for Erlang Developers
A "string" means something different again
% Erlang: "hello" is a LIST of character codes: Value = "hello", io:format("~w~n", [Value]). % shows [104,101,108,108,111]
let value = "hello" // a real, immutable .NET System.String printfn "%s" value
Erlang's "hello" is secretly [104, 101, 108, 108, 111], a plain list of integer code points — which is why ~w ("write the raw term") shows the numbers, while ~p/~s reprint it as text. F#'s "hello" is a genuine, immutable UTF-16 System.String from the .NET base class library — no code-list representation lurking underneath.
`null` still exists, unlike in Erlang
% Erlang has no null — the closest equivalent is the atom undefined, % which is just an ordinary atom with no special language treatment: Value = undefined, io:format("~p~n", [Value]).
let value: string = null // legal for reference types inherited from .NET, though discouraged printfn "%b" (isNull value)
Erlang has no null concept whatsoever — undefined is just a regular atom with no compiler support behind it. F#, inheriting the .NET runtime, still permits null for reference types (though idiomatic F# avoids it almost entirely in favor of Option, and F# 9's nullability analysis increasingly warns against it).
Build tools: rebar3/Mix vs. `dotnet`
% rebar3 (Erlang's build tool): % rebar3 new app myapp — create new project % rebar3 compile — compile % rebar3 shell — start a shell with the project loaded % rebar3 eunit — run tests % rebar3 release — build a deployable release
// dotnet (F#'s build tool, shared with the rest of .NET): // dotnet new console -lang F# — create new project // dotnet build — compile // dotnet fsi — start an interactive F# REPL // dotnet test — run tests // dotnet publish — build a deployable binary
Both ecosystems bundle project creation, compiling, an interactive shell, testing, and producing a deployable artifact behind one command-line tool. dotnet fsi ("F# Interactive") plays exactly the role rebar3 shell does — a REPL with your project already loaded, for quick experiments without a full compile cycle.