Unexpected inconsistency in records

Unexpected inconsistency in records

The other day, I was trying to figure out a bug in my code, and it turned out to be a misunderstanding on my part as to how C# records work. It’s entirely possible that I’m the only one who expected them to work in the way that I did, but I figured it was worth writing about in case.

As it happens, this is something I discovered when making a change to my 2029 UK general election site, but it isn’t actually related to the election, so I haven’t included it in the election site blog series.

Recap: nondestructive mutation

When records were introduced into C#, the “nondestructive mutation” with operator was introduced at the same time. The idea is that record types can be immutable, but you can easily and efficiently create a new instance which has the same data as an existing instance, but with some different property values.

For example, suppose you were to have a record like this:

public sealed record HighScoreEntry(string PlayerName, int Score, int Level);

You could then have code of:

HighScoreEntry entry = new("Jon", 5000, 50);

var updatedEntry = entry with { Score = 6000, Level = 55 };

This doesn’t change the data in the first instance (so entry.Score would still be 5000).

Recap derived data

Records don’t allow you to specify constructor bodies for the primary constructor (something I meant to write about in my earlier post about records and collections, but you can initialize fields (and therefore auto-implemented properties) based on the values for the parameters in the primary constructor.

So as a very simple (and highly contrived) example, you could create a record which determines whether or not a value is odd or even on initialization:

public sealed record Number(int Value)
{
    public bool Even { get; } = (Value & 1) == 0;
}

At first glance, this looks fine:

var n2 = new Number(2);
var n3 = new Number(3);
Console.WriteLine(n2); // Output: Number { Value = 2, Even = True }
Console.WriteLine(n3); // Output: Number { Value = 3, Even = False }

So far, so good. Until this week, I’d thought that was all fine.

Oops: mixing with and derived data

The problem comes when mixing these two features. If we change the code above (while leaving the record itself the same) to create the second Number using the with operator instead of by calling the constructor, the output becomes incorrect:

var n2 = new Number(2);
var n3 = n2 with { Value = 3 };
Console.WriteLine(n2); // Output: Number { Value = 2, Even = True }
Console.WriteLine(n3); // Output: Number { Value = 3, Even = True }

“Value = 3, Even = True” is really not good.

How does this happen? Well, for some reason I’d always assumed that the with operator called the constructor with the new values. That’s not actually what happens. The with operator above translates into code roughly like this:

// This won't compile, but it's roughly what is generated.
var n3 = n2.<Clone>$();
n3.Value = 3;

The <Clone>$ method (at least in this case) calls a generated copy constructor (Number(Number)) which copies both Value and the backing field for Even.

This is all documented – but currently without any warning about the possible inconsistency it can introduce. (I’ll be emailing Microsoft folks to see if we can get something in there.)

Note that because Value is set after the cloning operation, we couldn’t write a copy constructor to do the right thing here anyway. (At least, not in any sort of straightforward way – I’ll mention a convoluted approach later.)

In case anyone is thinking “why not just use a computed property?” obviously this works fine:

public sealed record Number(int Value)
{
    public bool Even => (Value & 1) == 0;
}

Any property that can easily be computed on demand like this is great – as well as not exhibiting the problem from this post, it’s more efficient in memory too. But that really wouldn’t work for a lot of the properties in the records I use in the election site, where often the record is constructed with collections which are then indexed by ID, or other relatively expensive computations are performed.

What can we do?

So far, I’ve thought of four ways forward, none of them pleasant. I’d be very interested to hear recommendations from others.

Option 1: Shrug and get on with life

Now I know about this, I can avoid using the with operator for anything but “simple” records. If there are no computed properties or fields, the with operator is still really useful.

There’s a risk that I might use the with operator on a record type which is initially “simple” and then later introduce a computed member, of course. Hmm.

Option 2: Write a Roslyn analyzer to detect the problem

In theory, at least for any records being used within the same solution in which they’re declared (which is everything for my election site) it should be feasible to write a Roslyn analyzer which:

  • Analyzes every member initializer in every declared record to see which parameters are used
  • Analyzes every with operator usage to see which parameters are being set
  • Records an error if there’s any intersection between the two

That’s quite appealing and potentially useful to others. It does have the disadvantage of having to implement the Roslyn analyzer though. It’s been a long time since I’ve written an analyzer, but my guess is that it’s still a fairly involved process. If I actually find the time, this is probably what I’ll do – but I’m hoping that someone comments that either the analyzer already exists, or explains why it isn’t needed anyway.

Update, 2025-07-29: I’ve written a pair of analyzers! See my follow-up post for more details.

Option 3: Figure out a way of using with safely

I’ve been trying to work out how to potentially use Lazy<T> to defer computing any properties until they’re first used, which would come after the with operator set new values for properties. I’ve come up with the pattern below – which I think works, but is ever so messy. Adopting this pattern wouldn’t require every new parameter in the parent record to be reflected in the nested type – only for parameters used in computed properties.

public sealed record Number(int Value)
{
    private readonly Lazy<ComputedMembers> computed =
        new(() => new(Value), LazyThreadSafetyMode.ExecutionAndPublication);

    public bool Even => computed.Value.Even;

    private Number(Number other)
    {
        Value = other.Value;
        // Defer creating the ComputedMembers instance until 
        computed = new(() => new(this), LazyThreadSafetyMode.ExecutionAndPublication);
    }

    // This is a struct (or could be a class) rather than a record,
    // to avoid creating a field for Value. We only need the computed properties.
    // (We don't even really need to use a primary
    // constructor, and in some cases it might be best not to.)
    private struct ComputedMembers(int Value)
    {
        internal ComputedMembers(Number parent) : this(parent.Value)
        {
        }

        public bool Even { get; } = (Value & 1) == 0;
    }
}

This is:

  • Painful to remember to do
  • A lot of extra code to start with (although after it’s been set up, adding a new computed member isn’t too bad)
  • Inefficient in terms of memory, due to adding a Lazy<T> instance

The inefficiency is likely to be irrelevant in “large” records, but it makes it painful to use computed properties in “small” records with only a couple of parameters, particularly if those are just numbers etc.

Option 4: Request a change to the language

I bring this up only for completeness. I place a lot of trust in the C# design team: they’re smart folks who think things through very carefully. I would be shocked to discover that I’m the first person to raise this “problem”. I think it’s much more likely that the pros and cons of this behaviour have been discussed at length, and alternatives discussed and prototyped, before landing on the current behaviour as the least-worst option.

Now maybe the Roslyn compiler could start raising warnings (option 2) so that I don’t have to write an analyzer – and maybe there are alternatives that could be added to C# for later versions (ideally giving more flexibility for initialization within records in general, e.g. a specially named member that is invoked when the instance is “ready” and which can still write to read-only properties)… but I’m probably not going to start creating a proposal for that without explicit encouragement to do so.

Conclusion

It’s very rare that I discover a footgun in C#, but this really feels like one to me. Maybe it’s only because I’ve used computed properties so extensively in my election site – maybe records really aren’t designed to be used like this, and half of my record types should really be classes instead.

I don’t want to stop using records, and I’m definitely not encouraging anyone else to do so either. I don’t want to stop using the with operator, and again I’m not encouraging anyone else to do so. I hope this post will serve as a bit of a wake-up call to anyone who is using with in an unsound way though.

Oh, and of course if I do write a Roslyn analyzer capable of detecting this, I’ll edit this post to link to it. (As noted earlier, this is that post.)

16 thoughts on “Unexpected inconsistency in records”

  1. I dunno, seems a bit like a “Doctor, it hurts when I do this” kind of problem. If a field is dependent on the current, actual value of another field, and not just on the initialized value of that field, then clearly it should always be computed, not only on initialization.

    But then I don’t use records myself ever since I discovered that field-level equality doesn’t extend to fields that are collections. With that caveat records just aren’t useful enough to justify themselves in my mind. Better to write a class with fully custom equality.

    Like

    1. Given that records are generally intended to be immutable (and the ones I create are deeply so), I wouldn’t expect there to be any difference between “the initialized value” and “the current actual value”. That’s what caused the surprise.

      It turns out that they’re “one time mutable” – after field initializes have been run, but before the instance is otherwise observable. That’s the surprising part, to me.

      Like

      1. I default to using computed property, but it doesn’t work for lazy initialisation as you say.

        I think a lazy language feature would be a great solution to this and I’m sure they would make it behave properly when cloning records

        Like

  2. “Records don’t allow you to specify constructor bodies for the primary constructor”. I use mainly record structs and they do, but to be sure I checked the class record, and… I see no error:
    “`

    public sealed record class Number
    {
    public Number(int Value)
    {
    this.Value = Value;
    Even = (Value & 1) == 0;
    }

    public bool Even { get; }
    public int Value { get; init; }

    public void Deconstruct(out int Value)
    {
    Value = this.Value;
    }
    }
    “`

    Like

  3. I agree this is confusing and a potential source of bugs. After reading Eric’s response on Stack Exchange, I realize how someone expects this to work might come down to how they read `with`:

    (1) “syntactic sugar to replace a constructor call”

    HighScoreEntry entry = new(“Jon”, 5000, 50);
    var updatedEntry = new(entry.PlayerName, 6000, 55);

    or,

    (2) “copy and write to read-only properties”

    HighScoreEntry entry = new(“Jon”, 5000, 50);
    var updatedEntry = new(entry) {
    Score = 6000, Level = 55
    }

    However there is some consistency between what you see using “with” and object initialization since you can also run into this by doing:

    var n = new Number(10) {
    Value = 5
    }

    Will show n.Even = true.

    Maybe that’s why “with” was designed to behave as it does?

    Like

  4. Another solution consistent with the field-copy behavior is to pass in a computed-value cache instance rather than caching the value itself in the record. This has the benefit that if the changed properties don’t impact the compound-key for a particular computed property, it wouldn’t need to be recomputed.

    Like

  5. Yeah, agreed that it isn’t exactly intuitively obvious at first glance that the primary constructor of a record, whether it is just a positional record, or a full record with a primary constructor, is a bit more special than your typical primary constructor, unless you’ve read the docs already. Nor is it immediately intuitively obvious that the generated copy constructor copies all fields, and does not, in fact, GAF about properties at all.

    `with` is shorthand for a call to the copy constructor with the following block being an object initializer that follows the normal rules of an object initializer.

    If you mutate anything in the with statement, the field did get copied, first, then field initializers in the record definition, if any, are executed, and then the initializer in the with statement is executed before you are handed your shiny new mutated record instance.

    So you can as much as triple-assign a field if you have an explicit backing field with an initializer for the corresponding primary constructor parameter, and then mutate it in a with statement.

    And you can mix the behavior of positional properties and primary constructor parameters, which is extra non-obvious.

    Consider this record, which demonstrates these concepts:

    public record ExampleRecord(int A, int B)
    {
    public int A
    {
    get => field;
    set
    {
    Console.WriteLine($”Called setter for {nameof(A)}. Previous value was {field}.”);
    field = value;
    }
    }
    }

    ExampleRecord thing1 = new(1,1);
    Console.WriteLine(JsonSerializer.Serialize(thing1));

    Console.WriteLine(“Copying without mutation”);
    ExampleRecord thing2 = thing1 with {};
    Console.WriteLine(JsonSerializer.Serialize(thing2));

    Console.WriteLine(“Directly calling set accessor for A”);
    thing2.A = 2;
    Console.WriteLine(JsonSerializer.Serialize(thing2));

    Console.WriteLine(“Copying with mutation of A only”);
    ExampleRecord thing3 = thing2 with { A = 3 };
    Console.WriteLine(JsonSerializer.Serialize(thing3));

    Console.WriteLine(“Copying with mutation of A and B”);
    ExampleRecord thing4 = thing3 with { A = 4, B = 4 };
    Console.WriteLine(JsonSerializer.Serialize(thing4));

    Console.WriteLine(“Copying with mutation of B only”);
    ExampleRecord thing5 = thing4 with { B = 5 };
    Console.WriteLine(JsonSerializer.Serialize(thing5));

    Output:

    {“B”:1,”A”:0}
    Copying without mutation
    {“B”:1,”A”:0}
    Directly calling set accessor for A
    Called setter for A. Previous value was 0.
    {“B”:1,”A”:2}
    Copying with mutation of A only
    Called setter for A. Previous value was 2.
    {“B”:1,”A”:3}
    Copying with mutation of A and B
    Called setter for A. Previous value was 3.
    {“B”:4,”A”:4}
    Copying with mutation of B only
    {“B”:5,”A”:4}

    As you can see, A was never set in the first record, as it is merely a parameter of the primary constructor with no auto-generated property or backing field associated with it, so it is just lost.
    The only time it ever got assigned the value we asked for was when we set it explicitly, either directly via the set accessor or in the initializer of a with statement.

    B was set in all four because it is a positional property and has an auto-generated backing field that the generator is aware of.

    But the backing field of A is always copied, regardless, in the auto-generated copy constructor, and is set directly, which the mutations for thing3 and thin4 illustrate in their output, when the initializer is run and sets the property, resulting in the output with the previous value, which is the copied value.

    thing5 proves that the backing field is copied directly for A even though it’s not a positional property, as it has the value from thing4 and did not result in output for calling the set accessor.

    I used the field keyword here, but the behavior is identical if you explicitly declare the backing field for A.

    However, you can fix it all if you declare that backing field and use an initializer. That makes A behave the same as if it were a positional property, plus also call the setter if you mutate it in the with statement.

    I was pleasantly surprised, though, to note that intellisense in Visual Studio is aware of the difference between the two subtly different symbols A and B, in the primary constructor, as were analyzers yelling at me for never using them. They all correctly referred to A, in the primary constructor, as “parameter A” and referred to B as “positional property B” even though they both have properties of the same name, in the final generated record.

    Like

  6. All that is to say that I don’t really think there’s an inconsistency here.

    Those properties behave the same way in a struct, when you use a with statement to copy a struct (not just a record struct), as well as in a normal class that has a primary constructor, which is then instantiated using an object initializer. It’s just a consequence of realizing you’re in a static context in those initializers, if you’re referring to a symbol that happens to appear in a primary constructor.

    So, lazy compute-and-store properties that are set with static initializers are more of a design flaw in the type being written than a language flaw, I’d argue.

    Like

  7. But that really wouldn’t work for a lot of the properties in the records I use in the election site, where often the record is constructed with collections which are then indexed by ID, or other relatively expensive computations are performed.

    I suspect that records might not be the right thing to do here.

    In a prior job, before records, we had immutable classes implemented the “old” way.

    A developer came up with a copy pattern where the Clone method had optional arguments that would default to null. Leaving the argument null told the clone method to keep the value. For properties that allowed null, we had a simple wrapper type that would easily typecast.

    I suspect this approach would be better in your case, because it allows the kind of nuanced logic on copy that you need.

    Like

  8. The fruit of C++ification of the language. Is there any way of initialization not copied from C++, or, even more preferably, from a long dead, but important (or let’s say, !important) language?

    Like

Leave a reply to Brandon Cancel reply