C# 9.0: The New Record Keyword

Introduction

In this post, I am going to discuss one of the new features included in C# 9.0: records. I’ll cover when and why you would want to use a C# record.

To see a full list of what’s new in C# 9.0 see:

What’s new in C# 9.0: https://docs.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-9

C# 9.0 is being released with .NET 5. Before I get into the theme of this post, I want to mention the upcoming release of .NET 5. This release is a major milestone. It represents the unification of .NET Core and .NET Framework. Going forward, there will be one .NET which can target Linux, macOS, iOS, Android, tvOS, watchOS, WebAssembly, and more.

.NET - A Unified PlatformSource: Microsoft

.NET 5 will be released at the .NET Conf which runs from Nov 10th to Nov 12th, 2020.

If you wish to sign up for the conference, details can be found here: https://www.dotnetconf.net/?utm_source=dotnet&utm_medium=banner&utm_campaign=savedate

If you want to get a better understanding of what’s new in .NET 5, I suggest you watch the following video from Microsoft Ignite 2020:

The Future of .NET is .NET 5: https://www.youtube.com/watch?v=kX0eX5tDGgc

What is the new record keyword and when would you use it?

Records provide a quick and straightforward way to write code for value-based types. Note: everything provided by a record can be implemented with C# classes. However, C# records make it far easier to do and significantly reduces the amount of boilerplate code. Later, we will see how much boilerplate code disappears when using a record versus a class.

So, what do I mean by value-based types? These are types where the data contained in the object are the primary concern, not the behaviour of the object; value-based types are simply a collection of data. Typical uses include:

  • Data Transfer Objects (DTOs). DTOs are types that contain no business logic. DTOs are used for transferring data over the wire or where isolation between service layers is required.

For more information on DTO’s, see: https://deviq.com/anemic-model/

  • Value Objects. Value objects are used in the Domain Driven Design (DDD) pattern of the same name. These are immutable objects that are only identifiable by the values of their properties. Value Objects are for types that only exist within the context of another object, that is, they have no identity on their own. An example of this would be an Address object which is associated with a Customer Order. The Address has no identity on its own; it exists only in the context of the Order. If the Order is deleted, the Address also is deleted as it makes no sense to exist on its own.

Further, 2 value objects are considered equal only if properties are equal. In contrast, 2 entities may be considered equal if their identifying values are equal. For instance, two Customer Orders may be considered equal if their Order Numbers are equal regardless of the state of their other properties.

For more information on Value Objects, see: https://deviq.com/value-object/

  • View Models. A View Model is a type that includes just the data a View requires for display or sending back to the server.

For more information on View Models, see: https://deviq.com/kinds-of-models/

The characteristics common to the value-based types are:

  • All property values determine equality between instances.
  • They do not contain business logic. These objects act as containers for data.
  • They should be immutable; changing initialized properties should not be allowed.

Immutability provides several benefits:

  • When you pass objects around, you should not have to worry about another method or class modifying the object. Unexpected property modifications can result in unexpected bugs.
  • Immutable objects are inherently thread safe. With the rise of async programming, this becomes more important.
  • The use of immutability is part of a defensive programming strategy.

While DTOs, Value Objects, and View Models are closely related and share the same characteristics, the intent of their usage is what distinguishes them.

Example of using the new record keyword

To see the basic functionality provided by a C# record, we will first create a class-based Value Object. We will then replace the class-based Value Object with one based on a C# record.

In the following example, we will create a basic console app and an Address Value Object. First, let’s code the Address as a class. After that, we can refactor it to make the Address a C# record. From this, we will see how much boilerplate code is removed by making the Address a C# record.

If you wish to try this yourself, you will need:

From Visual Studio, create a project named “RecordTypesCSharp9” as a “Console App (.NET Core)”.

In Visual Studio, change the Project properties to target “.NET 5.0” as shown below:

Now let’s create an Address class. The Address type will have the following characteristics:

  • It can be considered a DDD Value Object.
  • Immutable.
  • Supports equality comparison based on the properties of the object.
  • Overrides ToString() to list out the Address properties.
  • Implements Deconstruct() in order to support pattern matching.

Add the following code to the Address class. Note that most of this code is boilerplate and there is no business logic.

using System;
namespace RecordTypesCSharp9
{
  public class Address : IEquatable<Address>
  {
    public string StreetAddress { get; }
    public string City { get; }
    public string State { get; }

    public Address(string streetAddress, string city, string state)
    {
      StreetAddress = streetAddress;
      City = city;
      State = state;
    }

    public override string ToString()
    {
      return $"StreetAddress:{StreetAddress}, City:{City}, State:{State}";
    }

    public static bool operator == (Address left, Address right) =>
      left is object ? left.Equals(right) : right is null;

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

    public override bool Equals(object obj) => 
      obj is Address a && Equals(a);

    public bool Equals(Address other) =>
      other is object &&
      StreetAddress == other.StreetAddress &&
      City == other.City &&
      State == other.State;

    public override int GetHashCode()
    {
      return HashCode.Combine(StreetAddress, City, State);
    }

    public void Deconstruct(out string streetAddress, out string city, out string state)
    {
      streetAddress = StreetAddress;
      city = City;
      state = State;
    }

  }
}

Now copy the following code into our Program.cs. It exercises the functionality we incorporated in our Address type:

using System;
using System.Text.Json;

namespace RecordTypesCSharp9
{
  class Program
  {
    static void Main(string[] _)
    {
      Console.WriteLine($"Try out the Address (class based).");

      Address address1 = new Address
      ("200 Riverfront Ave SW", "Calgary", "AB");
      Console.WriteLine($"1) address1: {address1}");

      Address address2 = new Address
      ("200 Riverfront Ave SW", "Calgary", "AB");
      Console.WriteLine($"2) address2: {address2}");

      Console.WriteLine($"3) address1 == address2: {address1 == address2}");

      string jsonAddress1 = JsonSerializer.Serialize(address1);
      Address address1a = JsonSerializer.Deserialize<Address>(jsonAddress1);
      Console.WriteLine($"4) address1a == address1: {address1a == address1}");

      var isInCalgary = IsInCalgary(address1);
      Console.WriteLine($"5) isInCalgary: {isInCalgary}");
    }

    static bool IsInCalgary(Address address) => address switch
      {
        (_, "Calgary", _) => true,
        _ => false
      };
  }
}

When we run the Console App, the following output is produced.

From this we can see the following functioned as expected:

  • The ToString() override: 1) and 2) in the above screenshot.
  • Equality for constructed objects: 3) in the above screenshot.
  • Equality for serialized/deserialized objects: 4) in the above screenshot.
  • Deconstruction and pattern matching: 5) in the above screenshot.

None of this is either surprising or even that interesting. The thing to note is the amount of boilerplate code in the Address class required to provide this basic functionality. Consider that if you added a new property to the Address class, say a second address line, the boilerplate code must be updated, possibly introducing errors. Also, if you have many of these value-based types in your application, the amount of boilerplate code can be significant.

Now let’s replace the Account class with an Account type based on a C# record.

Go to the Address.cs file and replace the existing code with the code shown below.

namespace RecordTypesCSharp9
{
  public record Address(string StreetAddress, string City, string State);
}

That’s it; this all that is required to provide the same functionality as the previous implementation of the class-based Address.

Before we run the Console app again, let’s also change the following line in Program.cs from:

Console.WriteLine($"Try out the Address (class based).");

To:

Console.WriteLine($"Try out the Address (record based).");

Running the console app produces the following output:

We can see that the new Address implementation based on the C# record works the same as before.

The “With” Expression

By default, C# records are immutable. Note that records can be made mutable by using the same class-based semantics for defining properties and constructors as classes but in general you should be using the default immutability provided by C# records.

To help make immutability easier to work with, the C# record semantics provide a way to copy an existing record and update one or more values in a single statement.

For example, to create a new address from an existing instance and update the StreetAddress to a new value, we can do this using the “with” expression as follows:

var newAddress = address1 with {StreetAddress = “804-3 Ave SW”};

The expression above would give us a new Address instance with the same City and State as contained in address1 but with a new value for StreetAddress.

Summary

Underneath the covers, a C# record is converted to a C# class that implements the boilerplate code that we had in our class-based Address type.

As C# records are converted to classes, records can use the same semantics as classes including:

  • Inheritance
  • Methods
  • Property getter and setters

By using a C# record to replace value-based classes, we can remove a significant amount of boilerplate code. This helps to reduce the size of our codebase and more importantly, reduces the probability of errors and makes our code easier to change.

For more information on C# records, see: https://docs.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-9#record-types