Special Offer: My C#/.NET Bootcamp Course is out now. Get 10% OFF using the code FRIENDS10.

Record types are immutable reference types that provide value semantics for equality. Don’t worry – record types aren’t as complicated as they sound. Let’s break it all down and

  • see what record types are,
  • how to implement them in C# 9,
  • and why we should care about them.

If you haven’t already setup your computer to run C# 9, check out the Install and Use C# 9 in Visual Studio 2019 article first.

What Is a Record Type?

Record types are introduced in C# 9 to allow developers to write immutable reference types. It means that the properties of an instance of a reference type cannot change after its initialization.

Let’s write a record type definition and compare it to a class. 

public record Person(string FirstName, string LastName);

We use the public modifier, followed by the new record keyword, and provide a name and arguments. We end the record type definition with a semicolon.

Now let’s see how we initialize an object from a record type. We use a constructor like we would for creating objects from classes. We have two parameters to provide a value for the FirstName and the LastName properties.

If we try to change a property’s value after the object initialization, we get a compiler error. We cannot change the value of an existing instance of a record type.

Let’s see if we can use the object initializer syntax with a record type definition.

var personInitializer = new Person { FirstName = "Peter", LastName = "Clark" };

Now, we get a compiler error. It means that we have to call the constructor of a record type to initialize the values.

Creating a New Object Based on an Existing Object

If we want to change the value of an existing object’s property, we need to create a new instance. If we only want to change a few properties, we can use the following syntax.

var personCopy = person with { FirstName = "Ben" };

It allows us to create a new object of the Person record type with the same values as the existing instance except for the values that we provide after the ‘with’ statement.

Inheritance

It is possible to inherit from a record type. For example, you can have the Person record type we defined before and create an Employee record type that inherits from the Person record type.

The syntax is almost the same compared to class inheritance. We define a new Employee record type, and after its arguments, we add a colon followed by the base type and its arguments.

It’s a simple syntax, which allows us to keep the promise of having a way to declare a data type without implementing a full class. Be aware that building inheritance trees can make it harder to maintain the application, but if you need it, it is great to have the option to inherit from an existing record type.

Equality

During compilation, every record type gets an implementation of the Equals method. It allows us to compare instances of record types. The implementation does value-based equality checks. That means that if two instances of a record type have the same property values, they are treated as equal.

If we have business logic that needs to compare different objects, record types can help make use of value-based equality without having to write the code for it yourself.

We can see this in action when we create two instances of the previously discussed Person record type and compare them to each other.

var p1 = new Person("Jack", "Johnson");
var p2 = new Person("Jack", "Johnson");
var equals = p1 == p2;
Console.WriteLine(equals);

We create two instances of the Person record type with the same values for the firstName and the lastName properties. We check for equality and output the result to the Console.

As we can see, the equals variable is true because both instances share the same values. Now let’s change the last name of p2 and rerun the program. This time, the equals variable contains false because one of the properties does not match the other instance.

var p1 = new Person("Jack", "Johnson");
var p2 = new Person("Jack", "Blake");
var equals = p1 == p2;
Console.WriteLine(equals);

In my opinion, the overridden Equals and HashCode methods are one of the most important features of record types and can potentially save a lot of code and avoid mistakes.

ToString

Record types provide another valuable feature. They provide an implementation of the ToString method. It allows us to transform the property values of a record type into a string.

Let’s take a look at the following code:

var p1 = new Person("Jack", "Johnson");
Console.WriteLine(p1);

We create an instance of a Person record and provide it to the Console.WriteLine method, which calls its ToString implementation. The output contains the name of the record type and all properties, including their values. The format has a JSON-like syntax but starts with the name of the record type.

I think it’s a helpful feature that allows us to inspect the data of a record type during development. For example, when you hover over a record type instance in the debugger, you see all the property values without digging into the object itself. 

It also can be helpful for logging data. If you don’t need the data to be in a specific format, but need to have it persisted as a string, maybe the ToString implementation is good enough and saves you time.

How Record Types Work under the Hood

Now that we understand how to define and use a record type let’s see what happens behind the scenes.

Record types are essentially a compiler feature. The developer writes a record type definition, and during compilation, the compiler generates a class based on a template. Yes, at runtime, a record type is a class with a specific implementation.

Let’s take a look at the IL code generated by a record type definition.

Again, we use the previously defined Person record type.

public record Person(string FirstName, string LastName);

We see a Person class that implements the IEquatable<T> interface. The class contains two readonly string fields. We also see an EqualityContract, two public properties to access the fields, and a constructor with two string arguments. 

public class Person : IEquatable<Person>
{
    [CompilerGenerated]
    [DebuggerBrowsable(DebuggerBrowsableState.Never)]
    private readonly string <FirstName>k__BackingField;

    [CompilerGenerated]
    [DebuggerBrowsable(DebuggerBrowsableState.Never)]
    private readonly string <LastName>k__BackingField;

    [System.Runtime.CompilerServices.Nullable(1)]
    protected virtual Type EqualityContract
    {
        [System.Runtime.CompilerServices.NullableContext(1)]
        [CompilerGenerated]
        get
        {
            return typeof(Person);
        }
    }

    public string FirstName
    {
        [CompilerGenerated]
        get
        {
            return <FirstName>k__BackingField;
        }
        [CompilerGenerated]
        set
        {
            <FirstName>k__BackingField = value;
        }
    }

    public string LastName
    {
        [CompilerGenerated]
        get
        {
            return <LastName>k__BackingField;
        }
        [CompilerGenerated]
        set
        {
            <LastName>k__BackingField = value;
        }
    }

    public Person(string FirstName, string LastName)
    {
        <FirstName>k__BackingField = FirstName;
        <LastName>k__BackingField = LastName;
        base..ctor();
    }

    public override string ToString()
    {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.Append("Person");
        stringBuilder.Append(" { ");
        if (PrintMembers(stringBuilder))
        {
            stringBuilder.Append(" ");
        }
        stringBuilder.Append("}");
        return stringBuilder.ToString();
    }

    [System.Runtime.CompilerServices.NullableContext(1)]
    protected virtual bool PrintMembers(StringBuilder builder)
    {
        builder.Append("FirstName");
        builder.Append(" = ");
        builder.Append((object)FirstName);
        builder.Append(", ");
        builder.Append("LastName");
        builder.Append(" = ");
        builder.Append((object)LastName);
        return true;
    }

    [System.Runtime.CompilerServices.NullableContext(2)]
    public static bool operator !=(Person r1, Person r2)
    {
        return !(r1 == r2);
    }

    [System.Runtime.CompilerServices.NullableContext(2)]
    public static bool operator ==(Person r1, Person r2)
    {
        return (object)r1 == r2 || ((object)r1 != null && r1.Equals(r2));
    }

    public override int GetHashCode()
    {
        return (EqualityComparer<Type>.Default.GetHashCode(EqualityContract) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(<FirstName>k__BackingField)) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(<LastName>k__BackingField);
    }

    [System.Runtime.CompilerServices.NullableContext(2)]
    public override bool Equals(object obj)
    {
        return Equals(obj as Person);
    }

    [System.Runtime.CompilerServices.NullableContext(2)]
    public virtual bool Equals(Person other)
    {
        return (object)other != null && EqualityContract == other.EqualityContract && EqualityComparer<string>.Default.Equals(<FirstName>k__BackingField, other.<FirstName>k__BackingField) && EqualityComparer<string>.Default.Equals(<LastName>k__BackingField, other.<LastName>k__BackingField);
    }

    [System.Runtime.CompilerServices.NullableContext(1)]
    public virtual Person <Clone>$()
    {
        return new Person(this);
    }

    protected Person([System.Runtime.CompilerServices.Nullable(1)] Person original)
    {
        <FirstName>k__BackingField = original.<FirstName>k__BackingField;
        <LastName>k__BackingField = original.<LastName>k__BackingField;
    }

    public void Deconstruct(out string FirstName, out string LastName)
    {
        FirstName = this.FirstName;
        LastName = this.LastName;
    }
}

Those are the building blocks we consume when creating an instance of the record type and accessing its properties as a developer.

Below the constructor, we also have an implementation of the ToString method and an additional PrintMembers method. Next, we see a few more methods implementing the value-based equality of the record type.

Last but not least, we have the Deconstruct method. The Deconstruct method allows us to access the properties as individual values. Deconstructing is not exclusive to record types and will not be further discussed in this video.

Features of a Record Type in C#

When I first came across record types, I was curious about the difference between a class with read-only properties and a record type. Having discussed the generated code of a record type definition, we get insight into what additional features we get from a record type compared to a simple class definition.

A record type offers us:

  • read-only properties, which makes an instance of the type immutable without additional work such as defining fields or properties with specific keywords.
  • a constructor with all properties as arguments without having to write the constructor itself.
  • to perform value-based equality checks without overriding the GetHashCode and Equals methods.
  • a textual representation of the object’s values and its type without overriding the ToString method.
  • an implementation for the Deconstruct method, which allows us to use object deconstruction to access individual properties.

Besides getting all those features, we also write a lot less code to define a record type compared to doing the same with a pure class definition.

Alternative Syntax to Define Record Types

We already saw the default syntax to create a record type. It allows us to define a record type on a single line of code. We also learned that a record type transforms into a class during compilation. 

C# 9 allows us to define a record more like a class. Let’s take a look at the following record type definition where we explicitly specify the properties:

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

Instead of defining the record type on a single line, we open a block and explicitly declare the properties with a getter and a setter.

The setter is defined with the new init keyword, which allows us to define that we want a read-only backing field for this property.

So far, so good. Now let’s try to create an instance of the record type and set values to the properties.

var p1 = new Person("Jeff", "Green");

As we can see, this code does not compile. The error message tells us that no constructor is accepting two arguments on this type.

What we can do instead is using the object initializer syntax. Let’s try it:

var person2 = new Person { FirstName = "Peter", LastName = "Clark" };

As you can see, we successfully initialized an object of the record type and set the properties’ values.

What about if we want the record type to behave the same as if we declared the type using the default syntax? We can add a constructor to the definition and implement it to assign the values from the arguments to the properties:

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

      // Otherwise constructor without arguments
      public Person(string firstName, string lastName)
      {
           FirstName = firstName;
           LastName = lastName;
      }
}

Now the definition gets larger and larger. We can now use the constructor the same way we did with the default syntax. But what’s more interesting is that we cannot use the object initializer syntax anymore.

var person = new Person { FirstName = "Peter", LastName = "Clark" };

Our definition replaced the default constructor with our implementation that requires two arguments. We can fix this error by providing an additional parameterless constructor:

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

    // Otherwise constructor without arguments
    public PersonRecord(string firstName, string lastName)
    {
        FirstName = firstName;
        LastName = lastName;
    }

    public PersonRecord()
    {
    }
}

This change allows us to use both initializations. We can use the constructor as well as the object initializer syntax.

While I am personally disappointed that we cannot use the object initializer with the default syntax, I don’t think it’s worth using the alternative syntax and write so much more code to support it.

Alternative syntax without a setter

You might wonder what happens if we define the properties without the new init-only property feature. Let’s take a look at it:

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

The generated class almost looks the same as with the definitions we saw before. The only but very important difference is that the properties of the generated class do not have a setter. It means that we cannot set a value to the property. This definition does not make a lot of sense, in my opinion.

Default setter instead of init setter

Another question is what happens if we use a default setter instead of the init-only property definition. Take a look at the following definition:

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

This time, we get a setter for our properties. The problem is that the backing fields aren’t defined as read-only properties. The consequence is that we can change the values of the created objects after initialization, and therefore, the immutability is broken, and our object is mutable.

Let’s give it a try:

var person = new Person { FirstName = "Peter", LastName = "Clark" };
person.FirstName = "Wayne";

Alternative Syntax Summary

To summarize the alternative syntax part of this video, I suggest sticking with the default syntax to define record types. 

The advantage of the alternative syntax is that we can use object initializers. But to get access to object initializers, we need to define the properties ourselves and implement two constructors. Also, we need to make sure that we don’t break immutability.

And let’s be honest. Developers don’t like typing anyway. The default syntax provides us with a short and concise way to declare a record type.

Adding Custom Methods

One last thing. As we know by now, record types compile to classes. Does that mean we can add custom methods to our record types? Let’s give it a try:

public record Person(string FirstName, string LastName)
{
    public int LuckyNumber()
    {
        return 16;
    }
}

Yes, we can write methods and include them in the record type.

The syntax is simple. Instead of ending the type after the property list with a semicolon, we open a block and define our methods. Note that at the end of the block, we don’t need a semicolon.

Use Cases for Record Types

After learning everything about declaring and using record types in C# 9, it’s time to talk about the use cases for using record types.

ASP.NET WebAPI controllers

One use case that comes to my mind is returning data from an ASP.NET WebAPI controller. Often, we load data from the database and transform it to send it over the network. Usually, we create a model class that contains the data.

With record types, we can use the shorter syntax to create data types for our controllers.

Data Logging

Another use case is if we need to log data from our data classes. The default implementation of the toString and PrintMembers methods allow us to output the data without manually writing code for it.

Copying data

If we need to copy a data structure, record types provide us with a cloning mechanism that lets us do it on a single line.

Compare data

If we have business logic that needs to compare different objects’ properties, we can take advantage of the default value-based equality implementation that record types provide.

Parallelizing (Immutability)

Immutability can help us when parallelizing the computation of our data. If the data does not change, we do not need to synchronize access to it.

Conclusion

Every situation where we could take advantage of one of the features record types provide is a potential use case for using record types instead of writing a class.

I’m sure there are many more use cases I cannot think of right now. Let me know in the comments where you feel record types can make a difference.

 

Claudio Bernasconi

I'm an enthusiastic Software Engineer with a passion for teaching .NET development on YouTube, writing articles about my journey on my blog, and making people smile.