'Prevent C# "with expressions" bypassing constructor validation

I was disappointed to discover that C#'s "with expressions" allow the caller to bypass constructor validations on the record.

Consider:

record AscendingPair { 
    public AscendingPair(int small, int large)
    {
        if (small >= large) throw new ArgumentException("");
        (Small, Large) = (small, large);
    }
    public int Small { get; init; }
    public int Large { get; init; }
}

[Test]
public void Can_create_an_invalid_pair() {
    var valid = new AscendingPair(1, 2);
    var invalid = valid with { Small = 3 }; // This does not throw :(
}

Is there a smart workaround that would allow use of with, but still enforce the validation?



Solution 1:[1]

The with expression is lowered into something like this (check this on SharpLab):

var temp = valid.<Clone>$(); // you can't actually access this from C#
temp.Small = 3;
var invalid = temp;

This is documented here:

First, receiver's "clone" method (specified above) is invoked and its result is converted to the receiver's type. Then, each member_initializer is processed the same way as an assignment to a field or property access of the result of the conversion. Assignments are processed in lexical order.

Notes:

  • valid.<Clone$>() just calls the copy constructor of the record (new AscendingPair(valid)).
  • It is possible to set temp.Small here because you are doing it in the member initialiser list, which is one of the places where you can set init-only properties.

Now it should be clear how exactly the with expression bypasses the check in your constructor.

One way to solve this is to move the check to the init accessors:

record AscendingPair { 
    public AscendingPair(int small, int large)
    {
        if (small >= large) throw new ArgumentException("");
        (Small, Large) = (small, large);
    }
    private int small;
    private int large;
    public int Small { 
        get => small;
        init {
            if (value >= large) {
                throw new ArgumentException("");
            }
            small = value;
        }
    }
    public int Large { 
        get => large;
        init {
            if (small >= value) {
                throw new ArgumentException("");
            }
            large = value;
        }
    }
}

There is an important caveat to this fix though: the order of the assignments in the with expression now matters. This is a natural consequence of how the with expression is lowered, with each assignment being "processed in lexical order". For example:

var valid = new AscendingPair(1, 2);
var invalid = valid with { Large = 4, Small = 3 };

is fine, but,

var valid = new AscendingPair(1, 2);
var invalid = valid with { Small = 3, Large = 4 };

throws an exception.

We can't really do anything about this though, because to solve this problem, we would need to move the check to after all the assignments of the with expression have completed, but as far as I know, we can't really know when that is inside the record. The lowered code does not call an extra method or anything like that at the end of the series of assignments.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 richardec