How to change the behavior of a C# Record constructor
Record[1]is a new feature in C# 9. Record is from Structs[2]Borrowing special classes, because they have value-based equality, you can think of them as a mixture between two types. By default, they are more or less immutable and have syntactic sugar, making declarations easier and more concise. However, syntactic sugar may mask more standard tasks, such as changing the behavior of default constructors. In some cases, you may need to do this to verify. This article will show you how to achieve this.
Take this simple example class as an example:
public class StringValidator { public string InputString { get; } public StringValidator(string inputString) { if ((inputString)) throw new ArgumentNullException(nameof(inputString)); InputString = inputString; } }
Obviously, if consumers try to create an instance of such a class without a valid string, they will receive an exception. The standard syntax for creating a Record is as follows:
public record StringValidator(string InputString);
It's friendly and concise, but it's not clear how you'll verify the string. This definition tells the compiler that there will be a property named InputString, and that the constructor will pass the value from the argument to the property. We need to remove syntax sugar to verify the string. Fortunately, this is easy. We don't need to use the new syntax to define our Record. We can define a record similar to a class, but change the keyword class to a record.
public record StringValidator { public string InputString { get; } public StringValidator(string inputString) { if ((inputString)) throw new ArgumentNullException(nameof(inputString)); InputString = inputString; } }
Unfortunately, this means we cannot use non-destructive mutations [3]. The with keyword creates some properties for us to change the functionality of a new version of Record. This means we won't modify the original instance of Record, but we will get a copy of it. This is a common method for Fluent API and functional programming. This allows us to remain unchanged.
To allow non-destructive mutations, we need to add an init property accessor. This works similarly to how the constructor works, but is only called during object initialization. This is a more complete solution to implementing the init accessor. This allows you to share constructor logic and initialization logic.
using System; namespace ConsoleApp25 { class Program { static void Main(string[] args) { //This throws an exception from the constructor //var stringValidator = new StringValidator(null); var stringValidator1 = new StringValidator("First"); var stringValidator2 = stringValidator1 with { InputString = "Second" }; (); //This throws an exception from the init accessor //var stringValidator3 = stringValidator1 with { InputString = null }; //Output: Second } } public record StringValidator { private string inputString; public string InputString { get => inputString; init { //This init accessor works like the set accessor ValidateInputString(value); inputString = value; } } public StringValidator(string inputString) { ValidateInputString(inputString); InputString = inputString; } public static void ValidateInputString(string inputString) { if ((inputString)) throw new ArgumentNullException(nameof(inputString)); } } }
Should the Record constructor have logic?
This is a controversial debate beyond the scope of this article. Many people will argue that you shouldn't put logic in constructors. Record's design encourages you not to place logic in constructors or init accessors. Generally speaking, Record should represent the snapshot status of the data in a timely manner. You don't need to apply logic because assuming you know the state of the data at this time. But, like all other programming structures, it is not possible to know what use cases Record might produce. This is in the library Urls[4]An example[5] , [6]It treats the URL as an immutable record:
using ; namespace Urls { public record QueryParameter { private string? fieldValue; public string FieldName { get; init; } public string? Value { get => fieldValue; init { fieldValue = (value); } } public QueryParameter(string fieldName, string? value) { FieldName = fieldName; fieldValue = (value); } public override string ToString() => $"{FieldName}{(Value != null ? "=" : "")}{(Value)}"; } }
We make sure that we decode the query value when storing it, and then encode it when we use it as part of the Url.
You might ask: Why not record everything? There seems to be pitfalls related to this, but we are venturing into new territory and we have not yet developed best practices for Record in the C# context.
Summarize
It takes several years for developers to accept Records and formulate basic rules for using them. You currently have a blank piece of paper that you can freely try until the "expert" starts telling you something else. My suggestion is to use Record only to represent fixed data and minimal logic. Use syntactic sugar whenever possible. However, in some cases, minimum validation in the constructor may be feasible. Use your judgment to discuss with your team and weigh the pros and cons.
This is the end of this article about the behavior changes of C# Record constructor. For more related content of C# Record constructor, please search for my previous articles or continue browsing the related articles below. I hope everyone will support me in the future!
References
[1] Record: /en-us/dotnet/csharp/whats-new/tutorials/records
[2] Structs: /en-us/dotnet/csharp/language-reference/builtin-types/struct
[3] Non-destructive mutations: /en-us/dotnet/csharp/whats-new/tutorials/records#non-destructive-mutation
[4] In Urls: /MelbourneDeveloper/Urls
[5] Example: /MelbourneDeveloper/Urls/blob/5f55a9437cfac1223711d616bfdbeb72b230d263/src/Uris/#L5
[6] , : /MelbourneDeveloper/Urls