Understanding Value Equality in .NET Records

Blog post cover image
.NET records have emerged as a powerful abstraction for representing immutable data in a concise, expressive manner, largely due to their intrinsic support for value equality. However, achieving truly comprehensive value equality across all aspects of a record, especially when collections are involved, requires nuanced understanding and careful handling. In this article, we delve into the intricacies of value equality in .NET records, examine the challenges posed by collections, and present a robust solution to ensure that collections can be seamlessly integrated while preserving value equality semantics.
Check the complete source code on GitHub.

▸ Defining value equality

In .NET, value equality refers to the principle that two objects are considered equal if their values, rather than their references, are identical. This concept is particularly valuable when dealing with data transfer objects (DTOs) and other forms of data representation, where different instances with equivalent data must be treated as identical.
Records in C# provide a convenient mechanism for automatic value-based equality. By default, the equality of two records is determined by comparing the values of all properties within the record. This significantly reduces the boilerplate code otherwise required for implementing equality and hash functions.
However, achieving consistent value equality within records becomes more challenging when properties include collections, as conventional collection types in .NET employ reference-based equality by default.

▸ Challenges of collections in records

Consider the BadOrder record below, which includes a List<OrderItem> as one of its properties:
public record OrderItem(
    string ProductName,
    int Quantity,
    decimal Price);

public record BadOrder(
    string OrderId,
    string CustomerName,
    List<OrderItem> Items);
The BadOrder record contains a List<OrderItem> property named Items. To illustrate the issue, let us create two instances of BadOrder with identical data and evaluate their equality.
Here is the test code:
[Test]
public void TestBadOrderEquality()
{
    OrderItem item1 = new("ProductA", 2, 10.0m);
    OrderItem item2 = new("ProductB", 1, 20.0m);

    BadOrder order1 = new("Order1", "John Doe", [item1, item2]);
    BadOrder order2 = new("Order1", "John Doe", [item1, item2]);

    Assert.Multiple(() =>
    {
        Assert.That(order1.OrderId, Is.EqualTo(order2.OrderId));
        Assert.That(order1.CustomerName, Is.EqualTo(order2.CustomerName));
        Assert.That(order1.Items[0], Is.EqualTo(order2.Items[0]));
        Assert.That(order1.Items[1], Is.EqualTo(order2.Items[1]));

        // :-( THIS LINE WILL FAIL BECAUSE THE COLLECTIONS ARE NOT VALUE EQUAL
        Assert.That(order1, Is.EqualTo(order2));
    });
}
Although order1 and order2 contain identical data, the equality check will fail. This outcome is due to the List type's default use of reference-based equality in C#. As a result, even if two lists contain identical elements, they are treated as different objects in memory, thereby causing the equality check to fail.

▸ Introducing ValueCollection for value-based collection equality

To resolve this issue, we need a collection type that implements value-based equality rather than relying on reference equality. This can be accomplished by creating a custom collection class, ValueCollection<T>, which overrides the equality methods to ensure that the contents of the collection are compared based on their values.
Consider the revised GoodOrder record, which employs ValueCollection<OrderItem>:
public record GoodOrder(
    string OrderId,
    string CustomerName,
    ValueCollection<OrderItem> Items);
The implementation of ValueCollection<T> is as follows:
using System.Collections.ObjectModel;
using System.Text;

/// <summary>
/// Represents a collection of values.
/// </summary>
/// <param name="values">The values to initialize the collection with.</param>
/// <typeparam name="T">The type of the values in the collection.</typeparam>
public class ValueCollection<T>(params IList<T> values)
    : ReadOnlyCollection<T>(new List<T>(values))
{
    /// <summary>
    /// Adds an item to the collection.
    /// </summary>
    /// <param name="item">The object to add to the value collection.</param>
    /// <exception cref="ArgumentNullException">Thrown when item is null.</exception>
    public void Add(T item)
    {
        ArgumentNullException.ThrowIfNull(item);

        Items.Add(item);
    }

    /// <summary>
    /// Determines whether the specified object is equal to the current object.
    /// The comparison is done by comparing the items in the collection.
    /// </summary>
    /// <param name="obj">The object to compare with the current object.</param>
    /// <returns>true if the specified object is equal to the current object; otherwise, false.</returns>
    public override bool Equals(object? obj)
    {
        if (ReferenceEquals(null, obj))
        {
            return false;
        }

        if (ReferenceEquals(this, obj))
        {
            return true;
        }

        return obj is ValueCollection<T> other
               && other.Items.SequenceEqual(Items);
    }

    /// <inheritdoc />
    public override int GetHashCode()
    {
        HashCode hashCode = new();
        foreach (T item in Items)
        {
            hashCode.Add(item);
        }

        return hashCode.ToHashCode();
    }

    /// <summary>
    /// Returns a string that represents the current object.
    /// If the collection has more than 3 items, only the first 3 items are shown.
    /// </summary>
    /// <returns>A string that represents the current object.</returns>
    public override string ToString()
    {
        StringBuilder sb = new();

        sb.Append("[ ");

        // if we have more than 3 items, we only show the first 3 then "..."
        if (Items.Count > 3)
        {
            sb.AppendJoin(", ", Items.Take(3));
            sb.Append(", ...");
        }
        else
        {
            sb.AppendJoin(", ", Items);
        }

        sb.Append(" ]");

        return sb.ToString();
    }
}
The ValueCollection<T> class extends ReadOnlyCollection<T> and overrides the Equals, GetHashCode, and ToString methods to enforce value-based equality for the collection. Consequently, two instances of ValueCollection<T> are considered equal if their items are equivalent, regardless of whether they are different objects in memory.

▸ Testing value equality with GoodOrder

Let us revisit our previous test, this time using GoodOrder:
[Test]
public void TestGoodOrderEquality()
{
    OrderItem item1 = new("ProductA", 2, 10.0m);
    OrderItem item2 = new("ProductB", 1, 20.0m);

    GoodOrder order1 = new("Order1", "John Doe", [item1, item2]);
    GoodOrder order2 = new("Order1", "John Doe", [item1, item2]);

    Assert.Multiple(() =>
    {
        Assert.That(order1.OrderId, Is.EqualTo(order2.OrderId));
        Assert.That(order1.CustomerName, Is.EqualTo(order2.CustomerName));
        Assert.That(order1.Items[0], Is.EqualTo(order2.Items[0]));
        Assert.That(order1.Items[1], Is.EqualTo(order2.Items[1]));

        // :-) THIS LINE WILL PASS BECAUSE THE COLLECTIONS ARE VALUE EQUAL
        Assert.That(order1, Is.EqualTo(order2));
    });
}
In this scenario, the equality check succeeds because ValueCollection<OrderItem> guarantees that the Items property is compared based on the values of its elements, rather than comparing the references of the collections.

▸ Advantages of using ValueCollection

The use of ValueCollection<T> in records yields several significant benefits:

▸ Key considerations

While ValueCollection<T> offers a robust solution for achieving value equality, certain considerations must be kept in mind:

▸ Conclusion

Value equality in .NET records is a powerful feature that enables developers to treat objects as equal based on their contents rather than their references. However, when dealing with collections, it is essential to ensure that equality comparisons are conducted correctly. The default collection types in C# rely on reference equality, which can lead to unintended behaviour when used within records.
By employing a custom collection type such as ValueCollection<T>, you can ensure that collections in your records are compared based on their values, thereby providing consistent and intuitive value equality. This approach streamlines testing, enhances code clarity, and ensures that your records are both robust and reliable.
If you are working with .NET records and require value equality involving collections, consider implementing ValueCollection<T> to circumvent the limitations of reference-based equality, ensuring that your data is compared in a manner consistent with your expectations.

Does this resonate with your team?

Let's talk about how Atypical Consulting can help you move forward.

Contact me