Skip to main content

Understand the tradeoffs of records in csharp by exploring the features

· 7 min read
Adnan Rafiq
Title Image of the blog

Records - A Reference Type were introduced in C#9 and extended in C#10 to allow record struct - A Value Type. What is so special about records that the .NET team shipped them in two consecutive releases. In nutshell C# lacked a immutable type with true value equality semantics with a short syntax. Records solves this problem. In this post you will learn:

  • Concise Syntax - Positional Properties
  • Value Equality
  • Immutability
  • Non-Destructive Mutation
  • Deconstruction
  • DDD Value Object using records only

Does this make you curious? I am excited to share this with you.

Youtube Video

I have recorded a detailed Youtube video, if you prefer the video content.

Unlock the Powers of C# Record

What are records and its features

A record class is a reference type which gets allocated on heap. It gives you value equality, non-destructive mutation, deconstruction and immutability out of the box. An example record looks like record Name(string FirstName, string LastName);.

Positional Property Syntax

In the following record record Name(string FirstName, string LastName); FirstName and LastName are positional properties which participate in value equality. The compiler behind the scene will implement a constructor, getter and init only setter. It can be used like var name=new Name("Adnan","Rafiq");.

If you love this syntax and do not care about other features, Data Transfer Objects are great use case like Request & Response DTO or you would like pass an object to the downstream internal API to do the work.

Value Equality

The following code will print true on the console.

var i=1; 
var j=1;
Console.WriteLine( i==j );

The below code will also print true on the console. Because both the objects have same values. But remember record is a reference type like class which supports value equality.

var p1=new Name("A","Z"); 
var p2=new Name("A","Z");
Console.WriteLine(p1==p2);

The compiler implements the IEquatable<T> for you. The class can implement the IEquatable<T> manually to get the value equality feature.

Tradeoff: If your requirement is that value equality should only compare some properties to determine equality. You can override the semantics of record value equality as shown in the below example. Also, notice you can have methods like you can have in classes.

record class Name(string FirstName, string LastName)
{
public string Initials() => $"{LastName.AsSpan()[0]}{FirstName.AsSpan()[0]}";

public virtual bool Equals(Name? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return FirstName == other.FirstName;
}

public override int GetHashCode()
{
return HashCode.Combine(FirstName, LastName);
}
}

Immutability

A record declared using positional property syntax is immutable. You can not set the value of its any property because the setter generated by the compiler is init only which can only set value within the constructor. If you try to the following var n=new Name("A","Z"); n.FirstName="C";//compiler error you will get a compiler error.

Since it is immutable, it is thread safe since no one can change its value once created. But if you like to make it mutable, you can certainly do that by declaring the property setter accordingly yourself just like classes, but you should avoid this, and may be your use case does need class instead of record.

Non-Destructive Mutation

Non-Destructive mutation is copying the values of a record object to a new object without changing the original. The following code will copy all the values to new object, and allows you to change all or some using initializer syntax.

var n = new Name("A", "Z");
var n1 = n with { FirstName = "C" };

WriteLine(n);//Prints Name { FirstName = A, LastName = Z }
WriteLine(n1);//Prints Name { FirstName = C, LastName = Z }

//In the above line only FirstName is changed to C, while LastName is Z.

Deconstruction

Deconstruction is breaking up the object property values in order to assign it to variables. In the below code, you will first see the . notation, the regular we access object values, and then using deconstruction like a variable.

var n = new NameA("A", "Z");
WriteLine($"{n.FirstName} {n.LastName}");
var (fn, ln) = n;
WriteLine($"{fn} {ln}"); //Notice no dot notation.

Along with nice syntax it is useful in scenarios where you would like to assign object property value to a local variable to operate on it.

DDD Value Object using records only

In Domain Drive Design (DDD) a Value Object is a concept in which an object which can not exist without an identity object or does not have any meaning unless it is attached to an entity like when Name gets attached to Person will give it meaning. It is known as Value Object, and it must support value equality.

Before the arrival of records in C#10, the value object semantics were achieved by implementing IEquatable<T interface. There are packages available which provides a generic abstract class to achieve the value equality semantics. If your codebase already use a similar solution, then you probably do not need record in the context of value equality.

But in the newer code base, record provides a lot of value, as it conveys the concept of value equality and will probably become part of common knowledge in coming years which brings the value of readability and easier communication among team members.

Another fair criticism is that record class does not offer true value equality when you introduce an array or another reference type inside a record. But it can be solved by overriding the equality contract of record.

Consider the below code example.


public record Name(string FirstName, string LastName, NickName[] NickNames)
{
public override bool Equals(object obj)
{
var other = obj as Name;
if (other == null) return false;
return this.FirstName == other.FirstName
&& this.LastName == other.LastName
&& this.NickNames.SequenceEqual(other.NickNames);
}
public override int GetHashCode()
{
return HashCode.Combine(FirstName, LastName, NickNames);
}
};

Inheritance Support

record does support inheritance like class. Before going the inheritance route consider your use case, do you really need inheritance with value equality semantics, if yes. Sure.

Missing IComparable Implementation

The below code will throw an exception on highlighted line, when you try to sort an array of record type objects. Because it does not implement the ICompareable interface out of the box like value equality.

NickName[] nickNamesOfAdnan = { new("Dani"), new("Sahib") };
var nameOfAdnan = new Name("Adnan", "Rafiq", nickNamesOfAdnan);

NickName[] diffNickNameObject = { new("Dani"), new("Sahib") };
var nameOfAdnanWithDiffLastName = nameOfAdnan with { NickNames = diffNickNameObject };
Name[] names = { nameOfAdnan, nameOfAdnanWithDiffLastName };
foreach (var name in names.OrderBy(x => x))
{
WriteLine(name);
}

But you can implement it yourself if your use case requires sorting by the record object. Since record is a value, it should have supported this feature. The below example implements the IComparable<T> and IComparable.

record class Name(string FirstName, string LastName, NickName[] NickNames) : IComparable<Name>, IComparable
{
public int CompareTo(Name? other)
{
if (ReferenceEquals(this, other)) return 0;
if (ReferenceEquals(null, other)) return 1;
var firstNameComparison = string.Compare(FirstName, other.FirstName, StringComparison.Ordinal);
if (firstNameComparison != 0) return firstNameComparison;
return string.Compare(LastName, other.LastName, StringComparison.Ordinal);
}

public int CompareTo(object? obj)
{
if (ReferenceEquals(null, obj)) return 1;
if (ReferenceEquals(this, obj)) return 0;
return obj is Name other ? CompareTo(other) : throw new ArgumentException($"Object must be of type {nameof(Name)}");
}
}
note

If you do not like to implement the comparable interfaces automatically, you can write source generator to do this for you on build time.

Feedback

I would love to hear your feedback, feel free to share it on Twitter.