F# Module 4 – Modelling data with types
Structural equality, comparison and hashing
Predict when F# compares values by contents or by reference, why nan = nan is false while compare says 0, and how to keep hashes and custom keys right.
What you will learn
- Predict whether = compares two F# values by their contents or by reference
- Predict the order compare gives records, union cases and strings, and sort names for readers
- Explain why nan = nan is false while compare nan nan is 0, and what that does to Set, List and dict
- Write a hash that agrees with equality, and keep string hashes out of anything stored
- Choose an equality attribute for a type, or write custom equality and comparison that agree
Before you start
What you’ll learn · Before you start
What you will learn
- Predict whether = compares two F# values by their contents or by reference
- Predict the order compare gives records, union cases and strings, and sort names for readers
- Explain why nan = nan is false while compare nan nan is 0, and what that does to Set, List and dict
- Write a hash that agrees with equality, and keep string hashes out of anything stored
- Choose an equality attribute for a type, or write custom equality and comparison that agree
Before you start
On this page
Two bookings for coach S4, berth 21, built in two places of a program: are they the same booking? F# answers yes for
records, tuples, lists and unions, which compare their contents, and no for classes, which compare identity unless the
class says otherwise. That one rule decides what every =, Set, Map and List.distinct in your code does.
This lesson measures each rule on .NET 10: the order compare gives records and union cases, why nan = nan is false
while compare nan nan is 0, so that a Set and a list disagree about the same three numbers, why a string’s hash
changed between two runs of one script, and how a key type with its own Equals but no matching hash let a duplicate
into a HashSet. You will finish with an email key that behaves in a Set and a Map.
Records, unions and lists compare their contents, classes their identity
equality.fsx builds pairs of values from the same data, twice each, and compares every pair three ways: with F#’s
=, with .NET’s Equals, and by asking whether the two are one object:
// Two values built separately from the same data: equal by contents, or only when they are the same object?
type Seat = { Coach: string; Berth: int }
type SeatClass(coach: string, berth: int) =
member _.Coach = coach
member _.Berth = berth
let show (what: string) (x: obj) (y: obj) (equal: bool) =
printfn "%-8s %-6b %-7b %b" what equal (x.Equals y) (obj.ReferenceEquals(x, y))
printfn "%-8s %-6s %-7s %s" "two" "=" "Equals" "same object"
show "records" { Coach = "S4"; Berth = 21 } { Coach = "S4"; Berth = 21 } ({ Coach = "S4"; Berth = 21 } = { Coach = "S4"; Berth = 21 })
show "tuples" ("S4", 21) ("S4", 21) (("S4", 21) = ("S4", 21))
show "lists" [ 21; 22 ] [ 21; 22 ] ([ 21; 22 ] = [ 21; 22 ])
show "arrays" [| 21; 22 |] [| 21; 22 |] ([| 21; 22 |] = [| 21; 22 |])
show "unions" (Some "S4") (Some "S4") (Some "S4" = Some "S4")
show "classes" (SeatClass("S4", 21)) (SeatClass("S4", 21)) (SeatClass("S4", 21) = SeatClass("S4", 21))
let seat = SeatClass("S4", 21)
printfn "a class equals itself: %b" (seat = seat) from session import run
# equality.fsx compares six kinds of value with =, with .NET's Equals and by identity.
run("""
dotnet fsi equality.fsx
""") Output
$ dotnet fsi equality.fsx two = Equals same object records true true false tuples true true false lists true true false arrays true false false unions true true false classes false false false a class equals itself: true
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 equality_session.py
The last column is false on every row: each pair is two separate objects. Yet records, tuples, lists and unions were
= all the same, because F# gives them structural equality: Comparing two values by their contents: two records are equal when every field is equal, even if they are separate objects. F# records, unions and tuples compare this way by default. More in “Tuples, records and anonymous records”: two values are
equal when their parts are equal, field by field and item by item. The compiler writes the Equals and GetHashCode
of every record and union for you, which is why Equals agreed on those rows.
Classes keep reference equality: Equality by identity: two references are equal only when they point to the same object, as obj.ReferenceEquals tests. F# classes compare this way unless they override Equals.: two SeatClass objects with the same coach and
berth were unequal, while a seat compared with itself was equal. A class compares by contents only when it overrides
Equals, which is the usual reason to model data as a record instead.
Arrays are the row to remember. F#’s = compares their elements, so two arrays of 21 and 22 were equal, but .NET’s
Equals on an array compares references, so the same pair was unequal by Equals.
Comparing arrays with .Equals out of C# habit
Wrong: xs.Equals ys on two arrays, which said false for [| 21; 22 |] and [| 21; 22 |], because .NET compares
the two references. Right: xs = ys in F#, which compares the elements and said true.
Checkpoint
Check: Structural and reference equality
compare reads fields in declaration order and cases from top to bottom
Records and unions also get structural comparison: Ordering F# records, unions, tuples and lists by their contents, as compare and List.sort do: a record's fields from first to last as declared, union cases in declaration order, list items from the front., which compare, List.sort,
max, Set and Map all use. ordering.fsx sorts seats, priorities and names with it:
open System
type Seat = { Coach: string; Berth: int }
// The same two fields, declared in the other order.
type SeatByBerth = { Berth: int; Coach: string }
type Priority = Low | Medium | High
let seats: Seat list = [ { Coach = "S5"; Berth = 3 }; { Coach = "B1"; Berth = 40 }; { Coach = "S5"; Berth = 1 } ]
printfn "compare S4-21 S4-22: %d" (compare { Coach = "S4"; Berth = 21 } { Coach = "S4"; Berth = 22 })
printfn "sorted by Seat: %s" (seats |> List.sort |> List.map (fun s -> $"{s.Coach}-{s.Berth}") |> String.concat " ")
let byBerth = seats |> List.map (fun s -> { SeatByBerth.Berth = s.Berth; Coach = s.Coach })
printfn "sorted by SeatByBerth: %s" (byBerth |> List.sort |> List.map (fun s -> $"{s.Coach}-{s.Berth}") |> String.concat " ")
printfn "%A, compare Low High = %d" (List.sort [ High; Low; Medium ]) (compare Low High)
let names = [ "bala"; "Arjun"; "anu"; "Bhavna" ]
printfn "List.sort: %A" (List.sort names)
printfn "InvariantCulture: %A" (List.sortWith (fun x y -> String.Compare(x, y, StringComparison.InvariantCulture)) names)
printfn "compare \"a\" \"B\" = %d" (compare "a" "B") from session import run
# ordering.fsx sorts records, union cases and names with F#'s compare, then names for readers.
run("""
dotnet fsi ordering.fsx
""") Output
$ dotnet fsi ordering.fsx compare S4-21 S4-22: -1 sorted by Seat: B1-40 S5-1 S5-3 sorted by SeatByBerth: S5-1 S5-3 B1-40 [Low; Medium; High], compare Low High = -2 List.sort: ["Arjun"; "Bhavna"; "anu"; "bala"] InvariantCulture: ["anu"; "Arjun"; "bala"; "Bhavna"] compare "a" "B" = 31
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ordering_session.py
A record compares its fields in the order the type declares them and stops at the first field that differs. Step through the first line of the output:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Fields in declaration order. The generated comparison of Seat looks at Coach first, because the type declares Coach first.
- The coaches are equal. Both coaches are "S4", so compare cannot decide yet and moves on to the next field.
- The berths decide. Berth 21 is less than 22, so this field decides the whole comparison.
- A negative number. compare printed -1: a comes first. Only the sign is promised; compare Low High gave -2.
- What a sort does with it. List.sort uses the same comparison, so the seats came out B1-40 S5-1 S5-3: by coach, then by berth.
- Swap the fields. SeatByBerth declares Berth first. The same three seats then sorted S5-1 S5-3 B1-40, by berth only.
Seat declares Coach first, so the seats sorted by coach: B1-40 S5-1 S5-3. SeatByBerth holds the same two fields
in the other order and sorted the same seats by berth: S5-1 S5-3 B1-40. Reordering a record’s fields is therefore a
change of behaviour, not of style. Union cases compare by their order in the type, so [High; Low; Medium] sorted to
[Low; Medium; High], and compare Low High returned -2: only the sign of a comparison is promised, so test
compare a b < 0, never = -1.
Strings are compared by ordinal comparison: Comparing strings by the numbers of their UTF-16 code units, as StringComparison.Ordinal and F#'s compare do. It is fast and the same in every culture, but puts every capital letter before every small one. More in “Strings, formatting and culture-safe output”, by the numbers of their characters.
Every capital letter comes before every small one, so List.sort put Arjun and Bhavna before anu and bala, and
compare "a" "B" was 31. For a list a person reads, pass the culture’s rules: String.Compare with
StringComparison.InvariantCulture gave anu, Arjun, bala, Bhavna.
Key idea
The generated comparison follows the declaration: fields from first to last, union cases from top to bottom, and strings by character code, so the type’s layout is its sort order.
Checkpoint
Check: The order compare gives
nan is not equal to itself, yet compare calls two nans equal
NaN: Not a number: the float value that 0.0 / 0.0 and other undefined operations produce. NaN is not equal to anything, itself included, so nan = nan is false; test for it with Double.IsNaN. More in “Numbers, arithmetic and explicit conversions” is the one value whose equality and comparison disagree. nan.fsx passes it through every way F# and .NET compare numbers:
open System.Collections.Generic
// One float that is not a number, through every way F# and .NET compare values.
let row (what: string) (result: string) = printfn "%-36s %s" what result
row "nan = nan" (sprintf "%b" (nan = nan))
row "[nan] = [nan]" (sprintf "%b" ([ nan ] = [ nan ]))
row "compare nan nan" (sprintf "%d" (compare nan nan))
row "compare nan 1.0" (sprintf "%d" (compare nan 1.0))
row "nan < 1.0, nan > 1.0" (sprintf "%b, %b" (nan < 1.0) (nan > 1.0))
row "Set.ofList [nan; nan; 1.0]" (sprintf "%d items" (Set.ofList [ nan; nan; 1.0 ]).Count)
row "List.distinct [nan; nan; 1.0]" (sprintf "%d items" (List.distinct [ nan; nan; 1.0 ]).Length)
row "Set.contains nan" (sprintf "%b" (Set.ofList [ nan; 1.0 ] |> Set.contains nan))
row "List.contains nan" (sprintf "%b" (List.contains nan [ nan; 1.0 ]))
row "F# dict: ContainsKey nan" (sprintf "%b" ((dict [ (nan, "a") ]).ContainsKey nan))
row ".NET Dictionary: ContainsKey nan" (sprintf "%b" (let d = Dictionary<float, string>() in d[nan] <- "a"; d.ContainsKey nan))
row ".NET HashSet [nan; nan; 1.0]" (sprintf "%d items" (HashSet [ nan; nan; 1.0 ]).Count)
row "List.sort [2.0; nan; 1.0]" (sprintf "%A" (List.sort [ 2.0; nan; 1.0 ]))
row "List.max [nan; 2.0; 1.0]" (sprintf "%A" (List.max [ nan; 2.0; 1.0 ]))
row "List.max [2.0; nan; 1.0]" (sprintf "%A" (List.max [ 2.0; nan; 1.0 ]))
row "Double.IsNaN nan" (sprintf "%b" (System.Double.IsNaN nan)) from session import run
# nan.fsx: nan under =, compare, F# collections and .NET collections.
run("""
dotnet fsi nan.fsx
""") Output
$ dotnet fsi nan.fsx nan = nan false [nan] = [nan] false compare nan nan 0 compare nan 1.0 -1 nan < 1.0, nan > 1.0 false, false Set.ofList [nan; nan; 1.0] 2 items List.distinct [nan; nan; 1.0] 3 items Set.contains nan true List.contains nan false F# dict: ContainsKey nan false .NET Dictionary: ContainsKey nan true .NET HashSet [nan; nan; 1.0] 2 items List.sort [2.0; nan; 1.0] [nan; 1.0; 2.0] List.max [nan; 2.0; 1.0] nan List.max [2.0; nan; 1.0] 2.0 Double.IsNaN nan true
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 nan_session.py
nan = nan was false and so was [nan] = [nan], because F#’s = follows the floating-point rule that NaN equals
nothing; the LanguagePrimitives reference calls this partial equivalence relation semantics. compare nan nan was 0
and compare nan 1.0 was -1, because comparison needs a total order to sort, so it treats NaN as equal to itself and
below every number. .NET’s own Double.Equals sides with comparison: its documentation says two NaN values are equal
by Equals and unequal by the equality operator. Step through which functions use which rule:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Two rules for one value. F#'s = follows the floating-point rule, so nan = nan is false. compare needs an order it can sort by, so it calls two nans equal and puts nan below every number.
- Removing duplicates. List.distinct asks =, which never finds a nan equal to another, so all 3 items stay. Set.ofList asks compare, which calls them equal, so the set holds 2.
- Looking a value up. List.contains nan uses = and found nothing, although a nan is in the list. Set.contains uses compare and found it.
- Keys in a dictionary. F#'s dict uses F#'s equality and hash, so a nan key is never found again. A .NET Dictionary uses Double.Equals, which says two nans are equal, and found it.
- Order changes the answer. List.max asks whether each item is greater than the largest so far, which is false whenever nan is involved: [nan; 2.0; 1.0] gave nan, [2.0; nan; 1.0] gave 2.0.
So the same three numbers made a Set of 2 items and a List.distinct of 3, Set.contains nan was true while
List.contains nan was false, and a NaN key was found in a .NET Dictionary but never in F#’s dict. Even
List.max changed its answer with the order of the list: nan for [nan; 2.0; 1.0] and 2.0 for [2.0; nan; 1.0],
because NaN is neither greater nor smaller than 2.0.
Testing for NaN with =
Wrong: if reading = nan then …, whose test is false for every reading, a NaN included, so the check never fires. Right:
Double.IsNaN reading, which is true for NaN only. Test it where the data comes in, and decide there whether a NaN
is dropped, reported or kept.
Checkpoint
Check: nan under equality and comparison
A hash must follow equality, and a string’s hash changes between runs
A hash code: An int computed from a value so that a hash table can find it quickly. Equal values must have equal hash codes, while unequal values may share one; F#'s hash and .NET's GetHashCode compute it. lets a HashSet, a Dictionary or F#’s dict find a value without comparing
it with every other: the hash picks a bucket, and Equals runs only on the items there that have the same hash. The
FSharp.Core reference describes hash as designed to give equal hashes to values that are equal by =. hashing.fsx
runs twice to see which hashes also stay the same from one run to the next, and badkey.fsx breaks the rule on purpose:
open System.IO
type Seat = { Coach: string; Berth: int }
// Run it twice: the first run saves its hashes, the second compares its own with them.
let file = "last-run.txt"
let names = [ "hash 42"; "hash (1, 2)"; "hash [1; 2; 3]"; "hash \"S4\""; "hash of a Seat record" ]
let now = [ hash 42; hash (1, 2); hash [ 1; 2; 3 ]; hash "S4"; hash { Coach = "S4"; Berth = 21 } ]
printfn "hash 42 = %d, hash (1, 2) = %d, hash [1; 2; 3] = %d" now[0] now[1] now[2]
printfn "two equal records, equal hashes in this run: %b" (hash { Coach = "S4"; Berth = 21 } = hash { Coach = "S4"; Berth = 21 })
if File.Exists file then
let before = File.ReadAllLines file |> Array.map int |> List.ofArray
for name, b, n in List.zip3 names before now do
printfn "%-22s same as in the last run: %b" name (b = n)
else
File.WriteAllLines(file, now |> List.map string)
printfn "hashes saved for the next run" open System
open System.Collections.Generic
// Two spellings of one address should be one key. Equals ignores case, but nothing tells the hash to.
[<CustomEquality; NoComparison>]
type Email =
{ Address: string }
override this.Equals other =
match other with
| :? Email as e -> String.Equals(this.Address, e.Address, StringComparison.OrdinalIgnoreCase)
| _ -> false
let a = { Address = "Asha.Rao@Example.com" }
let b = { Address = "asha.rao@example.com" }
printfn "a = b: %b" (a = b)
printfn "same hash: %b" (hash a = hash b)
printfn "HashSet [a; b] holds %d" (HashSet [ a; b ]).Count from session import run
# hashing.fsx twice, so the second run can compare its hashes with the first; then a key type whose hash disagrees
# with its equality.
run("""
dotnet fsi hashing.fsx
dotnet fsi hashing.fsx
dotnet fsi badkey.fsx
""") Output
$ dotnet fsi hashing.fsx hash 42 = 42, hash (1, 2) = 35, hash [1; 2; 3] = 2535 two equal records, equal hashes in this run: true hashes saved for the next run $ dotnet fsi hashing.fsx hash 42 = 42, hash (1, 2) = 35, hash [1; 2; 3] = 2535 two equal records, equal hashes in this run: true hash 42 same as in the last run: true hash (1, 2) same as in the last run: true hash [1; 2; 3] same as in the last run: true hash "S4" same as in the last run: false hash of a Seat record same as in the last run: false $ dotnet fsi badkey.fsx badkey.fsx(6,6): warning FS0346: The struct, record or union type 'Email' has an explicit implementation of 'Object.Equals'. Consider implementing a matching override for 'Object.GetHashCode()' a = b: true same hash: false HashSet [a; b] holds 2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hashing_session.py
hash 42 was 42, hash (1, 2) was 35 and hash [1; 2; 3] was 2535 in both runs. The string’s hash and the hash of a
record holding that string changed between the runs, although the code and the text were the same: .NET randomises
string hashes per process. The String.GetHashCode documentation is blunt about the consequence: a hash code is only
good inside the process that computed it, so it must never be stored, sent or used as a key that outlives a run.
Within one run, two equal records still had equal hashes, which is all a hash table needs.
badkey.fsx shows the other half of the rule. Its Equals ignores case, but nothing told the hash to, so the two
spellings were equal and had different hashes. Step through what the HashSet did with them:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The first address. The set asks a for its hash and uses it to choose a bucket. The bucket is empty, so a goes in.
- The second address. b is equal to a by the custom Equals, which ignores case. The set asks b for its hash first.
- A different hash. Email overrides Equals but not GetHashCode, so the two hashes differ: same hash printed false. The set looks for an item stored with b's hash.
- Equals is never asked. No item has that hash, because a was stored under its own, so Equals is never called and b goes in as a new item.
- The warning said so. Warning FS0346 pointed at the type when the script ran: an explicit Equals without a matching GetHashCode.
Checkpoint
Check: Hashes that follow equality
Attributes choose what F# generates, and custom equality needs three members
Some types should not get the generated equality at all. A record that holds a function cannot have it, because
functions have no equality. The session runs emailkey.fsx, then tries = on such a record in F# Interactive, and
then each attribute:
open System
// Custom equality done whole: Equals, a GetHashCode that agrees with it, and a comparison for Set and Map.
[<CustomEquality; CustomComparison>]
type Email =
{ Address: string }
override this.Equals other =
match other with
| :? Email as e -> String.Equals(this.Address, e.Address, StringComparison.OrdinalIgnoreCase)
| _ -> false
override this.GetHashCode() = StringComparer.OrdinalIgnoreCase.GetHashCode this.Address
interface IComparable with
member this.CompareTo other =
match other with
| :? Email as e -> String.Compare(this.Address, e.Address, StringComparison.OrdinalIgnoreCase)
| _ -> invalidArg "other" "not an Email"
let a = { Address = "Asha.Rao@Example.com" }
let b = { Address = "asha.rao@example.com" }
let c = { Address = "ravi@example.com" }
printfn "a = b: %b, same hash: %b, compare a b: %d" (a = b) (hash a = hash b) (compare a b)
printfn "Set of a, b and c: %d addresses" (Set.count (Set.ofList [ a; b; c ]))
printfn "Map lookup with the other spelling: %A" (Map.tryFind b (Map.ofList [ (a, 3) ]))
// The other way: store one spelling only, and the generated equality, hash and comparison are right already.
type EmailKey = private EmailKey of string
module EmailKey =
let create (address: string) = EmailKey(address.Trim().ToLowerInvariant())
let k1 = EmailKey.create "Asha.Rao@Example.com"
let k2 = EmailKey.create " asha.rao@example.com"
printfn "EmailKey: k1 = k2: %b, same hash: %b, Set holds %d" (k1 = k2) (hash k1 = hash k2) (Set.count (Set.ofList [ k1; k2 ])) from session import fsi, run
# emailkey.fsx, then what the compiler says about a record with a function field and about the equality attributes.
run("""
dotnet fsi emailkey.fsx
""")
fsi([
'type Offer = { Name: string; Apply: decimal -> decimal };;',
'let festival = { Name = "festival"; Apply = fun price -> price * 0.9m };;',
'festival = festival;;',
'[<ReferenceEquality>]\ntype RuleRef = { Name: string; Apply: decimal -> decimal };;',
'let r1 = { RuleRef.Name = "festival"; Apply = fun price -> price * 0.9m };;',
'let r2 = { RuleRef.Name = "festival"; Apply = fun price -> price * 0.9m };;',
'r1 = r1, r1 = r2;;',
'Set.ofList [ r1 ];;',
'[<NoComparison>]\ntype Point = { X: int; Y: int };;',
'{ X = 1; Y = 2 } = { X = 1; Y = 2 };;',
'Set.ofList [ { X = 1; Y = 2 } ];;',
'#quit;;',
], args=["--nologo"]) Output
$ dotnet fsi emailkey.fsx
a = b: true, same hash: true, compare a b: 0
Set of a, b and c: 2 addresses
Map lookup with the other spelling: Some 3
EmailKey: k1 = k2: true, same hash: true, Set holds 1
$ dotnet fsi --nologo
> type Offer = { Name: string; Apply: decimal -> decimal };;
type Offer =
{
Name: string
Apply: (decimal -> decimal)
}
> let festival = { Name = "festival"; Apply = fun price -> price * 0.9m };;
val festival: Offer = { Name = "festival"
Apply = <fun:festival@2> }
> festival = festival;;
festival = festival;;
^^^^^^^^
stdin(3,1): error FS0001: The type 'Offer' does not support the 'equality' constraint because it is a record, union or struct with one or more structural element types which do not support the 'equality' constraint. Either avoid the use of equality with this type, or add the 'StructuralEquality' attribute to the type to determine which field type does not support equality
> [<ReferenceEquality>]
- type RuleRef = { Name: string; Apply: decimal -> decimal };;
type RuleRef =
{
Name: string
Apply: (decimal -> decimal)
}
> let r1 = { RuleRef.Name = "festival"; Apply = fun price -> price * 0.9m };;
val r1: RuleRef = { Name = "festival"
Apply = <fun:r1@6> }
> let r2 = { RuleRef.Name = "festival"; Apply = fun price -> price * 0.9m };;
val r2: RuleRef = { Name = "festival"
Apply = <fun:r2@7> }
> r1 = r1, r1 = r2;;
val it: bool * bool = (true, false)
> Set.ofList [ r1 ];;
Set.ofList [ r1 ];;
-------------^^
stdin(9,14): error FS0001: The type 'RuleRef' does not support the 'comparison' constraint. For example, it does not support the 'System.IComparable' interface
> [<NoComparison>]
- type Point = { X: int; Y: int };;
type Point =
{
X: int
Y: int
}
> { X = 1; Y = 2 } = { X = 1; Y = 2 };;
val it: bool = true
> Set.ofList [ { X = 1; Y = 2 } ];;
Set.ofList [ { X = 1; Y = 2 } ];;
-------------^^^^^^^^^^^^^^^^
stdin(13,14): error FS0001: The type 'Point' does not support the 'comparison' constraint because it has the 'NoComparison' attribute
> #quit;;
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 attributes_session.py
festival = festival was error FS0001: the record does not support the
The requirement 'T : equality that = and hash place on a type. A record or union satisfies it only when all its fields do, so a field of function type, or [<NoEquality>], takes it away.,
and the message suggested an attribute. The attributes are how you choose:
[<ReferenceEquality>]makes=compare identity:r1 = r1was true andr1 = r2false, for two rules with the same name and code. It also switches off the generated comparison, soSet.ofList [ r1 ]was error FS0001.[<NoComparison>]and[<NoEquality>]say that a type is never compared or never tested for equality.{ X = 1; Y = 2 } = { X = 1; Y = 2 }was still true under[<NoComparison>], whileSet.ofListof the same point was error FS0001.[<CustomEquality; CustomComparison>]hands all three members to you:Equals, aGetHashCodethat agrees with it, andIComparableforSetandMap. emailkey.fsx writes them case-insensitively, and theSetheld 2 of the 3 addresses andMap.tryFindfound the other spelling.
The second half of emailkey.fsx needs no custom code: EmailKey.create stores one spelling, trimmed and in lower
case, so the generated equality, hash and comparison are right already, and the Set held one of two spellings. The
price is the original spelling, which the key no longer has. Choose for your own type:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Choice | As a Set or Map key | Two spellings as one | With function fields | Code to keep right |
|---|---|---|---|---|
| Generated Written: nothing: the default | Good compare and hash come with the type | Poor compares the text exactly as stored | Poor festival = festival was error FS0001 | Good none: the compiler writes it |
| One stored spelling Written: a private case and a create function | Good EmailKey: the Set held 1 of 2 spellings | Good lowered once, in create | Poor still the generated equality | Good one function; the original spelling is lost |
| ReferenceEquality Written: [<ReferenceEquality>] | Poor no comparison, so no Set or Map key | Poor equal only to the same object | Good r1 = r1 was true, r1 = r2 false | Good one attribute |
| Custom Written: [<CustomEquality; CustomComparison>] | Good Set held 2 of 3 addresses, Map found the other spelling | Good Equals, GetHashCode and CompareTo agree | Mixed possible, if equality ignores the functions | Poor three members that must agree; FS0346 if one is missing |
| NoEquality or NoComparison Written: [<NoEquality>] or [<NoComparison>] | Poor Set.ofList was error FS0001 under NoComparison | Poor no = under NoEquality, the exact text under NoComparison | Mixed states that the type is not compared | Good one attribute |
How the answers decide
- “Will values of the type be Set elements or Map keys?” Yes, Set or Map keys: rules out ReferenceEquality and NoEquality or NoComparison. Set and Map need comparison: [<NoComparison>] made Set.ofList error FS0001, and ReferenceEquality removes the comparison too.
- “Can two different stored values mean the same thing, such as two spellings of one address?” Yes, they should be equal: rules out Generated and ReferenceEquality and adds a caveat to Custom. Generated equality compares the stored text; store one spelling, or write all three members so they agree.
- “Does the type hold a function, or an object that has no equality?” Yes, a function field: rules out Generated and One stored spelling. Functions have no equality, so the generated = on a record with a function field was error FS0001.
- “Can two different stored values mean the same thing, such as two spellings of one address?” No, exact values and “Does the type hold a function, or an object that has no equality?” No, only data: adds a caveat to Custom and ReferenceEquality. For plain data with exact values, the generated equality is already right and costs no code.
Checkpoint
Check: Equality attributes and custom equality
Practise: an email key for a Map
practice.fsx makes an email key whose domain ignores case while the part before the @ is kept as written, by storing
the domain in lower case. It checks six pairs, a Map lookup and a sort:
open System
// An email address as a Map key: the domain is case-insensitive, the local part before @ is kept as written.
// The domain is lowered once, when the value is made, so the generated equality, hash and comparison are right.
type Email = private { Local: string; Domain: string }
module Email =
let tryCreate (text: string) =
match text.Split '@' with
| [| local; domain |] when local <> "" && domain <> "" -> Some { Local = local; Domain = domain.ToLowerInvariant() }
| _ -> None
let text (e: Email) = $"{e.Local}@{e.Domain}"
let email text = (Email.tryCreate text).Value
let pairs =
[ "asha@Example.com", "asha@example.com", true
"Asha@example.com", "asha@example.com", false
"ravi@example.com", "ravi@example.org", false
"ravi@EXAMPLE.ORG", "ravi@example.org", true
"anu@example.in", "anu@example.in", true
"a.nu@example.in", "anu@example.in", false ]
for x, y, expected in pairs do
let equal = email x = email y
let hashOk = not equal || hash (email x) = hash (email y)
printfn "%-17s %-17s equal %-5b expected %-5b hash agrees %b" x y equal expected hashOk
let visits = Map.ofList [ (email "asha@Example.com", 3) ]
printfn "Map lookup: %A" (Map.tryFind (email "asha@example.com") visits)
printfn "Sorted: %s" ([ "ravi@b.in"; "Asha@a.in"; "asha@B.in" ] |> List.map email |> List.sort |> List.map Email.text |> String.concat ", ")
printfn "Not an address: %A" (Email.tryCreate "asha.example.com" |> Option.map Email.text) from session import run
# practice.fsx checks an email key on six pairs, in a Map and in a sorted list.
run("""
dotnet fsi practice.fsx
""") Output
$ dotnet fsi practice.fsx asha@Example.com asha@example.com equal true expected true hash agrees true Asha@example.com asha@example.com equal false expected false hash agrees true ravi@example.com ravi@example.org equal false expected false hash agrees true ravi@EXAMPLE.ORG ravi@example.org equal true expected true hash agrees true anu@example.in anu@example.in equal true expected true hash agrees true a.nu@example.in anu@example.in equal false expected false hash agrees true Map lookup: Some 3 Sorted: Asha@a.in, asha@b.in, ravi@b.in Not an address: None
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py
Every pair matched its expected result, every equal pair had equal hashes, and the Map found the key under the other
spelling of the domain. The sort put Asha before asha because the type declares Local first and compares it by
character code, so the capital A comes first.
Ignore case in the local part too
Lower local as well in tryCreate and run dotnet fsi practice.fsx again: the second pair, Asha@ and asha@,
becomes equal, so its line now reports equal true against expected false.
Answer the questions below on the five ideas of this lesson. Each choice is explained as soon as you pick it, and the questions return first to the idea you have answered least well:
Practice that adapts to you
Practise this lesson
12 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up, for freshers and 2–5 years: what does structural equality mean in F#, and which types have it?
Structural equality means that two values are equal when their contents are equal: the same fields, the same union
case with equal data, the same items in the same order. F# gives it by default to records, unions, tuples, lists,
arrays (under =), options and the F# collections, and the compiler writes the matching GetHashCode and, where every
field allows it, a comparison. Classes compare by reference unless they override Equals. Two exceptions matter in
practice: a record or union with a function field has no equality at all, and floats follow the rule that nan = nan
is false. [<ReferenceEquality>], [<NoEquality>] and [<CustomEquality>] change what the compiler generates.
Two harder questions follow, about a report whose two counts of distinct readings disagreed and about a cache key built from a string’s hash:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A monitoring report counts distinct sensor readings twice, once with List.distinct and once with Set.ofList. Most days the two counts match, but on days when sensors fail, the List.distinct count is higher. Explain the cause and fix the pipeline.
-
Mid to senior
A service keeps a disk cache keyed by hash productName so that it can skip work after a restart. In tests every lookup hits the cache, but in production almost every lookup misses after each deployment. Explain why, and design a key that survives restarts.
Key takeaways
- Records, unions, tuples and lists are
=when their contents are, classes only when they are one object, and F#’s=compares array elements while .NET’sEqualson arrays does not. comparereads a record’s fields in declaration order and union cases from top to bottom, and promises only the sign of its result; strings sort by character code unless you pass a culture.nan = nanis false whilecompare nan nanis 0, so aSetandList.distinctdisagree about the same numbers; checkDouble.IsNaNwhere data comes in.- A hash must agree with equality, and string hashes change between runs, so they are never stored or compared across processes.
- Attributes choose the generated members; custom equality needs
Equals,GetHashCodeand a comparison that agree, and storing one spelling often needs no custom code at all.
Terms in this lesson
- hash code
- An int computed from a value so that a hash table can find it quickly. Equal values must have equal hash codes, while unequal values may share one; F#'s hash and .NET's GetHashCode compute it.
- reference equality
- Equality by identity: two references are equal only when they point to the same object, as obj.ReferenceEquals tests. F# classes compare this way unless they override Equals.
- structural comparison
- Ordering F# records, unions, tuples and lists by their contents, as compare and List.sort do: a record's fields from first to last as declared, union cases in declaration order, list items from the front.
References
- Records (structural equality, ReferenceEquality) (Microsoft Learn)
- Discriminated unions (Microsoft Learn)
- Constraints (equality and comparison) (Microsoft Learn)
- Operators module (=, compare, hash) (The F# Software Foundation)
- LanguagePrimitives module (GenericEquality and GenericEqualityER) (The F# Software Foundation)
- ReferenceEqualityAttribute (The F# Software Foundation)
- CustomEqualityAttribute (The F# Software Foundation)
- NoComparisonAttribute (The F# Software Foundation)
- Double.Equals method (NaN) (Microsoft Learn)
- String.GetHashCode method (Microsoft Learn)
- String.Compare method (Microsoft Learn)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress