Dive C3.01.3
What carries over, what changes
Three buckets: what you keep, what you gain, and the one thing you set down.
With both runtimes named, the move to F# sorts into three buckets. Kept: the
functional core transfers almost whole — immutability by default, the |> pipe,
pattern matching, and even the actor's shape, since the MailboxProcessor is a
mailbox with a receive loop. Gained: the type system does real work — types are inferred
and checked at compile time, and a match that misses a case is a compiler warning
rather than a runtime surprise. Given up: OTP-style supervision. The CLR isolates with
exceptions, not with supervised processes, and there is no built-in supervision tree — the resilience an
Elixir developer gets from OTP is the one thing an F# program must build or adopt deliberately. This dive
reads each bucket beside real Elixir.
Kept — the functional core
Most of what an Elixir developer reaches for has a near-identical F# form. The |>
pipe is the same operator, threading a value left to right through a chain of functions; Elixir's
Enum maps onto F#'s List (or Seq).
Pattern matching is the same idea, with {:ok, v} becoming the
Ok case of a typed Result. Data is immutable in both. The
snippet below is the pipe, both ways — and the F# value was produced by dotnet fsi.
Elixir — the pipe and Enum
# Elixir [1, 2, 3, 4, 5] |> Enum.map(fn x -> x * 2 end) |> Enum.sum() # => 30
F# — the same pipe, List instead of Enum (verified in FSI)
// F# [ 1; 2; 3; 4; 5 ] |> List.map (fun x -> x * 2) |> List.sum // val it : int = 30
- the pipe
|>is identical:x |> fisf x, read left to right.- Enum → List/Seq
- Elixir's
Enumfunctions map onto F#'sListandSeqmodules. - matching
- the same shape;
{:ok, v}becomesOk v, a case of a typed union. - immutability
- default in both; a "change" produces a new value rather than mutating the old.
Gained — the compiler checks more
The change that an Elixir developer notices most is the type system, and the gain it is clearest on is
exhaustiveness. In Elixir, a case that omits a clause raises only
if that value actually arrives at run time. In F#, the compiler reads the union, sees a case is unhandled,
and warns at compile time — before the program runs. The match below is total over a three-case union; drop
an arm and the build reports an incomplete match.
Elixir — a case over atoms (a missed clause raises at run time)
# Elixir — dynamic; only fails if :amber actually arrives case light do :red -> :green :green -> :amber end
F# — an exhaustive match the compiler checks (verified in FSI)
type Light = Red | Amber | Green let next = function | Red -> Green | Amber -> Red | Green -> Amber next Red // val it : Light = Green // omit the Amber arm and the compiler reports FS0025: incomplete match
next is
Light -> Light with no annotation, the same "no ceremony" an Elixir developer
values. What is added is that the inference is static: it happens at compile time and the compiler
reasons about it, including whether a match covers every case.Given up — OTP supervision
The actor's shape carries over: the MailboxProcessor below holds state in
a recursive async loop and answers messages, exactly like the BEAM's looping
process. What does not carry over is the runtime around it. On the BEAM, that looping process is supervised:
a crash is isolated, and a supervisor restarts it to a known state. The CLR has no built-in supervision
tree. An unhandled exception in the agent's loop ends the loop; nothing restarts it unless the program
arranges to. The resilience pattern itself — isolate, crash, restart — is the thing an F# system rebuilds
deliberately (with a restart wrapper, a host's lifetime management, or a library), rather than inherits.
Elixir — a supervised GenServer
# Elixir — GenServer under a supervisor def handle_cast({:incr, n}, total), do: {:noreply, total + n} def handle_call(:total, _from, total), do: {:reply, total, total} # a crash is isolated; the supervisor restarts to a known state
F# — a MailboxProcessor: same shape, no supervisor (verified in FSI)
type Msg = Incr of int | Total of AsyncReplyChannel<int> let counter = MailboxProcessor.Start(fun inbox -> let rec loop total = async { let! msg = inbox.Receive() match msg with | Incr n -> return! loop (total + n) | Total reply -> reply.Reply total; return! loop total } loop 0) counter.Post(Incr 5) counter.Post(Incr 3) counter.PostAndReply(fun ch -> Total ch) // val it : int = 8 // no supervisor: an unhandled exception in loop ends it
MailboxProcessor gives
the actor's mailbox and receive loop, but not OTP — no supervision tree, no "let it crash" restart for free.
An F# system that needs that resilience builds it on top of the runtime's primitives, rather than receiving
it from the platform.Sort each habit into a bucket
Select a bucket. The diagram highlights it; the readout names what falls in it and the F# construct that meets it. Kept transfers unchanged, gained is what the type system adds, and given up is the one capability — OTP supervision — the CLR leaves to the program.
Kept · gained · given up — select one
|>, match, MailboxProcessor| bucket | what falls in it | F# construct |
|---|---|---|
| kept | immutability, the |> pipe, pattern matching, the actor's mailbox-and-loop shape | |> · match · MailboxProcessor |
| gained | static types inferred and checked at compile time; exhaustive-match warnings | type inference · FS0025 incomplete-match |
| given up | OTP-style supervision and "let it crash" — no built-in supervision tree on the CLR | build it: restart wrapper · host lifetime · a library |
The language comes with you; the platform does not. Keep the pipe and the match, accept the compiler's help, and plan for the resilience the BEAM gave for free.
The seam, named
That is the module's argument in one line: F# the language is close to Elixir the language, and the CLR
the runtime is not the BEAM. The rest of C3 builds on the kept and gained columns — static types
(C3.02), pattern matching sharpened (C3.03), the pipe (C3.04), records and unions (C3.05), and
Result / Option (C3.06) — and returns to the actor in
C3.07, where the MailboxProcessor gets its own module and the OTP gap is faced
directly.
From Elixir
A functional vocabulary on a process-oriented runtime with supervision built in.
To F#
The same vocabulary, statically typed, on a shared managed runtime — with supervision left to the program.
References
Sources
- Microsoft — F# documentation — pattern matching and exhaustiveness, the pipe, and the
MailboxProcessor. - FSharp.Core API reference —
List,MailboxProcessor, and theAsyncmodule. - Microsoft — .NET documentation — exceptions and the threading model the CLR uses in place of supervision.
- The F# Software Foundation — F# on the .NET runtime.
Related in this course
- C3 — F# for Elixir developers — the chapter this module opens; the kept/gained columns are its later modules.
- Elixir — Processes & the actor model — the supervised process the
MailboxProcessorechoes. - Elixir — The BEAM's evolution — where the OTP resilience this dive sets down comes from.
- C3.01.2 — The CLR — the managed runtime that does without supervision.