Records and the ‘with’ operator, redux

In my previous blog post I described some behaviour of C# record types which was unexpected to me, though entirely correct according to the documentation. This is a follow-up post to that one, so if you haven’t read that one yet, please do so – I won’t go over all the same ground.

Is this a problem or not?

The previous blog post made it to Hacker News, and the comments there, on Bluesky, and on the post itself have been decidedly mixed.

Several people believe this is working as intended, several people believe it’s a terrible decision on the part of the C# language team.

Perhaps unsurprisingly, the most insightful comment was from Eric Lippert, who referred to a post on the Programming Language Design and Implementation Stack Exchange site. Eric has answered the question thoroughly, as ever.

I believe the difference in opinions comes from interpretation about what “with” is requesting. Eric wrote:

The with makes a copy of the record, changing only the value of the property you identified as wishing to change, so it ought not to be surprising that nothing else changed.

That’s not how I’ve ever thought of with – I haven’t expected it to say “this object but with these different properties”, but instead “a new record, with the same parameters as the original one, but with these different parameters“. It’s a subtle distinction – sufficiently subtle that I hadn’t even bothered to think about it until running into this problem – but I suspect it explains how different people think about the same feature in different ways. I wouldn’t have thought of “setting the property” because I think of records as being immutable to start with: that the only way you can end up with a record where the property returns a different value is by providing that different value as part of construction. (Again, to be crystal clear: I don’t think I’ve found any bits of documentation which are incorrect. It’s my mental model that has been wrong.)

I haven’t gone back over previous YouTube videos describing the feature – either from the C# team itself or from other developers – to see whether a) it’s described in terms of setting properties rather than parameters; b) the videos describe the distinction in order to make it clear which is “right”.

In my defence, even when you do have a better mental model for how records work, this is a pretty easy mistake to make, and you need to be on the ball to spot it in code review. The language absolutely allows you to write records which aren’t just “lightweight data records” in the same way that you do for classes – so I don’t think it should be surprising that folks are going to do that.

So, after this introductory spiel, this post has two aspects to it:

  • How am I going to stop myself from falling into the same trap again?
  • What changes have I made within the Election 2029 code base?

Trap avoidance: Roslyn Analyzers

In the previous post, I mentioned writing a Roslyn analyzer as a possible way forward. My initial hope was to have a single analyzer which would just spot the use of with operators targeting any parameter which was used during initialization.

That initial attempt worked to some extent – it would have spotted the dangerous code from the original blog post – but it only worked when the source code for the record and the source code using the with operator were in the same project. I’ve now got a slightly better solution with two analyzers, which can even work with package references where you may not have access to the source code for the record at all… so long as the package author is using the same analyzers! (This will make more sense when you’ve seen the analyzers.)

The source code of the analyzers is on GitHub and the analyzers themselves are in the JonSkeet.RoslynAnalyzers NuGet package. To install them in a project, just add this to an item group in your project file:

<PackageReference Include="JonSkeet.RoslynAnalyzers" Version="1.0.0-beta.6"
        PrivateAssets="all"
        IncludeAssets="runtime; build; native; contentfiles; analyzers"/>

Obviously, it’s all very beta – and there are lots of corner cases it probably wouldn’t find at the moment. (Pull requests are welcome.) But it scratches my particular itch for now. (If someone else wants to take the idea and run with it in a more professional, supported way, ideally in a package with dozens of other useful analyzers, that’s great.)

As I mentioned, there are two analyzers, with IDs of JS0001 and JS0002. Let’s look at how they work by going back to the original demo code from the previous post. Here’s the complete buggy code:

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

// Use of record
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 }

Adding the analyzer package highlights the int Value parameter declaration in Number, with this warning:

JS0001 Record parameter ‘Value’ is used during initialization; it should be annotated with [DangerousWithTarget]

Currently, there’s no code fix, but we need to do two things:

  • Declare an attribute called DangerousWithTargetAttribute
  • Apply the attribute to the parameter

Here’s the complete attribute and record code with the fix applied:

[AttributeUsage(AttributeTargets.Parameter)]
internal sealed class DangerousWithTargetAttribute : Attribute;

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

The attribute doesn’t have to be internal, and indeed in my election code base it’s not. But it can be, even if you’re using the record from a different assembly. The analyzer doesn’t care what namespace it’s in or any other details (although it does currently have to be called DangerousWithTargetAttribute rather than just DangerousWithTarget).

At this point:

  • The source code makes it clear to humans that we know it would be dangerous to set the Value property in a with operator with Number
  • The compiled code makes that clear (to the other analyzer) as well

After applying the above change, we get a different warning – this time on n2 with { Value = 3 }:

JS0002: Record parameter ‘Value’ is annotated with [DangerousWithTarget]

(Both of these warnings have more detailed descriptions associated with them as well as the summary.)

Now you know the problem exists, it’s up to you to fix it… and there are multiple different ways you could do that. Let’s try to get warning-free by replacing our precomputed property with one which is computed on demand. The analyzers don’t try to tell you if [DangerousWithTarget] is applied where it doesn’t need to be, so this code compiles without any warnings, but it doesn’t remove our JS0002 warning:

// No warning here, but the expression 'n2 with { Value = 3 }' still warns.
public sealed record Number([DangerousWithTarget] int Value)
{
    public bool Even => (Value & 1) == 0;
}

As it happens, this has proved unexpectedly useful within the Election2029 code, where even though a parameter isn’t used in initialization, there’s an expected consistency between parameters which should discourage the use of the with operator to set one of them.

Once we remove the [DangerousWithTarget] attribute from the parameter though, all the warnings are gone:

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

The analyzer ignores the Even property because it doesn’t have an initializer – it’s fine to use Value for computed properties after initialization.

A new pattern for Election2029

So, what happened when I enabled the analyzers in my Election2029 project? (Let’s leave aside the bits where it didn’t work first time… there’s a reason the version number is 1.0.0-beta.6 already.)

Predictably enough, a bunch of records were flagged for not specifying the [DangerousWithTarget]… and when I’d applied the attribute, there were just one or two places where I was using the with operator in an unsafe way. (Of course, I checked whether the original bug which had highlighted the issue for me in the first place was caught by the analyzers – and it was.)

For most of the records, the precomputation feels okay to me. They’re still fundamentally pretty lightweight records, with a smattering of precomputation which would feel pointlessly inefficient if I made it on-demand. I like the functionality that I’m given automatically by virtue of them being records. I’ve chosen to leave those as records, knowing that at least if I try to use the with operator in a dangerous operator, I’ll be warned about it.

However, there are two types – ElectionCoreContext and ElectionContext, which I [wrote about earlier] – which have a lot of precomputation. They feel more reasonable to be classes. Initially, I converted them into just “normal” classes, with a primary constructor and properties. It felt okay, but not quite right somehow. I liked the idea of the record type for just the canonical information for the context… so I’ve transformed ElectionContext like this (there’s something similar for ElectionCoreContext):

public sealed class ElectionContext : IEquatable<ElectionContext>
{
    public ElectionContextSourceData SourceData { get; }

    // Bunch of properties proxying access
    public Instant Timestamp => SourceData.Timestamp;
    // ...

    public ElectionContext(ElectionContextSourceData sourceData)
    {
        // Initialization and validation
    }

    public sealed record ElectionContextSourceData(Instance Timestamp, ...)
    {
        // Equals and GetHashCode, but nothing else
    }
}

At this point:

  • I’ve been able to add validation to the constructor. I couldn’t do that with a record in its primary constructor.
  • It’s really clear what’s canonical information vs derived data – I could even potentially refactor the storage layer to only construct and consume the ElectionContextSourceData, for example. (I’m now tempted to try that. I suspect it would be somewhat inefficient though, as it uses the derived data to look things up when deserializing.)
  • I can still use the with operator with the record, when I need to (which is handy in a few places)
  • There’s no risk of the derived data being out of sync with the canonical data, because the ordering is very explicit

Ignoring the naming (and possibly the nesting), is this a useful pattern? I wouldn’t want to do it for every record, but for these two core, complex types it feels like it’s working well so far. It’s early days though.

Conclusion

I’m really pleased that I can now use records more safely, even if I’m using them in ways that other folks may not entirely condone. I may well change my mind and go back to using regular classes for all but the most cut-and-dried cases. But for now, the approaches I’ve got of “use records where it feels right, even if that means precomputation” and “use classes to wrap records where there’s enough behavior to justify it” are working reasonably well.

I don’t really expect other developers to use my analyzers (although you’re very welcome to do so, of course) – but the fact that they’re even feasible points to Roslyn being a bit of a miracle. I’m not recommending either my “careful use of records slightly beyond their intended use” or “class wrapping a record” approaches yet. I’ve got plenty of time to refactor if they don’t work out for the Election2029 project. But I’d still be interested in getting feedback on whether my decisions at least seem somewhat reasonable to others.

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.)