Dive C2.09.1
The functional core
Typed data, pure functions. The part with no surprises, and no setup to test.
The core of the lab is the part that does the thinking and nothing else. It holds the
domain types — a Tier discriminated union whose three cases are the only
legal tiers, and a Customer record — and the pure functions over them: a
discount defined by a match on the tier, and a
totalDiscount that folds a list of customers. No reading, no writing, no clock, no
null. A function in the core takes its input and returns its output, the same way every
time — which is what makes it trivial to unit-test, and what this dive builds first.
Model the domain so illegal states cannot be built
The first move is to name the domain in types. A loyalty tier is one of exactly three things, so it is a
discriminated union with three cases — not a string, not an
int code. A customer is a small bundle of fields, so it is a record.
Together they make the illegal value unrepresentable: there is no fourth tier to mistype, because the type has
only three names.
type Tier = Bronze | Silver | Gold type Customer = { Id: int; Name: string; Tier: Tier }
The discount function reads the rate off the tier with a
match. Because Tier is a closed union, the match is checked for
completeness: cover all three cases and it compiles silently; add a fourth tier later and leave this match
unchanged, and the compiler reports FS0025, an incomplete pattern. The math cannot
silently fall out of step with the type.
let discount tier = // val discount : Tier -> float match tier with | Bronze -> 0.0 | Silver -> 0.05 | Gold -> 0.10 discount Gold // val it : float = 0.1 discount Silver // val it : float = 0.05 discount Bronze // val it : float = 0.0
- Tier
- a discriminated union with three cases — the closed set of legal tiers, with no payload.
- Customer
- a record: named, immutable fields.
{ Id = 1; Name = "Ann"; Tier = Gold }is one value. - discount
- a pure function
Tier -> floatdefined by a totalmatch. - FS0025
- the incomplete-match warning — the compiler's nudge when a new case is added but a match is not updated.
The C# the reader knows, beside the F#
A C# team would reach for a record and a switch expression,
and it reads almost the same. The difference is in the model. C# typically carries the tier as a
string (or an enum, which has the same open-ended numeric
backing): the switch needs a default arm because any string is a legal value,
and a typo like "gold" slips through to the default with no warning. The F# union has
three names and no others, and the match is exhaustive over exactly them.
C# — a record and a switch expression over a string
record Customer(int Id, string Name, string Tier); double Discount(string tier) => tier switch { "Gold" => 0.10, "Silver" => 0.05, _ => 0.0, // mandatory — and "gold" lands here, silently }; Discount("Gold"); // 0.1
F# — a discriminated union and a total match
type Tier = Bronze | Silver | Gold type Customer = { Id: int; Name: string; Tier: Tier } let discount tier = // val discount : Tier -> float match tier with | Bronze -> 0.0 | Silver -> 0.05 | Gold -> 0.10 // no default; the three cases ARE the type discount Gold // val it : float = 0.1
The C# switch over a class hierarchy is the same story (C2.04): it is
open, so the _ arm is mandatory and a new subclass triggers no warning.
The F# match over a discriminated union is closed, so the compiler
checks every arm against a fixed set of cases.
Fold the list, do not loop it
The whole-collection step is a fold. totalDiscount takes a list of customers and
sums each one's discount with List.sumBy — the projection-then-sum that a C# team
writes as a for loop with a running total, or as
customers.Sum(c => Discount(c.Tier)) in LINQ (C2.05, C2.07). The loop names
how; sumBy names what. The result over the two fixed customers — a
Gold and a Bronze — is 0.1.
let customers = [ { Id = 1; Name = "Ann"; Tier = Gold } { Id = 2; Name = "Ben"; Tier = Bronze } ] let totalDiscount custs = // val totalDiscount : Customer list -> float custs |> List.sumBy (fun c -> discount c.Tier) totalDiscount customers // val it : float = 0.1
Every function here is pure: discount Gold is
0.1 on every call, and totalDiscount customers is
0.1 on every call, with nothing read from outside and nothing written. That property —
same input, same output, no side effect — is what lets a C# team write a unit test with no mocks, no fixtures,
and no setup: assert the answer and move on.
The C# starting point
A record, a switch with a default arm, and a for loop or .Sum(…) — correct, but open over its inputs.
The F# core
A discriminated union, a total match, and a List.sumBy fold — a pure value-in, value-out function the compiler keeps honest.
Run discount and totalDiscount
Set each customer's tier. The diagram lights the match arm that
discount takes for the active customer, and the readout shows the exact FSI value for
that arm and the folded totalDiscount over both customers. The fold is the sum of the
two rates: change a tier and watch the total move. Nothing here reads a file or a clock — the answer depends
only on the input.
discount c.Tier · the matched arm lights
| tier | discount tier | note |
|---|---|---|
| Bronze | 0.0 | Ben's default tier |
| Silver | 0.05 | — |
| Gold | 0.10 | Ann's default tier |
| totalDiscount [Gold; Bronze] | 0.1 | |
The core is a closed set of types and a handful of total functions over them. Read the same input, get the same answer — which is the whole reason it tests in one line.
Where this leads
The core computes, but it does not yet read a tier from anywhere or print the total. Those are side effects, and they belong in the next layer — the imperative shell, the thin band that loads input, calls this core, and renders the result. That is the next dive; the dive after assembles both into one runnable program.
References
Sources
- Microsoft — F# discriminated unions — the closed-case unions the
Tiermodel is built on. - Microsoft — F# records — the named, immutable fields of the
Customertype. - FSharp.Core API reference —
List.sumByand the collection folds.
Related in this course
- C2.09.2 — The imperative shell — where the I/O and contained mutation around this core go.
- C1.06 — Discriminated unions & records — the two type forms, in language depth.
- C2.02 — Immutability & expressions — the immutable values the core is made of.