Module 1 · Equality, comparers and reliable keys · Lesson 1 of 3
Equality and Hash-Code Contracts for Immutable C# Value Keys
Watch
Learning outcome
By the end of this lesson, you should be able to explain the contract between Equals and GetHashCode, distinguish true equality from a hash collision, and reason about why immutable value objects make reliable keys in hash-based collections. This is a common interview topic because it tests both language knowledge and practical collection behavior.
Intuition
Think of a hash code as a fast bucket hint, not as an identity proof. Hash-based collections use the hash to narrow the search space first, then they call equality inside the matching bucket.
That means two important things:
- If two values are equal, they must produce the same hash code.
- If two values produce the same hash code, they still might not be equal.
That second case is a collision. It is normal, and it does not break the contract.
A useful mental model is:
GetHashCodesays, “Where should I look?”
Equalssays, “Is this the same logical value?”
That distinction matters when you use a type as a key in Dictionary<TKey,TValue> or HashSet<T>. The collection first groups by hash code and then uses equality within that group.
Analogy: the coat-check drawer
Imagine a coat check that sorts claim tickets into a few numbered drawers. Two copies of the same ticket must lead to the same drawer. Different tickets can share a drawer, so the attendant still compares the ticket details before returning a coat.
Mapping: the drawer hint represents the hash; the final ticket comparison represents equality. Limit: this is a search analogy, not the collection's exact storage layout. A drawer number does not prove ownership, and real collections still require a consistent comparer.
Deep dive
For a value object, equality should be based on the logical data the type represents, not on object identity. For an immutable Money value, two instances with the same currency and amount should compare equal even when constructed separately. The record struct below is a value type; reference identity is not its equality model.
In C#, the contract is simple:
- If
a.Equals(b)istrue, thena.GetHashCode()andb.GetHashCode()must return the same value.
- If
a.GetHashCode()is the same asb.GetHashCode(), that does not provea.Equals(b). Under one consistent, contract-correct comparer in the same execution, different hash codes rule out equality: this is the contrapositive of rule 1. In contrast, different values can have the same hash code, so matching hashes require an equality check. Do not compare hash codes from different comparers or processes as stable identities.
-
Equalsshould be reflexive, symmetric, transitive, and consistent.
- Hashing must not distinguish values that equality treats as equal. Combining equality-relevant fields is the usual design; leaving one out can increase collisions without necessarily violating the contract.
You often see this implemented manually for classes and structs, but modern C# also gives records built-in value equality. For interview purposes, you should still understand the manual implementation because it shows the underlying contract clearly.
A reliable immutable key keeps its equality-relevant state stable after construction. That prevents the value from changing after it has been inserted into a hash-based collection. If the logical value cannot change, the hash code cannot drift out from under the collection.
Failure modes
A few common mistakes come up repeatedly in interviews and production code:
- Overriding
Equalsbut notGetHashCode.
- Using mutable fields in equality and then changing them after insertion into a
HashSetorDictionary.
- Assuming same hash code means equal object.
- Accidentally comparing reference identity for a value object.
- Mixing comparer policy with type semantics: the type defines its own equality, but the collection may accept an external comparer that changes lookup behavior.
Another subtle point: RuntimeHelpers.GetHashCode is about object identity-oriented hashing, not logical value equality. That makes it different from an overridden GetHashCode on your type.
Interview drill
Answer first, then compare with the worked responses. Use the Money program below as your evidence.
- Why must equal objects have equal hash codes?
Here, a and b both represent USD 19.99. Searching with either must lead to the same candidate entries; the set must treat the second as a duplicate.
- Why are collisions allowed?
Imagine a and c receive the same hash. Their amounts still differ, so the equality check keeps both values. The set count remains 2.
- What if equality says two keys are equal but their hashes differ?
A lookup can search the wrong candidate group and miss an equal entry. In this fixture that would undermine the promise that another USD 19.99 finds the stored value.
- Why is immutability useful for hash keys?
The inserted Money value cannot later change from USD 19.99 into USD 20.00. Its key identity stays aligned with the value originally stored.
- How do records simplify this example?
The listing declares no manual equality methods. The generated equality makes a.Equals(b) and a == b agree for these currency-and-amount values.
- How does collection policy differ from the type's own equality?
This set uses its default policy for Money. In the next lesson, two string collections deliberately use different policies and produce different duplicate counts for the same inputs.
Revision checklist
- I can define the equality/hash-code contract in one sentence.
- I know that collisions do not imply equality.
- I can explain why keys should be immutable.
- I can describe how
DictionaryandHashSetuse hash codes plus equality.
- I can implement a small value type with correct
EqualsandGetHashCode.
- I can explain why a record type is often a good fit for value semantics.
Worked executable example
The example below uses an immutable readonly record struct, which is a value type with built-in value semantics. It demonstrates three distinct cases:
- two equal values with the same hash code,
- two different values that may or may not collide,
- a collection lookup that depends on both hashing and equality.
Because collisions are allowed and are determined by the runtime implementation, the example does not pretend to force a specific collision. Instead, it prints the contract comparisons and also reports whether the third value happens to collide with the first value on this runtime.
Code walkthrough
The executable example compares three Money values. The first two are logically identical, so Equals returns true and their hash codes match. The third differs in amount, so Equals returns false.
The program then inserts all three values into a HashSet<Money>. The set count stays at 2, because the duplicate logical value is not added twice. Finally, the program reports whether the third value collided with the first one by comparing hash codes. If they match, that is a collision, but not equality.
That is the key interview idea: equal values must hash equally, but equal hashes do not prove equality.
Quick reference: answer with evidence
- State the equality rule before discussing a hash. In the existing
Moneyexample, currency and amount identify the value. - For
aandb, the equality result and hash agreement are guaranteed. Foraandc, matching hashes would be a collision, not a duplicate. - Explain the set count as two logical values. Do not base the answer on the numeric hashes printed by one machine.
- Keep the key's equality-relevant state stable while it is stored. The example uses a
readonly record structcontaining a string and a decimal. - A collection can receive a different equality policy. Always identify which comparer a question uses before predicting a count.
Make a collision predictable
The existing program correctly permits either outcome for the unequal values' hash comparison. For a repeatable follow-up, keep Money unchanged and put this comparer alongside it:
public sealed class CollisionComparer : IEqualityComparer<Money>
{
public bool Equals(Money x, Money y) => x.Equals(y);
public int GetHashCode(Money value) => 0;
}Inside Main, try:
var demo = new HashSet<Money>(new CollisionComparer());
Console.WriteLine(demo.Add(new Money("USD", 7m)));
Console.WriteLine(demo.Add(new Money("USD", 9m)));
Console.WriteLine(demo.Add(new Money("USD", 7m)));
Console.WriteLine(demo.Count);Expected output:
True True False 2
All three candidates receive hash 0. The second amount still earns a new entry; the repeated first amount does not. This comparer is a teaching fixture with deliberately poor distribution, not a production recommendation.
An adjusted copy does not replace the stored value
With the lesson's Money type, start with var original = new Money("USD", 7m);. Then var adjusted = original with { Amount = 9m }; creates an adjusted value while original stays USD 7. A set containing only original still finds that value and does not find adjusted until it is added. This copy-and-change expression does not mutate the key stored in the set.
Interview answer in twenty seconds
“I define a key by its logical value, then keep that value stable. In this example two USD 7 values produce one set entry. A USD 9 value gets another entry, even when I deliberately give every key the same hash. The hash narrows the search; the equality rule settles the match.”
Optional challenge: separate type equality from collection policy
Suppose a second comparer groups Money values by currency only. In that collection, USD 7 and USD 9 would share one group, while new Money("USD", 7m) == new Money("USD", 9m) would remain false. A custom comparer does not rewrite the record's generated operator.
Source for the collection contract: Microsoft: IEqualityComparer.GetHashCode. The examples and reasoning above are original applications to this lesson's Money model.
Optional video
Watch Dev Leader: Beginner’s Guide to C# Record Equality (14:37). This free external walkthrough uses record classes, including a collection-member caveat. Return to this lesson for record structs and the precise hash-key contract.
Executable code examples
Run this example
Use the .NET 10 SDK. In a new empty folder, save the project below as Example.csproj and the listing as Program.cs. Run dotnet run --project Example.csproj.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>Immutable value key with equality, hash-code checks, and collision awareness
Program.cs
using System;
using System.Collections.Generic;
public readonly record struct Money(string Currency, decimal Amount)
{
public override string ToString() => $"{Currency} {Amount:0.00}";
}
public static class Program
{
public static void Main()
{
var a = new Money("USD", 19.99m);
var b = new Money("USD", 19.99m);
var c = new Money("USD", 20.00m);
Console.WriteLine($"a.Equals(b): {a.Equals(b)}");
Console.WriteLine($"a hash == b hash: {a.GetHashCode() == b.GetHashCode()}");
Console.WriteLine($"a.Equals(c): {a.Equals(c)}");
Console.WriteLine($"a hash == c hash: {a.GetHashCode() == c.GetHashCode()}");
Console.WriteLine($"a hash == c hash means equal: {a.GetHashCode() == c.GetHashCode() && a.Equals(c)}");
Console.WriteLine($"a hash == c hash is just a collision check: {a.GetHashCode() == c.GetHashCode()}");
var set = new HashSet<Money> { a, b, c };
Console.WriteLine($"HashSet count: {set.Count}");
Console.WriteLine($"Contains USD 19.99: {set.Contains(new Money("USD", 19.99m))}");
Console.WriteLine($"Contains USD 20.00: {set.Contains(new Money("USD", 20.00m))}");
Console.WriteLine(a == b ? "Equality operator agrees" : "Equality operator disagrees");
}
}Expected output
One permitted output is shown below. Both collision-check lines may instead be True, but they must agree. Equal hashes never override the False equality result for a and c.
a.Equals(b): True a hash == b hash: True a.Equals(c): False a hash == c hash: False a hash == c hash means equal: False a hash == c hash is just a collision check: False HashSet count: 2 Contains USD 19.99: True Contains USD 20.00: True Equality operator agrees