C# 9 introduced us to records, a new way to define data structures with increased readability and usability in specific scenarios. While they may look like structs or classes, they offer an opportunity to reduce boilerplate code and potentially allow us to code differently. One of the significant differences between records and other types is that records operate as value types.

In this post, we’ll explore the difference between a reference type and a value type and how we can use source generators to change the value comparison of one record against another.

Value Types vs. Reference Types

When comparing classes to records, the significant difference is comparison operators. In .NET, users will likely define types using the class type. While you may create two classes with all the same values, you will find they are not the same in the comparison. Let’s look at a code example.

using static System.Console;

// will be false
WriteLine(new Friend("Khalid") == new Friend("Khalid"));

public class Friend
{
    public Friend(string name)
    {
        Name = name;
    }
    
    public string Name { get; }    
}

When we talk about “reference types,” we are talking about the space an entity occupies in memory. In this case, the instances of Friend are two separate entities in memory, even though they share the same values. But what about value types? In our case, what about record types?

using static System.Console;

// will be true
WriteLine(new Friend("Khalid") == new Friend("Khalid"));

public record Friend(string Name);

Well, record comparisons compare all the property values in the record. If they match, then the two entities are the “same”. So value comparison has advantages, especially when you’re more concerned about logical sameness than technical sameness.

A good use case is removing elements from a collection to determine logical uniqueness. In the following example, I have a group of friends, and I want to know which entries are Distinct.

using static System.Console;

Friend[] friends =
{
    new("Khalid"), 
    new("Maarten"),
    new("Rachel"),
    new("Khalid")
};

// Is 3: Khalid, Maarten, & Rachel
WriteLine(friends.Distinct().Count());

public record Friend(string Name);

If the Friend type were of class, we’d see a result of 4 instead of 3 since we would be comparing each instance by reference.

The Business Problem with Records

While the value comparison of records is a great feature, it does break down in most business apps I’ve written, where entities typically have an identifier field of Id. Let’s take a look at an example.

using static System.Console;

var items = new List<CartItem>
{
    new(1, "The Witcher 3", 60m),
    new(2, "Elden Ring", 60m),
    new(3, "King of Fighters XV", 50m),
    new(4, "Street Fighter V", 30m)
};

WriteLine($"Items in Cart (before): {items.Count}");

// remove record with value equality
// not with reference equality
var item = new CartItem(1);
items.Remove(item);

WriteLine($"Items in Cart (after): {items.Count}");

public record CartItem(
    int Id,
    string Name = "",
    decimal Price = 0m);

We have a shopping cart with items, and each item has an Id property. Logically, we know prices and names of products can change, but identifiers never do. Therefore, removing an entry with only the Id will not produce the outcome we intended in the previous code, as records compare all the properties.

To fix this issue, we need to overwrite the comparison operator of our CartItem class.

public record CartItem(
    int Id,
    string Name = "",
    decimal Price = 0m)
{
    public virtual bool Equals(CartItem? other)
    {
        if (ReferenceEquals(null, other)) return false;
        if (ReferenceEquals(this, other)) return true;
        return Id == other.Id;
    }

    public override int GetHashCode()
    {
        return Id;
    }
}

Changing the Equals and GetHashCode will make our code work as expected, but with the drawback of being noisey.

Using Source Generators For Record Comparisons

Rather than overriding every records’ comparison operators, what if we could declare which properties should be part of the value comparison?

public partial record CartItem(
    [Equality] int Id,
    string Name = "",
    decimal Price = 0m);

Well, I did just that! You can add this generator to your projects, and it will generate the record comparison operators to change the value comparison of records to each other. Note, I had to embed a gist because source generator code and liquid (blog engine templating language) don’t mix.

If you want to see a working sample, you can go to the GitHub Repository and try it for yourself. When you include the source generator in your projects, you can reduce the boilerplate needed to express your intent. That’s pretty cool in my book.

using RecordEquality;
using static System.Console;

var items = new List<CartItem>
{
    new(1, "The Witcher 3", 60m),
    new(2, "Elden Ring", 60m),
    new(3, "King of Fighters XV", 50m),
    new(4, "Street Fighter V", 30m)
};

WriteLine($"Items in Cart (before): {items.Count}");

// remove record with value equality
// not with reference equality
var item = new CartItem(1);
items.Remove(item);

// before records, you'd have to do this
// items.RemoveAll(i => i.Name == item.Name);

WriteLine($"Items in Cart (after): {items.Count}");

public partial record CartItem(
    [Equality] int Id,
    string Name = "",
    decimal Price = 0m);

Now our example works with adding a partial on our record and an EqualityAttribute. Do you like what you see? I hope so, but you can also just override the original comparisons if you think Source Generators are too much trouble. It’s your code!

Conclusion

Records are a pretty cool type, and by using source generators, we can express our value comparison intent with minimal changes. Try out the sample project and tell me what you think on Twitter at @buhakmeh. Thank you so much for reading and sharing my post with colleagues.