Records vs Classes in C#

A selection of black vinyl records, they are not arranged in any order and are spread out over the image, some overlapping others. Most of the records have central record label stickers, and are all in primary colours or have single colour patterns.

The cover image for this post is by Eric Krull

This blog post was written by Jamie.


Introduction

Version 9 of the C# language brought a large number of things with it

here’s a list of the things that C# 9 brought to the table

Among those changes were things like Top-level Statements and Code Generators, but my favourite is the record type.

But what is a record and why might you use one rather than a class?

Records and Classes

A lot of developers where taught OOP principles, and these have served us well. Encoding the properties which describe a thing and it’s thing-ness is a great way to design the solution to some problems. Look at some physical object around you, they all have properties which can likely be described by types in your programming language of choice (and by adding a few custom types, too). You’re probably reading this blog post on a phone, tablet, or computer of some kind, so let’s take a whack at describing it using OOP:

public enum Currency
{
    GBP = 1,
    USD = 2,
    EUR = 3
}
public enum DeviceType
{
    Desktop = 1,
    Laptop = 2,
    Tablet = 3,
    Phone = 4
}

// Represents devices which can be used to read stuff
public interface ICanRead
{
}

public class Device : ICanRead
{
    public string Name { get; set; }
    public decimal OriginalCost { get; set; }
    public Currency PurchaseCurrency { get; set; }
    public string SerialNumber { get; set; }
    public DeviceType DeviceType { get; set; }
    public Double ScreenSize { get; set; } 
    // etc.
}

This section of C# code is a nice way to start describing the properties (or things) that make up a device and contribute to it’s thing-ness. This is a fantastic use of a class, especially the inclusion of inheritance; however, this has lead to an anemic domain model - the Device class is a domain model, as we’re describing something within the domain of “devices that I can use to read stuff with”, but it contains no business logic.

This isn’t necessarily a bad thing, but there’s a way to describe you domain objects without falling into the anemic domain model.

Records to the rescue?

In a blog post by Mads Torgersen, he describes the record type as:

A record is still a class, but the record keyword imbues it with several additional value-like behaviors. Generally speaking, records are defined by their contents, not their identity. In this regard, records are much closer to structs, but records are still reference types.

While records can be mutable, they are primarily built for better supporting immutable data models.

Let’s say that you are constructing an object which you know that you’ll never mutate: a view model, for instance. You could use a record type. Here’s the Device code from earlier, but as a record:

public enum Currency
{
    GBP = 1,
    USD = 2,
    EUR = 3
}
public enum DeviceType
{
    Desktop = 1,
    Laptop = 2,
    Tablet = 3,
    Phone = 4
}
public record DeviceRecord(string Name, decimal OriginalCost,
    Currency PurchaseCurrency, string SerialNumber,
    DeviceType DeviceType, Double ScreenSize);

DeviceRecord someNewDevice = new("Jamie's Laptop", 1499.0m,
    Currency.GBP, "A1B2C3D4E5",
    DeviceType.Laptop, 15.0);

One caveat here is that you can’t use Object Initializers to set up record instances that you’ve declared in this way. For instance, you can’t do:

var someNewDevice = new DeviceRecord
{
    Name = "Jamie's Laptop",
    OriginalCost = 1499.0m,
    PurchaseCurrency = Currency.GBP,
    SerialNumber = "A1B2C3D4E5",
    DeviceType = DeviceType.Laptop,
    ScreenSize = 15.0
};

You’ll get an error message similar to the following if you do try:

There is no argument given that corresponds to the required formal parameter ‘Name’ of ‘DeviceRecord.DeviceRecord(string, decimal, Program.Currency, string, Program.DeviceType, double)

- Roslyn

This error message is telling us that even though we can do someNewDevice.Name to get the property, we cannot refer to it in an object initialiser.

But one thing that you can do is use with to copy over the values of properties like this:

// Records support inheritance, too
public record DeviceRecordRedux : ICanRead
{
    public string Name { get; init; }
    public decimal OriginalCost { get; init; }
}

var device = new DeviceRecordRedux { Name = "Jamie's Laptop", OriginalCost = 1499.0m };
var otherDevice = device with { Name = "Spare device" };

We have to change the way that we declare the record, because with uses Object Initialisation. But we also get copy construction for free if we do this:

The with-expression works by actually copying the full state of the old object into a new one, then mutating it according to the object initializer. This means that properties must have an init or set accessor to be changed in a with-expression

Equality: A Warning

Records perform value-based equality, similar to struct types. This means that it’s possible to be caught out by some equality gotchas:

var device = new DeviceRecordRedux { Name = "Jamie's Laptop", OriginalCost = 1499.0m };
var otherDevice = device with { Name = "Spare device" };

// The following will both return false, as we'd expect
Console.WriteLine(ReferenceEquals(device, otherDevice));
Console.WriteLine(Equals(device, otherDevice));

var original = otherDevice with {Name = "Jamie's Laptop" };

// This returns false
Console.WriteLine(ReferenceEquals(device, original));
// But this returns true. Why?
Console.WriteLine(Equals(device, original));

The call to Equals(device, original) returns true because both device and original have the same values for their properties (using value-based equality), whereas ReferenceEquals(device, original) returns false because they don’t refer to the same instance of DeviceRecordRedux.

Why Use Records?

Well, they support immutability (although are mutable themselves), have support for the with keyword (for copying all values of another record instance), and you also get formatting for displaying your record:

var deviceAsClass = new Device
{
    Name = "Jamie's Laptop",
    OriginalCost = 1499.0m
};

var deviceAsRecord = new DeviceRecordRedux {
    Name = "Jamie's Laptop",
    OriginalCost = 1499.0m
};

// Shows "Program+Device"
// i.e the type-name
Console.WriteLine(deviceAsClass);

// Shows "DeviceRecordRedux { Name = Jamie's Laptop, OriginalCost = 1499.0 }"
// i.e. useful data
Console.WriteLine(deviceAsRecord);

I also think that they are easier to reason about and deal with when you need a class which will either be anemic or will be immutable. ViewModels and DTOs are fantastic for this - any type of object which will only contain data, but not have methods hanging off of it or expect that data to change much.

The record type also leans very heavily into the functional programming paradigm, too. And with C# being multi-paradigm (it supports both OOP and functional programming), this is a great addition to your language toolkit, even if you never do functional programming.