In this article, we are going to discuss a new feature added in C#9, called “records”. Records offer an alternative to classes and structs and offer a number of advantages over those types that we’ll cover in this article.

To download the source code for this article, you can visit our GitHub repository.

To start off, let’s cover what records actually are, and how they differ to classes and structs. 

What Are Records?

Records are reference types, just like classes. The main way they differ from classes is when it comes to equality.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

Two records are equal if:

  • Definitions are equal (e.g same number/name of properties)
  • Values in each of those definitions are equal

On the other hand, two classes are equal if:

  • The two objects are of the same class type
  • Variables refer to the same object

In other words, records use value-based equality, whilst classes use memory-based equality.

Now that we understand what records are at a high level, let’s dig into how we can actually use them.

Basic Syntax

To declare a record, we use the recordsyntax, in place of where we would use class or struct:

public record Person

Using the above syntax, we are implicitly using record classes. We can be explicit by using the class keyword:

public record class Person

Alternatively, we can use a record struct:

public record struct Person

When we mark our class or struct as a record, the compiler will generate a bunch of methods for our convenience such as overriding the Equals method and several operators. These will come in handy when it comes to equality, which we will discuss in the next section.

Equality

As we mentioned earlier, two records are equal if the definitions and the values are equal.

Let’s look at an example, by creating a simple record:

public record Person
{
    public Person(string firstName, string lastName)
    {
        FirstName = firstName;
        LastName = lastName;
    }

    public string LastName { get; set; }
    public string FirstName { get; set; }
}

Now let’s create a few records and then see if they are equal:

public class Program
{
    public static void Main(string[] args)
    {
        var person1 = new Person("Joe", "Bloggs");
        var person2 = new Person("Joe", "Bloggs");
        var person3 = new Person("Jane", "Bloggs");

        Console.WriteLine($"Person1 == Person2? {person1 == person2}");
        Console.WriteLine($"Person1 == Person3? {person1 == person3}");

        Console.ReadKey();
    }
}

The output is:

Person1 == Person2? True
Person1 == Person3? False

As we expected, because person 1 and person 2 share the same definition and values, they are equal, whilst person 3 has a different FirstName, so it is not equal.

This behavior can be extremely useful to check if two records have changed, for example, if you’re passing a record to a different thread/process and want to see if the values have changed. This ties nicely to the next feature and benefit of records that we will discuss, being immutability.

Immutability of Records

Records are designed to be used for scenarios where immutability is key. For example, if we design a multi-threaded application where we pass objects around. They are also designed to hold data structures and not states.

There are a number of features included with records that help with this design goal.

Init-only Properties

The first one is “init-only properties”.

Let’s modify our Person class:

public record Person
{
    public string FirstName { get; init; }
    public string LastName { get; init; }    
}

We are now using the init operator to specify that the properties of the Person record can only be set during initialization.

Let’s modify our console app to now use the object initializer to set the properties:

var person1 = new Person { FirstName = "Joe", LastName = "Bloggs" };
var person2 = new Person { FirstName = "Joe", LastName = "Bloggs" };
var person3 = new Person { FirstName = "Jane", LastName = "Bloggs" };

If we then try to modify the FirstName property of person1, we get the following error:

CS8852: Init-only property or indexer 'Person.FirstName' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor.

Positional Records

The more concise way of presenting this behavior is by using “positional records”.

Let’s modify our Person class again:

public record class Person(string FirstName, string LastName);

Now we can modify our code again to go back to the constructor method:

var person1 = new Person("Joe", "Bloggs");
var person2 = new Person("Joe", "Bloggs");
var person3 = new Person("Jane", "Bloggs");

We still get the same error if we try and change any of the properties again.

This is probably the key feature to call out. We want to capture the initial state of the data and pass it around our app, but we do not want anything modifying it in any way. If we want the record similar to the state of another record, however, we can use the special cloning features of records, which we’ll discuss in the next section.

Cloning Records

When it comes to cloning in .NET, there are usually two possibilities:

  • Shallow cloning (the members of the copied object refer to the same object as the original object)
  • Deep cloning (the members of the copied object are completely separate)

With regular classes, to do a shallow clone we can use the MemberwiseClone() method that is inherited from the base Object class. To do a deep clone, we would need to implement ICloneable and do it ourselves (perhaps, with some kind of serialization).

Built into records, there is a special with operator we can use:

var person4 = person3 with { LastName = "Doe" };
Console.WriteLine(person4);

In the above example, we are saying that we would like a copy of the person3 object, but with the LastName property set to “Doe”, and all other properties the same. This syntax can come in very handy when we want to create copies of records with only minimal differences.

Behind the scenes, the compiler is doing what is called “non-destructive mutation”, where the properties of the original record are copied to the new record, but the original record is not mutated in any way, keeping with the goal of immutability. 

Let’s check the output:

Person1 == Person2? True
Person1 == Person3? False
Person { FirstName = Jane, LastName = Doe }

The result is what we expect: the LastName has been set to what we specified, but the FirstName has been based off the person3 record. One other thing you might notice is the built-in formatting display. We didn’t have to create a custom .ToString() method to make it nice and readable, records do this for us! This is a very handy feature that will save us lots of time (and code).

Inheritance

Just like with normal classes, records support inheritance. Let’s create a derived Employee record:

public record Employee(string FirstName, string LastName, string Job) : Person(FirstName, LastName);

The syntax is very similar to regular class inheritance. The properties we inherit from the base record pass to the constructor, and the derived class has its own properties as well.

Next, let’s modify our class to now instantiate our derived class:

var person1 = new Employee("Joe", "Bloggs" , "Firefigher");
var person2 = new Employee("Joe", "Bloggs", "Teacher");
var person3 = new Employee("Jane", "Bloggs", "Programmer");

If we run our app, we’ll see the ToString() built-in formatted we mentioned earlier automatically picks up the new property, without us changing any code:

Employee { FirstName = Jane, LastName = Doe, Job = Programmer }

When to Use Records

We’ve discussed a lot of the features of records, and syntactic sugar aside, it should be clear there are a number of benefits, and it should be a first-class citizen in our programming toolbelt. We will not compare records vs classes vs structs, as that is outside of the scope of this article and an extensive topic, but in terms of performance let’s treat classes and records (reference types) the same, whilst structs (value types) are for simple types where no inheritance of complex domain modeling is required. So, let’s focus on the classes vs records use cases.

Immutability

The main advice for records over classes so far is when we want to have immutable objects that don’t need to change state, and are passed around different threads/classes. An obvious example here would be a DTO or an input model. These are one-way objects that get properties set, and are passed along. These are now perfect candidates for records. 

Concise Code

Furthermore, we’ve seen how simple and concise writing records are. Less code means better code and a smaller codebase, which means easier maintenance. The fact that we can declare an object with auto-initialized properties and built-in formatting with a single line is a very enticing proposition.

Changing Our Mindset

Finally, another way to look at things is flipping the question to: “when wouldn’t we use a record?”. Immutability is a good thing and will save a lot of bugs, so having a mindset of “records-first”, and going to classes when a mutation is required is a good idea. If we build a DDD-like application where state and behavior are modeled on the objects, then classes are a good choice. But for simple APIs where we bind to objects, pass to data layers, map to objects and return responses, records are perfect as no mutation is required.

Conclusion

In this article, we have provided a brief introduction to the new records type in C#, discussed the syntax and basic features, and reviewed the use cases for applying them to our applications. Records are not a ‘small’ new feature, they are a brand new top-level type new to the framework, and given their simplicity and number of advantages over other types, let’s expect to see a lot more of them in codebases moving forward.

 

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!