Everything You Want To Know About The Record Type In .NET 5.... But Were Afraid To Ask

In January 2021, I asked the viewers on one of my Rockin’ the Code World with dotNetDave shows on C# Corner Live was, “What is the new class type is for .NET 5?”. The answer is the record class definition that only works in .NET 5 and above. I have been using it for the new code I’m writing for my new open-project called Spargine (releasing soon). I also did a Twitter poll to see if other developers plan to move to .NET 5 so they can use the record type. Let me explain why I’m excited about this new type, and so far, is my favorite new feature in .NET 5.

Everything You Want to Know About the Record Type in .NET 

Many of the classes I create in assemblies are what I call “model types” or POCO classes, which in most cases mean classes that are mainly used to transport data in and out of back-end API services that I usually write using ASP.NET using the Web API. You can think of these as the code first classes used in Entity Framework. They still should follow good architecture, coding standards, but mainly these classes are just to represent data.

For example, the Person class I use in my testing framework. If you navigate to it, you can see it just has auto-properties that look like this.

/// <summary>  
/// Gets or sets the postal code.  
/// </summary>  
/// <value>The postal code.</value>  
public string PostalCode { get; set; }  
This property really should look like this from the PersonProper class that includes appropriate documentation, attributes and more importantly validating data in the setter.  
/// <summary>  
/// Gets or sets the postal code.  
/// </summary>  
/// <value>The postal code.</value>  
/// <exception cref="ArgumentOutOfRangeException">PostalCode</exception>  
[DataMember(Name = "postalCode")]  
[XmlElement]  
public string PostalCode  
{  
    get  
    {  
    return this._postalCode;  
    }  
    set  
    {  
    if (this._postalCode == value)  
    {  
        return;  
    }  
  
    this._postalCode = value.HasValue(0, 15) == false ?   
        throw new ArgumentOutOfRangeException(nameof(this.PostalCode),  
        Resources.PostalCodeLengthIsLimitedTo15Characters) : value;  
    }  
}

On top of proper validation, as shown above, these classes should also implement IComparable<T>, IEquatable<T> and override ToString(), GetHashCode(), and all the operators. That is a lot of extra work that I rarely see developers implement. My original Person class inflates from 99 lines in the class to 647 for PersonProper! Visual Studio is not very useful in helping developers out to create these data classes properly. Usually, these model classes also need to be immutable or act like it. Something else that can be time-consuming to implement.

The .NET team at Microsoft has helped improve this when .NET 5 was released in 2020 with the new record class type helps a lot with the more “boilerplate” code we need to write. First, defining a record type is just like a class, using record instead of class. For example, my PersonClass would be defined like this.

public record PersonRecord : IDataRecord<PersonRecord, string>  

Setting Values

The first big difference with record types is how values are set. Instead of using the set, we use an init like this.

public string PostalCode  
{  
    get  
    {  
        return this._postalCode;  
    }  
    init  
    {  
        if (string.IsNullOrEmpty(value))  
        {  
            throw new ArgumentNullException(nameof(this.PostalCode),  
                 "Value for postal code cannot be null or empty.");  
        }  
  
        this._postalCode = value.Length > 20 ?   
            throw new ArgumentOutOfRangeException(nameof(this.PostalCode),   
            "Postal code length is limited to 20 characters.") : value;  
    }  
}

You can see that init works just like the set, except for the rules,

  1. Init values can be set in the constructor, just like read-only variables.
  2. Init values can be set during object initialization.

Once the object is created, the data cannot be modified, just like immutable types. If I decompile this property for PersonProper, it looks like this.

.property instance string PostalCode  
{  
    .get instance string dotNetTips.Spargine.Tester.Models.PersonProper::get_PostalCode()  
    .set instance void dotNetTips.Spargine.Tester.Models.PersonProper::set_PostalCode(string)  
    .custom instance void [System.Runtime.Serialization.Primitives]System.Runtime.Serialization.DataMemberAttribute::.ctor() = { Name=string('postalCode') }  
    .custom instance void [System.Xml.ReaderWriter]System.Xml.Serialization.XmlElementAttribute::.ctor()  
} 

But this property in a record type looks like this,

.property instance string PostalCode  
 {  
     .get instance string dotNetTips.Spargine.Tester.Models.AddressRecord::get_PostalCode()  
     .set instance void modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit) dotNetTips.Spargine.Tester.Models.AddressRecord::set_PostalCode(string)  
 }

The “magic” behind init for record types is modreq. Modreq’s are user-defined modifiers in IL that have the following contract: If the compiler recognizes the modreq it can do whatever makes sense. However, if a compiler sees a modreq it does not understand on a member it is about to use, it is obliged to fail with an error. For record types, this protects the setter from being unwittingly called by a normal setter. If you are using C# 8 and below, the init setter will always fail.

Updating Values

Since values for record classes cannot be updated once created, then how can code update the values, for example on the client-side so the data can be updated in the backend? Simply, a new object will need to be created, and record types make that very easy by using the with keyword as shown below using my Tester assembly and NuGet package.

var person1 = RandomData.GeneratePersonCollection(count: 1, addressCount: 2).First();  
// Update Postal code  
var person2 = person1 with { CellPhone = "(858) 123-1234"};  

Now, person2 looks like this,

PersonRecord 
{ 
BornOn = 2/18/1981 11:41:33 AM -08:00, CellPhone = (858) 123-1234, Email = [email protected], FirstName = uiSq`JsON^GeoWh, HomePhone = 666-283-3580, Id = 157beaefb49749bd8793f8f3b9931984, LastName = aFsNhYo_NVS^\yB\RAihpclmc
} 

Equality

Whenever you create a class, especially model classes, it is important to implement IComparable<T>, IEquatable<T> and override the equality operators like this,

public static bool operator !=(PersonProper left, PersonProper right) => !( left == right ); 

This creates a lot of extra code, especially for the CompareTo() method. It also creates more work to maintain these types. Due to this, I rarely see developers implementing these methods. There is no need to worry about any of these methods with the record type. These methods are automatically generated as shown below.

[Nullable(1)]  
protected virtual Type EqualityContract  
{  
    [NullableContext(1), CompilerGenerated]  
    get  
    {  
    return Type.GetTypeFromHandle(PersonRecord);  
    }  
}  
  
[NullableContext(2)]  
public static bool operator !=(PersonRecord r1, PersonRecord r2)  
{  
    return r1 != r2;  
}  
  
[NullableContext(2)]  
public static bool operator ==(PersonRecord r1, PersonRecord r2)  
{  
    if (r1 != r2)  
    {  
    if (r1 != null)  
    {  
        return r1.Equals(r2);  
    }  
  
    return false;  
    }  
  
    return true;  
}  
  
[NullableContext(2)]  
public override bool Equals(object obj)  
{  
    return this.Equals(obj as PersonRecord);  
}  
  
[NullableContext(2)]  
public virtual bool Equals(PersonRecord other)  
{  
    if (!other == null ||   
        !(this.EqualityContract == other.EqualityContract) ||   
   !EqualityComparer<List<IAddressRecord>>.Default.Equals(this._addresses, other._addresses) ||  
        !EqualityComparer<DateTimeOffset>.Default.Equals(this._bornOn, other._bornOn) ||   
        !EqualityComparer<string>.Default.Equals(this._cellPhone, other._cellPhone) ||   
        !EqualityComparer<string>.Default.Equals(this._email, other._email) ||   
        !EqualityComparer<string>.Default.Equals(this._firstName, other._firstName) ||   
        !EqualityComparer<string>.Default.Equals(this._homePhone, other._homePhone) ||   
        !EqualityComparer<string>.Default.Equals(this._id, other._id))  
    {  
    return EqualityComparer<string>.Default.Equals(this._lastName, other._lastName);  
    }  
  
    return false;  
}  

One important feature of record types is that when using these equality operators, it is checking the actual data values of the objects, not their instances. Very cool!

Object HashCode

There is no need to override the GetHashCode() method either since they are auto-generated as shown below. There is no need to worry about the maintenance of this method either!

public override int GetHashCode()  
{  
  return (((((((EqualityComparer<Type>.Default.GetHashCode(this.EqualityContract) * -1521134295 + EqualityComparer<List<IAddressRecord>>.Default.GetHashCode(this._addresses)) * -1521134295 + EqualityComparer<DateTimeOffset>.Default.GetHashCode(this._bornOn)) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(this._cellPhone)) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(this._email)) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(this._firstName)) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(this._homePhone)) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(this._id)) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(this._lastName);  
} 

Overloading ToString()

The default implementation of ToString() in types is to return the name of the type, which is not very useful, so it is up to us to overwrite it to return something the better represents the data in the object. At my current contract, recently the lead developer came to me and said that he wanted all changes we push into Salesforce to be logged to the custom text field he created. This turned into a challenge to figure this out. It took me a while to come up with a way to come up with a generic method that supported properties that were nested and/or collections. I wrote about this in my article titled Coding Faster With The dotNetTips Utility - February 2021 Update.

The record type has this covered too! Now calling ToString() for my PersonRecord looks like this,

PersonRecord {
Addresses = System.Collections.Generic.List`1[dotNetTips.Spargine.Tester.Models.AddressRecord], BornOn = 2/20/1974 1:06:36 PM -08:00, CellPhone = (858) 123-1234, Email = [email protected], FirstName = `OkRd_TQXfONhtH, HomePhone = 744-817-4861, Id = d6e1664bb11b421fb80fb8f1ef1804ab, LastName = gUbkABVdnrZ[crPCgTMfoGoe[ 
}  

Here is the auto-generated ToString() code,  

public override string ToString()  
{  
    StringBuilder builder = new StringBuilder();  
    builder.Append("PersonRecord");  
    builder.Append(" { ");  
    if (this.PrintMembers(builder))  
    {  
    builder.Append(" ");  
    }  
  
    builder.Append("}");  
    return builder.ToString();  
}  
  
[NullableContext(1)]  
protected virtual bool PrintMembers(StringBuilder builder)  
{  
    builder.Append("Addresses");  
    builder.Append(" = ");  
    builder.Append(this.Addresses);  
    builder.Append(", ");  
    builder.Append("BornOn");  
    builder.Append(" = ");  
    builder.Append(this.BornOn.ToString());  
    builder.Append(", ");  
    builder.Append("CellPhone");  
    builder.Append(" = ");  
    builder.Append(this.CellPhone);  
    builder.Append(", ");  
    builder.Append("Email");  
    builder.Append(" = ");  
    builder.Append(this.Email);  
    builder.Append(", ");  
    builder.Append("FirstName");  
    builder.Append(" = ");  
    builder.Append(this.FirstName);  
    builder.Append(", ");  
    builder.Append("HomePhone");  
    builder.Append(" = ");  
    builder.Append(this.HomePhone);  
    builder.Append(", ");  
    builder.Append("Id");  
    builder.Append(" = ");  
    builder.Append(this.Id);  
    builder.Append(", ");  
    builder.Append("LastName");  
    builder.Append(" = ");  
    builder.Append(this.LastName);  
    return true;  
}

As you can see the PrintMembers() methods are generated to handle this. I sure wish our projects were .NET 5 already, which we will update for the next release! The only issue I have found with ToString() is that it does not work for properties that are collections. Instead, it prints the object name. Until this is improved, we can use the PropertiesToString() method discussed in the article listed above. This is the output from that method for the PersonRecord.

PersonRecord.Addresses[0].Address1:_tJ`TiaSNtiyHxc\JyqnbbIlN, PersonRecord.Addresses[0].Address2:MkWLFlsfjnWUghWbfhBnnY`no, PersonRecord.Addresses[0].City:Z`lfyvkXSGwKGIHpTWJMG]Q[M, PersonRecord.Addresses[0].Country:vsHwZnLcWeLHyjqfwUaYW``tx, PersonRecord.Addresses[0].CountyProvince:[BVaiwOQZDDdZrRlaj]v, PersonRecord.Addresses[0].Id:b0097f4df44a4b53ad2241e769e6305e, PersonRecord.Addresses[0].Phone:636-251-1607, PersonRecord.Addresses[0].PostalCode:33288770, PersonRecord.Addresses[0].State:ghwp`gnB^\PgpoC, PersonRecord.Addresses[1].Address1:wQGUDA_o_OdgiW[s_w_Cnxrny, PersonRecord.Addresses[1].Address2:^_ffZUyaQOhrYkAIsXauyFeDb, PersonRecord.Addresses[1].City:jjQmNyYRU`\sIFseFXHlAGL^t, PersonRecord.Addresses[1].Country:uaQsAXscDiehnDPPaDL_OjSgQ, PersonRecord.Addresses[1].CountyProvince:sh^Rbd^lrSXHXUHcPFVO, PersonRecord.Addresses[1].Id:1599d6e9e1264f78b96eed93627cb36b, PersonRecord.Addresses[1].Phone:823-744-0085, PersonRecord.Addresses[1].PostalCode:12550632, PersonRecord.Addresses[1].State:\FGuujrIIiDuxev, PersonRecord.BornOn:2/14/1998 1:13:43 PM -08:00, PersonRecord.CellPhone:(858) 123-1234, PersonRecord.Email:[email protected], PersonRecord.FirstName:B`aXJDy[bGyVpxL, PersonRecord.HomePhone:204-280-3088, PersonRecord.Id:61b9b0325f644069b25320b97e813336, PersonRecord.LastName:a^FQBmQn]jQ^GSSQ_NBFIxvv^

Much better don’t you think? I have requested performance changes for the ToString() method in record types: https://developercommunity2.visualstudio.com/t/Improve-Performance-of-ToString-for-Re/1337296.

Performance

Since code performance is important to me, I even wrote a book about it, I set out to see if using the new record class type is more performant.

Cloning

Due to the way that the record type works, you cannot do a normal clone, you just need to create a new object and change any values using with. The benchmark below shows the speed difference between creating a new record object as opposed to cloning a normal class from my testing NuGet package.

Everything You Want to Know About the Record Type in .NET 

Wow, look at that! The PersonRecord is 7,862ns faster!

Computing SHA256 Hash

Below are the results of creating a SHA256 Hash of these two types.

Everything You Want to Know About the Record Type in .NET 

As you can see the PersonRecord is 2,611ns faster!

Converting Object Property Values to Dictionary and a String

Something new I have added to my open-source projects is to convert any object to a collection of Dictionary records or a string. I was curious how these methods worked with PersonRecord.

Everything You Want to Know About the Record Type in .NET 

You can see that in every test, PersonRecord is more performant!

Converting Object to JSON

Even converting record types to JSON, using the built-in .NET 5 JSON serializer, is faster!

Everything You Want to Know About the Record Type in .NET

Summary

The takeaway is the using the record type can save a lot of coding and saves all the maintenance costs later down the road. For example, my normal fully implement Person type is 681 lines of code while my Person record type is only 260 lines! I plan to do more benchmarking for the record type that I will put in the next edition of my code performance book.

Here are my main reasons to use record.

  1. Makes creating immutable classes very easy!
  2. Makes updating types easy using the with keyword.
  3. Inheritance works the same as normal classes!
  4. Equality operators, GetHashCode() and a ToString() that returns the properties and their values!
  5. Record types are more performant!!!

There are many reasons to start moving your projects to .NET 5 and this is another one! If you have any comments or suggestions, please comment below.


McCarter Consulting
Software architecture, code & app performance, code quality, Microsoft .NET & mentoring. Available!