I was working with another developer the other day, using C# records. When using a record, you may have seen syntax similar to primary constructors to declare members via positional parameters.
public record MyRecord(int PropertyOne, int PropertyTwo);
The above is equivalent to either of the following:
public record MyRecord
{
public MyRecord (int propertyOne, int propertyTwo)
{
PropertyOne = propertyOne;
PropertyTwo = propertyTwo;
}
public required int PropertyOne { get; init; }
public required int PropertyTwo { get; init; }
}
public record MyRecord(int PropertyOne, int PropertyTwo)
{
public required int PropertyOne { get; init; } = PropertyOne;
public required int PropertyTwo { get; init; } = PropertyTwo;
}
But what happens when you use both record members and properties? I ran into this exact situation the other day. Let’s explore the consequences of doing so!
From Positional Parameters to a Constructor
Let’s start with our combined record:
public record MyRecord(int PropertyOne, int PropertyTwo)
{
public int PropertyOne { get; init; }
public int PropertyTwo { get; init; }
}
The first thing you’ll notice if you’re in an IDE is a very useful set of warnings, that give us a good hint that we’re doing something wrong.
CS8907: Parameter ‘PropertyOne’ is unread. Did you forget to use it to initialize the property with that name?
Positional parameter ‘PropertyOne’ is never used. Did you forget to use it to initialize the property with that name?
As an aside: If you’re treating warnings as errors (which you should do at every opportunity), this wouldn’t even build. For the sake of demonstration we’ll not do that, to see what’s going on.
If we were to try using our record, our two properties would remain uninitialized:
MyRecord record = new(1, 2);
Console.WriteLine($"{record.PropertyOne} {record.PropertyTwo}"); // Outputs "0 0", not "1 2"!
But if we used object initialization (which would bypass the constructor values), the result would be as expected:
MyRecord record = new(1, 2)
{
PropertyOne = 3,
PropertyTwo = 4
};
Console.WriteLine($"{record.PropertyOne} {record.PropertyTwo}"); // Outputs "3 4"
In short, the record is no longer declaring positional parameters, but a more conventional constructor whose values are not being used (as the helpful warning tells us). We could initialize our properties from the constructor’s values, but at that point we’re just adding a lot of redundant code, as we could simplify it to our original example:
public record MyRecord(int PropertyOne, int PropertyTwo);
Type Enforcement
Records go a step further than a conventional constructor, however. Types must match! Attempting to have different types will result in a compilation error.
public record MyRecord(string PropertyOne, int PropertyTwo)
{
public int PropertyOne { get; init; } = int.Parse(PropertyOne);
public int PropertyTwo { get; init; } = PropertyTwo;
}
CS8866: Record member ‘Example.MyRecord.PropertyOne’ must be a readable instance property or field of type ‘string’ to match positional parameter ‘PropertyOne’.
Record member ‘int Example.MyRecord.PropertyOne’ must be a readable instance property or field of type ‘string’ to match positional parameter ‘PropertyOne’
Keep It Simple By Using One or the Other
Reducing your records to using either positional parameters or declared properties is the way to go here. The compiler, your IDE, or both will warn you or not let you continue if you try using both in most circumstances, but ignoring the warnings can lead to unintended bugs! Whether you use positional parameters or properties is up to you or the shape of your record. Sometimes the quick and easy positional parameters are all you need. Other times you may want to explicitly declare the properties for various reasons (such as adding attributes or allowing setting properties after creation).
Either way, just use one or the other!