The record keyword in C# has been available since C# 9, and you either love it or hate it. Regardless of your opinion, you will likely make a not-so-obvious mistake when working with the with keyword. This particular mistake can lead to strange system behavior and hard-to-diagnose bugs. Nobody wants bugs, right?!

In this post, we’ll explore the problem and how to solve that issue.

The Problem with Records

The virtue of record types is based on immutability, allowing you, the developer, to encapsulate data and pass it around without fear of introducing unwanted changes. The with keyword enables you to copy an existing record to avoid mutating the original value, thus maintaining a clear separation between what was and might be. Let’s take a look at a simple example.

var one = new Simple(1);
var copy = one with { Number = one.Number + 1 };

Console.WriteLine(copy);

When running the previous code, the result is the following console output.

Simple { Number = 2 }

Attempting to change the Number directly creates a compilation exception with the message.

Init-only property 'Simple.Number' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor

Now, the real fun begins. We’ve been looking at value types, which the runtime will copy. What about reference types? Let’s add a collection to a record.

var item = new Item();

var newItem = item with { };
item.State.Add(1);
newItem.State.Add(2);

Console.WriteLine(item);
Console.WriteLine(newItem);

public record Item(List<int> State)
{
    public Item() : this(new List<int>())
    {
    }

    public override string ToString()
    {
        return $"Item {{ State = {string.Join(", ", State)} }}";
    }
}

What do you expect State (don’t peek)? You would be correct if you guessed it would be 1, 2 across both instances.

Item { State = 1, 2 }
Item { State = 1, 2 }

As you may well know, reference types point to a location in memory. When using the with keyword on records with reference types, the runtime will copy the reference to the new instance of the record. This behavior is excellent for memory efficiency, as the runtime does not allocate more memory for read-only information. This behavior is not good if you mutate the data before passing the data around to other consumers.

How do we solve this issue? Well, there are two ways.

Using Property Setters when copying

The first approach is to be aware of all the reference types in a record and mutate the values early in the creation process.

var item = new Item();

var newItem = item with { State = item.State.Append(2).ToList() };
item.State.Add(1);

Console.WriteLine(item);
Console.WriteLine(newItem);

The resulting output shows we now have two different collections.

Item { State = 1 }
Item { State = 2 }

This approach is acceptable but requires effort when using the with keyword.

Another approach is using a “copy constructor”. A copy constructor is a method that takes an instance in the known inheritance hierarchy and allows you to make decisions during the creation process. Let’s change our record definition by adding a copy constructor that creates a new collection instance. The with keyword will tell the runtime to look for a copy constructor on your definition before copying values over.

var item = new Item();

var newItem = item with { };
item.State.Add(1);
newItem.State.Add(2);
Console.WriteLine(item);
Console.WriteLine(newItem);

public record Item(List<int> State)
{
    public Item() : this(new List<int>())
    {
    }

    // copy constructor
    protected Item(Item oldItem)
    {
        State = new List<int>(oldItem.State);
    }

    public override string ToString()
    {
        return $"Item {{ State = {string.Join(", ", State)} }}";
    }
}

When we run the previous code, our result shows two separate collections on two record instances.

Item { State = 1 }
Item { State = 2 }

You have two viable approaches to work around reference types in record types.

Another option you should consider is the use of immutable reference types from the start. In the case of this example, you may want to use IReadOnlyList instead of List right in the record’s constructor.

Conclusion

While the record type has syntactic significance in C#, under the covers, it’s just a specialized implementation of a class. The concepts around value types and reference types still apply. Remembering what values you add to your implementations is essential, as incorrectly copying records can lead to strange bugs. My recommendation to most folks using record types would be to implement the copy constructor early on to remember to be intentional about the copying process.

I hope you found this post helpful, and thank you for sharing this post with friends and colleagues. Cheers :)