Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with .NET 10.0.401 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

equality.fsx: six kinds of value, compared three ways FSX · equality.fsx
// 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)
equality.fsx on the pinned SDK Python · equality_session.py
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

  1. Question 1 of 2 Two arrays [| 21; 22 |] were built separately. Which comparison printed true for them?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: F#'s = only

    Arrays are the row where F# and .NET disagree. In F# code, compare arrays with =, never with .Equals.

  2. Question 2 of 2 SeatClass("S4", 21) = SeatClass("S4", 21) printed false. What makes two seats with the same data equal?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Override Equals and GetHashCode in the class, or model the seat as a record

    Two separate objects are equal by contents only if their type says so: a record or union by default, a class through an Equals override.

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:

ordering.fsx: records, union cases and names, sorted FSX · ordering.fsx
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")
ordering.fsx on the pinned SDK Python · ordering_session.py
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:

Interactive · stage by stage How compare orders two seats Step through compare on two Seat records. It reads the fields in the order the type declares them and stops at the first that differs.
How compare orders two seats, step 6 of 6. Showing compare a b, Coach: "S4" vs "S4" (equal: go on), Berth: 21 vs 22 (21 < 22), the result (S5-1 S5-3 B1-40). In focus: Berth: 21 vs 22.compare a bS4-21 and S4-22Coach: "S4" vs "S4"equal: go onBerth: 21 vs 2221 < 22the resultS5-1 S5-3 B1-40declared first: sorts first

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Fields in declaration order. The generated comparison of Seat looks at Coach first, because the type declares Coach first.
  2. The coaches are equal. Both coaches are "S4", so compare cannot decide yet and moves on to the next field.
  3. The berths decide. Berth 21 is less than 22, so this field decides the whole comparison.
  4. A negative number. compare printed -1: a comes first. Only the sign is promised; compare Low High gave -2.
  5. 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.
  6. 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

  1. Question 1 of 2 SeatByBerth declares Berth before Coach. In what order did List.sort put the seats S5-3, B1-40 and S5-1?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: S5-1 S5-3 B1-40

    The generated comparison reads fields in declaration order, so moving a field changes every sort.

  2. Question 2 of 2 compare Low High returned -2. How should code test that a comes before b?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: compare a b < 0

    Negative means before, zero means equal and positive means after; the size of the number carries no meaning.

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:

nan.fsx: one value that is not a number, compared every way FSX · nan.fsx
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))
nan.fsx on the pinned SDK Python · nan_session.py
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:

Interactive · step by step Which rule each function uses for nan Step through the rows of nan.fsx. The left column follows F#'s =, where nan equals nothing; the right follows compare or .NET's Equals.
Which rule each function uses for nan, step 5 of 5. Showing = and F# hash, compare, List.distinct, Set.ofList, List.contains nan, Set.contains nan, F# dict, .NET Dictionary. In focus: compare.= and F# hashnan = nan: falsecomparecompare nan nan: 0List.distinct[nan; nan; 1.0]: 3 itemsSet.ofList[nan; nan; 1.0]: 2 itemsList.contains nanfalseSet.contains nantrueF# dictContainsKey nan: false.NET DictionaryContainsKey nan: trueList.sort put nan first

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 2 How many items did Set.ofList [nan; nan; 1.0] hold?

    Type a number.

    A hint

    A Set decides duplicates with compare. What did compare nan nan return?

    Show the answer: Question 1 of 2

    Answer: 2 items

    compare nan nan is 0, so the Set treats the two nans as one item and holds nan and 1.0. List.distinct, which uses =, kept all 3.

  2. Question 2 of 2 What did List.max [2.0; nan; 1.0] return?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 2.0

    With nan in the list, List.max answers differently for different orders. Check Double.IsNaN before computing a maximum.

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:

hashing.fsx: the hashes of this run against the last one FSX · hashing.fsx
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"
badkey.fsx: an Equals without a matching hash FSX · badkey.fsx
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
hashing.fsx twice, then badkey.fsx Python · hashing_session.py
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:

Interactive · stage by stage Why the HashSet kept both spellings Step through the HashSet adding a, then b. A hash table calls Equals only on items stored with the same hash.
Why the HashSet kept both spellings, step 5 of 5. Showing Add a, hash a picks a bucket, Add b, hash b picks a bucket (no item with that hash), the set (2 items: a duplicate). In focus: the set.Add a"Asha.Rao@Example.com"hash a picks a bucketempty: a goes inAdd b"asha.rao@example.com"hash b picks a bucketno item with that hashthe set2 items: a duplicateFS0346: add GetHashCode

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. The second address. b is equal to a by the custom Equals, which ignores case. The set asks b for its hash first.
  3. 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.
  4. 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.
  5. The warning said so. Warning FS0346 pointed at the type when the script ran: an explicit Equals without a matching GetHashCode.

Hash Generator For a hash you store or send, use a cryptographic hash such as SHA-256, which gives the same digest for the same text on every computer and in every run.

Checkpoint

Check: Hashes that follow equality

  1. Question 1 of 2 Which hash was different in the second run of hashing.fsx?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: hash "S4", and the hash of the record holding it

    Within one run equal values have equal hashes; across runs, anything that contains a string may hash differently.

  2. Question 2 of 2 badkey.fsx's a and b were equal by =. How many items did HashSet [a; b] hold?

    Type a number.

    A hint

    The set calls Equals only on an item stored with the same hash. Did a and b have the same hash?

    Show the answer: Question 2 of 2

    Answer: 2 items

    Their hashes differed, so the set never called Equals to compare b with a, and it kept both: a duplicate.

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:

emailkey.fsx: custom equality done whole, and a key that needs none FSX · emailkey.fsx
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 ]))
emailkey.fsx, then the attributes in F# Interactive Python · attributes_session.py
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 = r1 was true and r1 = r2 false, for two rules with the same name and code. It also switches off the generated comparison, so Set.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>], while Set.ofList of the same point was error FS0001.
  • [<CustomEquality; CustomComparison>] hands all three members to you: Equals, a GetHashCode that agrees with it, and IComparable for Set and Map. emailkey.fsx writes them case-insensitively, and the Set held 2 of the 3 addresses and Map.tryFind found 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:

Interactive · answer and compare Which equality does this type need? Answer three questions about a record or union you are writing. The table marks the choices that do not fit.
Will values of the type be Set elements or Map keys?
Can two different stored values mean the same thing, such as two spellings of one address?
Does the type hold a function, or an object that has no equality?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Five ways to give an F# type its equality, against what this lesson measured
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

  1. Question 1 of 2 Offer holds a field Apply: decimal -> decimal. What did festival = festival give?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Error FS0001: Offer does not support the 'equality' constraint

    The type itself compiled; only the use of = failed. [<ReferenceEquality>] or custom equality gives such a type an equality on purpose.

  2. Question 2 of 2 Which members does [<CustomEquality; CustomComparison>] leave for you to write?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Equals, GetHashCode and IComparable's CompareTo

    The three members must agree: equal values need equal hashes, and compare must return 0 for them.

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:

practice.fsx: one stored spelling of the domain FSX · practice.fsx
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)
practice.fsx on six pairs Python · practice_session.py
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.

  1. Question 1 of 12 Two Seat records were built separately from the same coach and berth. What did = give?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: true

    F# writes Equals and GetHashCode for every record and union, so values with equal contents are equal.

  2. Question 2 of 12 A list of 10,000 Seat records may contain the same seat twice, built in different places. Which call removes the repeats by value?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: List.distinct

    Structural equality is what makes records good keys and good values for distinct, Set and Map.

  3. Question 3 of 12 type Priority = Low | Medium | High. What did List.sort [High; Low; Medium] give?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: [Low; Medium; High]

    Declare cases in their natural order and sorting follows it.

  4. Question 4 of 12 Which call sorted the names into anu, Arjun, bala, Bhavna, the order a reader expects?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: List.sortWith with String.Compare and StringComparison.InvariantCulture

    F#'s compare on strings is ordinal: fast and the same everywhere, but capitals first. For people, pass a culture.

  5. Question 5 of 12 A teammate reorders the fields of a widely used record alphabetically, to look tidy. What can change?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: Every sort, min, max, Set and Map order over the record

    Treat field order as part of a record's behaviour, and give a sort its own key with List.sortBy when the order matters.

  6. Question 6 of 12 What does nan = nan give in F#?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: false

    Test for NaN with Double.IsNaN, never with = nan.

  7. Question 7 of 12 Sensor readings may contain NaN, and a report groups them in a Map by value and counts distinct readings with List.distinct. What should the import do?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: Check every value with Double.IsNaN where it comes in, and decide there what a NaN means

    NaN is best stopped at the boundary, the same way nulls are, so the rest of the code never meets it.

  8. Question 8 of 12 What must be true of the hashes of two values that are equal by =?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: They are equal

    Equal values, equal hashes, in one run of one program: that is the whole promise.

  9. Question 9 of 12 What did warning FS0346 ask for, when Email overrode Equals?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: A matching override of Object.GetHashCode

    Treat FS0346 as an error: a hash that disagrees with Equals breaks every HashSet and Dictionary that holds the type.

  10. Question 10 of 12 A service stores hash customerName in a database column to find customers faster after a restart. What happens?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: After a restart the hashes differ, so the stored values no longer match

    For a value you store, compute a stable hash, such as SHA-256 of the text, or store the key itself.

  11. Question 11 of 12 Under [<ReferenceEquality>], what did r1 = r2 give for two RuleRef values with the same name and code?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: false

    ReferenceEquality suits values whose identity matters, such as rules or handlers that hold functions.

  12. Question 12 of 12 Email addresses must be Map keys that ignore case, and each must be shown back to its user exactly as typed. Which design fits?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: Custom equality, hash and comparison that ignore case, with the address kept as typed

    Storing one spelling is the simpler choice whenever losing the original is acceptable; when it is not, write all three members so that they agree.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

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’s Equals on arrays does not.
  • compare reads 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 = nan is false while compare nan nan is 0, so a Set and List.distinct disagree about the same numbers; check Double.IsNaN where 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, GetHashCode and 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.