Intro

I'm thrilled that C# finally embraces more concepts coming from functional programming and provides all the required tooling to implement them without twisting the language for something it's not meant to do.

Immutability

C# 9.0's records will help us create immutable types that are very useful in large distributed architecture when using concepts like messaging and microservices. Immutability is an interesting property as it tends to simplify the way we think about a method. When something is immutable, we don't have to worry that another part of the application will mutate it. It eliminates the need to use locks and simplify all the highly concurrent parts of your system. It's not all rainbows and unicorns; learning to work with immutability can be hard at first, but when you see the benefits, it's usually worth it.

Records

So how can I create an immutable type in C# 9.0? As a record behave differently than a class or a struct, Microsoft introduced a new keyword in the language: record.

public record Chicken { public string Name { get; init; } }

[Fact]
public void InitOnlyByDefault()
{
    var c = new Chicken { Name = "test" };
    Assert.Equal("test", c.Name);

    // setting a value is not allowed
    // c.Name = "t";
}

If you are wondering what's that init keyword above, checkout my complete article on it.

Using primary constructors

Records start to shine when you combine them with primary constructors.

public record Chicken(string name);

Even if you can implement a record any ways you want, I would argue that they should always be implemented using primary constructors. It will make sure that you preserve proper immutability, and it will make them behave like F# records, which could be an advantage if you have some cross-language code.

The 'with' keyword

Records also come with another new keyword with to help work with immutability. As records are immutable after their construction, it could be annoying when you want to create another instance that's exactly the same as another object except for one property. with will allow you to 'clone' the other record and only change some properties. The important point here is that you'll get a new instance, thus preserving the first record's immutability.

[Fact]
public void RecordsWith()
{
    var c = new Chicken { Name = "test" };
    var c2 = c with { Name = "myNewName" };

    Assert.Equal("myNewName", c2.Name);
    Assert.NotEqual(c, c2);
}

Structural equality

Records also have another interesting behavior that differs from classes. They respect what we call structural equality, which means that two records are considered equal if all their properties are equal. This kind of behavior is a pain in the ass to implement with classic classes as you need to override the Equals and GetHashCode methods and maintain the code of these methods when you change a property. Records will give you all that behavior for free.

[Fact]
public void StructuralEquality()
{
    var c = new Chicken { Name = "test" };
    var c2 = new Chicken { Name = "test" };
            
    Assert.Equal(c, c2);
}

Records will be considered equal even if they point to a different reference in memory. You can prove it by creating an object ID in the watch window while debugging.

Pro tip: How to create an object ID in VisualStudio

In the local watch window, right-click on the object you want to get an ID representing the memory address.

local-watch-window

make-object-id

different-object-id

As you can see, the two instances point to different memory addresses, $3 and $4 respectively.

Deconstructor

Records also correctly support deconstrucor, a new feature introduced in C# 7. It will allow the implicit conversion of the record to a tuple containing all the properties.

public record Deconstructable(int quantity, string name, DateTime time);

[Fact]
public void Deconstructor()
{
    var now = DateTime.UtcNow;
    var deconstructMe = new Deconstructable(42, "My super name", now);
    var(x, y, z) = deconstructMe;

    Assert.Equal(42, x);
    Assert.Equal("My super name", y);
    Assert.Equal(now, z);
}

You can also achieve the same result with the Deconstruct method available on all record types.

records-deconstructor

Inheritance

As C# is mainly an imperative OOP language, inheritance is something that most people rely on, and that should be supported. It's most likely the most significant challenge the C# team had to overcome to make records part of the language while preserving backward compatibility.

public abstract record Food (int Calories);
public record Milk(int C, double FatPercentage) : Food(C);

[Fact]
public void PropertyNameInheritence()
{
    var m = new Milk(1, 3.25);

    Assert.Equal(1, m.C);
    Assert.Equal(1, m.Calories);
}

Interestingly, you'll see that Milk has both C and Calories.

record-inherited-properties

Now, look at what happens if we rename C to Calories in the Milk type.

public abstract record Food (int Calories);
public record Milk(int Calories, double FatPercentage) : Food(Calories);

You only have one property on the object, and if you look carefully, you see that it comes from the base class Food. I don't know what I was expecting with this, but it all makes sense to me the way it is.

records-base-properties

I've done a few other tests, and so far, everything seems to behave correctly right out of the box. However, I found some limitations; it's impossible to mix and match classes and records in an inheritance chain.

If you try to make a class inherit from a record, you'll get

only-records-can-inherit-from-records

If you try to make a record inherit from a class, you'll get

records-can-only-inherit-from-records

It's legal to implement an interface with a record.

public interface IRecord { }
public record RecordType : IRecord { }

Deconstructor and inheritance

Another thing I found is that implicit deconstruction doesn't seem to work with base record types. The only way to achieve this is to use the Deconstruct method.

[Fact]
public void DeconstructAsBaseType()
{
    Food f = new Milk(1, 3.25);
    // This is not valid
    //var (x) = f;

    // This works though
    f.Deconstruct(out var x);

    Assert.Equal(1, x);
}

Generic constraints

At the time of this writing, it's not possible to use the record keyword to constrain a generic type.

// This is not valid and won't compile
public void ThisIsADummyMethod<T>(T t) where T : record

Hopefully, it will be supported in the final release.

Closing word

I hope you enjoy this overview of the record feature. I tried to test as many edge cases I could think of. If you think I missed something important or would like me to check a specific edge case, let me know in the comments down below 👇 , and I'll be glad to go further my investigation.

Before you go

All code samples are also available on my GitHub repo, feel free to clone it and play with it. Don't forget to enable the preview mode in VisualStudio.