Object-Oriented C# · Lesson 18

Value equality for a named identifier class

Keep Equals and GetHashCode consistent when a class represents a logical value.

How it works

A class normally uses reference identity for equality unless it defines a different contract. A small identifier class may instead represent a logical value, where two separately created instances with the same code should be equal. Implementing IEquatable<T> provides a typed equality operation, while overriding object.Equals and GetHashCode makes the contract available to ordinary collection infrastructure.

Equal values must produce equal hash codes. Hash codes are not unique identifiers, and unequal values may share a hash code. Their role is to narrow collection lookup before equality resolves a match. Use the same fields and comparison rules for equality and hashing, and keep those fields stable while the object is stored in a hash-based collection.

Walk through the example. Two ItemId instances contain the same ordinal code. Typed Equals returns true, but the class has not overloaded the == operator, so == still compares reference identity and returns false. A HashSet uses the equality and hash contract and therefore retains only one logical identifier. The sample deliberately prints both comparisons to show that defining one equality mechanism does not automatically redefine every operator.

Watch the boundary. Do not base GetHashCode on mutable state that can change after insertion into a dictionary or set. Also do not persist a hash code as a durable business identifier. If operator equality is part of the public API, implement it consistently too, or consider a record whose generated value equality suits the model. Choose the simplest type shape that communicates the intended contract.

Practice independently. Extend ItemId with an explicit case-insensitive code policy and update both equality and hashing together. Test equal spellings, case variants, different codes, and null. Decide whether to overload == or clearly document Equals as the intended comparison. Finally explain how changing a code after insertion would violate the collection's assumptions, and enforce immutability to prevent that state change.

A working example

using System;
using System.Collections.Generic;
using System.Linq;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;

var first = new ItemId("A1");
var second = new ItemId("A1");
Console.WriteLine(first.Equals(second));
Console.WriteLine(first == second);
Console.WriteLine(new HashSet<ItemId> { first, second }.Count);
sealed class ItemId : IEquatable<ItemId>
{
    public string Code { get; }
    public ItemId(string code) { Code = code; }
    public bool Equals(ItemId? other) => other is not null && StringComparer.Ordinal.Equals(Code, other.Code);
    public override bool Equals(object? obj) => obj is ItemId other && Equals(other);
    public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Code);
}

Expected output

True
False
1
Try this
Use the same stable fields and comparer for Equals and GetHashCode; == is a separate operator contract.