Record Types in C# 9.0

Record Types in C# 9.0

What are Record Types?

Record Types are a popular feature of F#, also commonly found in other functional languages, that have recently been introduced to C# in version 9.0 (released alongside .Net 5).

On the face of it they provide a shorthand for creating classes, often used for basic models / DTOs (Data Transfer Objects) / POCOs (Plain Old CLR Objects).

However they come with some extensive benefits:

  1. Lightweight syntax to definite a type
  2. Standardised and clean string output of all values within a record type
  3. Immutable, all properties are readonly by default
  4. Equality is value based, checks based on the values inside the type, not the reference.
  5. Can be deep cloned using the WITH keyword
  6. Can be deconstructed like Tuples

Below we'll explore these benefits in more detail

Using Record Types

Prerequisites

You'll need .Net 5.0, details on how to set it up here

Creating a Record Type

For our example we have two enums set up that the models will use:

public enum Colour { Black, Blue, Red, Green }
public enum PenState { Up, Down }

There are two ways to create a Record Type in C#, firstly we have the longhand approach:

public record Animal
{
    public string Name { get; }

    public Animal(string name) => Name = name;
}

This gives you more control over the Constructor and is closer to how you would expect to create a class, it also continues to carry all the benefits we'll discuss below, at the expense of using a more verbose syntax.

The second approach is to use the Positional Record syntax:

public record Position(int X, int Y);

This generates the constructor and readonly properties for you, in the above example we'll have a type that can be instantiated with 2 properties x and y through the constructor in the order given above.

Record types can use inheritance like standard classes too:

public record Turtle(
    string Name, 
    Position Position, 
    int Angle, 
    PenState PenState, 
    Colour Colour) : Animal(Name);

In this example we are building a type that passes the Name property down to the Animal record type.

Instantiating a Record Type

Record Types are instantiated like any standard class.  The thing to note is that the constructor is auto generated for you in the order you supply the properties when using Positional Record syntax to declare the type:

var position = new Position(10, 20);
            
var turtle = new Turtle("Mr Scruff", position, 90, PenState.Down, Colour.Green);

Benefit 1: Standardised & Clean String Output

Record Types when converted to a string produce a string containing all the nested values, this can make debugging and logging easier from the get go

Console.WriteLine(turtle); 

/* Outputs:
 *   "Turtle { 
 *      Name = Mr Scruff, 
 *      Position = Position { X = 10, Y = 20 }, 
 *      Angle = 90, 
 *      PenState = Down, 
 *      Colour = Green }"
 */

Benefit 2: Immutability

The properties contained within a Record Type are immutable by default, meaning they are set to readonly and can only be assigned through the constructor.

This encourages the use of immutability in your application, simplifying the behaviour and testability of code.  Scott Wlaschin has an excellent post on the benefits of immutability found here

 turtle.Name = "Jim";  // Will fail as the property is read-only

Benefit 3: Value Based Equality

When creating a reference type (such as a class), if you were to compare two instances of that class using obj1 == obj2 it would by default return false.  This is as reference types check the location in memory of the instance as standard, not the values that contained in that type.

Normally, if you want a class to be comparable using == then you must override the Equals and GetHashCode methods to provide your own custom value based comparison.

A benefit of record types is that they do that overriding for you, allowing two instances of a record type to be compared based on the values they contain, not their references.

var otherTurtle = new Turtle("Mr Scruff", position, 90, PenState.Down, Colour.Green);

// This outputs true as the values inside each turtle are identical
Console.WriteLine(turtle == otherTurtle);

Benefit 4: Deep Copying Using With

Deep copying without just copying the reference of an object is always a challenge in C#, usually leading to manual mapping.

When you create a record type, you can use the with keyword to copy all values into a new object, whilst still allowing you to change a subset of values.

In this example copiedTurtle will have the same values as turtle, however the Colour property will be modified.

var copiedTurtle = turtle with { Colour = Colour.Blue };

Benefit 5: Deconstruction

Finally, record types can have their properties extracted using deconstruction as if they were tuples.  

In the first example we are ignoring all other properties just to extract the angle from the copiedTurtle.

In the second we are using nested deconstruction to get the x and y properties out of the Position type stored in copiedTurtle

var(_, _, angle, _, _) = copiedTurtle;
var (name, (xPosition, yPosition), _, _, _) = copiedTurtle;
Console.WriteLine($"{name} is at position {xPosition},{yPosition}");

Summary

Record Types are an excellent addition to the C# language, and one I'm looking forward to seeing in the wild over time.  

I believe records will help reduce the weight and mental gymnastics needed to understand more complex models, whilst encouraging immutability by default in applications.

Sources