DEV Community

Anton Grebenkin
Anton Grebenkin

Posted on

The Hidden Behavior of C# Records

C# records, introduced in C# 9, have become a favorite among developers for creating immutable data-transfer objects. Their concise syntax for defining properties, along with built-in value equality and a ToString() override, simplifies a lot of boilerplate code. One of their most celebrated features is the with expression, which allows for "nondestructive mutation"—creating a copy of a record with some properties changed.

However, there's a subtle but critical behavior related to the with expression that can easily catch developers by surprise: properties that are initialized from other primary constructor parameters are not recalculated when you create a copy using with.

The Seemingly Simple Scenario

Let's consider a common use case: a Person record that has a FirstName, a LastName, and a FullName property derived from the other two. A developer might intuitively write it like this:

public record Person(string FirstName, string LastName)
{
    // This property is calculated ONCE during construction.
    public string FullName { get; } = $"{FirstName} {LastName}";
}
Enter fullscreen mode Exit fullscreen mode

This looks perfectly reasonable. When we create a new Person, the FullName property is correctly initialized.

var person1 = new Person("John", "Doe");

// Outputs: FirstName: John, LastName: Doe, FullName: John Doe
Console.WriteLine($"FirstName: {person1.FirstName}, LastName: {person1.LastName}, FullName: {person1.FullName}");
Enter fullscreen mode Exit fullscreen mode

The problem arises when we use the with expression to create a modified copy. What would you expect the FullName to be in the following code?

var person2 = person1 with { FirstName = "Jane" };

// What will this output?
Console.WriteLine($"FirstName: {person2.FirstName}, LastName: {person2.LastName}, FullName: {person2.FullName}");
Enter fullscreen mode Exit fullscreen mode

Many developers would expect the output to be FirstName: Jane, LastName: Doe, FullName: Jane Doe. After all, we changed the FirstName, so the FullName should update accordingly.

The actual output is:

FirstName: Jane, LastName: Doe, FullName: John Doe

The FullName was not recalculated. It retained the value from the original person1 object.

Why Does This Happen? The Mechanics of with

The root cause lies in how the with expression is implemented by the C# compiler. Under the hood, a with expression is syntactic sugar for a method call that performs a shallow, member-wise copy.

When you define a record, the compiler generates a hidden copy constructor. This constructor is what the with expression uses as its foundation. Conceptually, it looks something like this:

// This is a simplified, conceptual representation of what the compiler generates
protected Person(Person original)
{
    this.FirstName = original.FirstName;
    this.LastName = original.LastName;
    this.FullName = original.FullName; // <-- The crucial line
}
Enter fullscreen mode Exit fullscreen mode

The key insight is that the copy constructor copies the values from the backing fields of the original object. In our example, person1 had its FullName backing field initialized to "John Doe". The copy constructor simply copied that string value over to the new instance. It does not re-execute the property initializer (= $"{FirstName} {LastName}"). The property initializer is only executed when the primary constructor is called directly, not when the copy constructor is used.

After the shallow copy is complete, the with expression then assigns the new value for FirstName, but by then it's too late for FullName.

The Solution: Use a True Computed Property

To achieve the expected behavior where a derived property is always up-to-date, you must define it as a true computed property using an expression-bodied member (often called a "fat arrow" property).

Here is the corrected version of the Person record:

public record Person(string FirstName, string LastName)
{
    // This is a computed property. It is re-evaluated on every access.
    public string FullName => $"{FirstName} {LastName}";
}
Enter fullscreen mode Exit fullscreen mode

The key difference is the use of => instead of { get; } =. An expression-bodied property does not have a backing field. Instead, it is essentially a method that is executed every single time the property is accessed.

Now, if we run the same test code with this new record definition:

var person1 = new Person("John", "Doe");
var person2 = person1 with { FirstName = "Jane" };

// Now, this produces the expected result
Console.WriteLine($"FirstName: {person2.FirstName}, LastName: {person2.LastName}, FullName: {person2.FullName}");
Enter fullscreen mode Exit fullscreen mode

The output will be correct:

FirstName: Jane, LastName: Doe, FullName: Jane Doe

This works because when we access person2.FullName, the code $"{FirstName} {LastName}" is executed in the context of person2, using its current property values.

Conclusion

The with expression is a powerful tool for working with immutable data, but it's vital to understand its mechanics. Remember this key distinction:

  • Property with an initializer (public string Prop { get; } = ...): The value is calculated once during initial construction and is copied as-is by the with expression.
  • Computed property (public string Prop => ...): The value is calculated on every access and will always reflect the current state of the object, making it the correct choice for derived values that must stay in sync.

Top comments (0)