jonnifyfsharp

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.

C2.09.1 · part 1

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 -> float defined by a total match.
FS0025
the incomplete-match warning — the compiler's nudge when a new case is added but a match is not updated.
C2.09.1 · part 2

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.

C2.09.1 · part 3

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

Ann
Ben
match tier with | Bronze -> 0.0 | Silver -> 0.05 | Gold -> 0.10 totalDiscount customers = List.sumBy discount 0.1
Ann = Gold · discount Gold = 0.10 · Ben = Bronze · discount Bronze = 0.00 · totalDiscount customers = 0.1
Without JavaScript: the discount of each tier, and the fold of the default two customers
tierdiscount tiernote
Bronze0.0Ben's default tier
Silver0.05—
Gold0.10Ann'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

Related in this course