Introduced with C#9 (.Net 5) in November 2020, the new record type adds yet another way of declaring types in C#.
By default record acts as reference type, like classes, but since C#10 you can also create a record struct (value type). You can also declare them using record class, but as that’s the default, you can omit the class type when doing so and declare them as just record.
Record types are compiler-generated classes/structs with some predefined methods, they can be declared very lightly on coding but will then gain their features when compiled. They all follow the IEquatable interface.
IEquatable : this interface ensures that they have value-based equality. This means that when comparing two of them, C# automatically compares their properties and the values inside them to determine if they are equal (contrary to classes, which basically compare pointers because they are of reference type so you need to compare them manually or use some external lib). Two record types will be considered equal if they are of the same custom type and have the same value in their properties.
ToString() and PrintMembers()
By default, all objects in C# have a ToString method, unfortunately custom types will basically display their type and that’s it. Record types have an advanced ToString method which will write its type followed by a JSON type string with the properties and their values. Internally ToString calls PrintMembers to do this, which you can also call (it’s protected, so internal use only), PrintMembers only prints the members if that’s all you want.
public record Printable { public string ToStringMembersOnly() { var sb = new StringBuilder(); return PrintMembers(sb) ? sb.ToString() : ""; } } internal class RecordTypeExamples { internal record Person(string Name, int Age); internal record Person2(string Name, int Age) : Printable; public static void Test() { var p1 = new Person("Mike", 20); Console.WriteLine(p1); // Person { Name = Mike, Age = 20 } var p2 = new Person2("John", 25); Console.WriteLine(p2.ToStringMembersOnly()); // Name = John, Age = 25 } }
As I show in that example, the default ToString() prints the type + members, but if you needed to get the members only you can always add an extra method to do so as it’s accessible internally. Also, PrintMembers is virtual, so it can handle inheritance as it will be overridden by the child.
Using “with” expressions
Records allow you to use with expressions to create copies with a different value, which enables non-destructive mutation:
file class WithExamples { internal record Person(string Name, string Email, int Age); public static void TestWith() { var p1 = new Person("Mike", "[email protected]", 20); var p2 = p1 with { Email = "[email protected]" }; } }
So you basically create a copy with a new value while keeping the original.
Note: with expressions were added on C# 9 to be used with record types, but from C#10+ can also be used with structure and anonymous types.
Standard record declaration
public record SomeRecord { public string Name { get; set; } public string Description { get; init; } }
The default record type nomenclature behaves like a class but implements the IEquatable interface, allows with expressions and has the new ToString method. Its declared fields are standard Properties that can be read and written on, though the convention would be to use init-only on the setter to make them immutable.
The standard record declaration does not have a constructor unless defined.
Warning: I’ve read some articles about record types which seem to assume that record types are immutable, the truth is they are only immutable if that’s the way you declare them. The official documentation on this type states that’s the intention indeed, but you are allowed to make them mutable too. So be careful not to make assumptions when you see a record type you didn’t declare.
Positional record
Positional records are declared with a more concise syntax, the C# compiler will turn the params into init-only Properties and add a constructor for you (plus the IEquatable and improved ToString):
internal record Person(string Name, string Email, int Age);
The positional record is immutable by default, meaning the state of the object can’t be modified after initialization, but you can still add your own properties/methods which could be mutable:
internal record Person(string Name, string Email, int Age) { string Notes { get; set; } // This property would be mutable void AddSpecialNote() { // Added methods would be able to also modify the state of mutable properties Notes = "My special note."; } }
Inheritance
Records can only inherit from Object or other Record types.