jonnifyfsharp

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.

C3.01.3 · part 1

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 |> f is f x, read left to right.
Enum → List/Seq
Elixir's Enum functions map onto F#'s List and Seq modules.
matching
the same shape; {:ok, v} becomes Ok v, a case of a typed union.
immutability
default in both; a "change" produces a new value rather than mutating the old.
C3.01.3 · part 2

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
The type itself is inferred — 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.
C3.01.3 · part 3

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
This is the honest cost of the move. The 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

kept pipe · match · immutability · actor shape gained static types · exhaustiveness given up OTP supervision · “let it crash”
kept · the functional core transfers almost whole — immutability, the |> pipe, pattern matching, and the actor's mailbox-and-loop shape. F#: |>, match, MailboxProcessor
Without JavaScript: the three buckets
bucketwhat falls in itF# construct
keptimmutability, the |> pipe, pattern matching, the actor's mailbox-and-loop shape|> · match · MailboxProcessor
gainedstatic types inferred and checked at compile time; exhaustive-match warningstype inference · FS0025 incomplete-match
given upOTP-style supervision and "let it crash" — no built-in supervision tree on the CLRbuild 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.

C3.01.3 · the close

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

Related in this course