Unveiling the Power of C# Records: Beyond Immutability
When asked about their understanding of Records, many developers respond simply, "Records are immutable".
While this statement is partially accurate, it oversimplifies the rich set of features that come with C# Records, introduced in C# 9.0 and released as part of the .NET 5.0 update in November 2020. It's been nearly four years since then, and the power and versatility of Records continue to evolve.
In this article, we'll explore the various aspects of Records, including their positional nature, immutability, value equality, nondestructive mutations, compiler-generated ToString()
methods, deconstructing, pattern matching, and practical use cases.
Positional Records
One of the key characteristics of Records is their positional nature. When defining a Record with a primary constructor, the compiler automatically generates public properties with init
modifiers for you. The parameters in the primary constructor are known as positional parameters,
and the compiler creates positional properties that mirror these parameters, hence the name "Positional Records".
public record Person(string Name, string Surname, int Age);
In the example, we use a primary constructor for the Record. The compiler creates positional properties that mirror the positional parameters.
Immutability
While it's commonly said that Records are immutable, it's essential to specify that this applies primarily to Positional Records because we can define mutable records.
public record Person
{
public string Name { get; set; }
public string Surname { get; set; }
public int Age { get; set; }
}
Records can indeed be defined as mutable, and the properties declared with the init
access modifier (either generated by the compiler or manually added by the developer) exhibit shallow immutability.
This means that you cannot change the value or the reference of reference type properties, but you can modify the data that a reference type property refers to. Let's see the example with the following Record.
public record Post(string Title, string Content, string[] Tags);
Our Post has an array of Tags. After initialization, we can't change the Tags with a new array.
var post = new Post("My Post", "My Content", ["tag1", "tag2"]);
post.Tags = ["tag3"]; // This won't work.
However, we can change the values of existing Tags.
var post = new Post("My Post", "My Content", ["tag1", "tag2"]);
post.Tags[0] = "tag3";
Console.WriteLine(post.Tags[0]); // Output: tag3
It's important to remember that Records does not have deep immutability but shallow immutability.
Value Equality
Initially, Records were reference types, meaning that they were compared by reference rather than by value. However, with the introduction of C# 10, Records can now also be created as value types (record struct
), preserving this behavior for backward compatibility.
// Reference type
public record Person(string Name, string Surname, int Age);
// Reference type
public record class Person(string Name, string Surname, int Age);
// Value type
public record struct Person(string Name, string Surname, int Age);
The first option creates a reference type by default. The .NET team left it for backward compatibility.
Despite being reference types by default, Records override the Equals(object)
method, enabling value-based equality checks. This distinction allows Records to behave more like structs, where two instances with identical values are considered equal.
As we know, the reference types are compared by references in .NET. Records are different. They are compared by values.
var anakin1 = new Person("Anakin", "Skywalker", 27);
var anakin2 = new Person("Anakin", "Skywalker", 27);
Console.WriteLine(anakin1 == anakin2); // Output: True
Nondestructive Mutations
Records support nondestructive mutations through the with
expression. This feature allows you to create a new instance of a Record, modifying certain properties while copying others from the original instance. For example, you could change a Person
's FirstName
and Age
properties while keeping the rest intact.
var anakin = new Person("Anakin", "Skywalker", 27);
var anakin = anakin with { Name = "Darth", Surname = "Vader"};
We created a new instance anakin from anakin changing only Name and Surname properties. The Age property is copied.
Compiler-Generated ToString()
The compiler provides a built-in ToString()
method for Records, which outputs the properties of the Record in a readable format. This feature is particularly useful because the default ToString()
implementation for classes and structures in C# only returns the type name, providing no insight into the actual data contained within the instance.
With Records, the output format is:
<record type name> { <property name> = <value> }
or
Person { Name = Anakin, Surname = Skywalker, Age = 27 }
Deconstructing
Deconstruction is a powerful feature that allows you to break down a Record into individual variables. However, this is only possible with Positional Records and does not apply to regular Records.
var (name, surname, age) = anakin;
Console.WriteLine(name); // Anakin
Console.WriteLine(surname); // Skywalker
Console.WriteLine(age); // 27
Pattern Matching
Positional Records integrate seamlessly with C#'s pattern-matching syntax, making it easy to destructure and match on properties within switch expressions. This capability enhances the expressiveness of your code and simplifies complex conditional logic.
Use Cases
Records are ideal for various scenarios, such as:
- Data Transfer Objects (DTOs)
- Events
- Commands
- Other immutable models
They are particularly useful when working with data received from external APIs or deserialized from JSON into strongly typed structures. Records ensure that instances with equivalent values are treated as equal, which is crucial for operations like logging, tracing, and comparison.
However, it's worth noting that Records are not suitable for representing entity types in Entity Framework due to their immutable nature.
Summary
Understanding the nuanced features of Records is key to harnessing their full potential. While they are immutable by default, it's important to recognize that this applies to Positional Records, and the immutability is shallow rather than deep. Records offer a powerful tool set for managing immutable data and ensuring value equality, making them a cornerstone feature in modern C# development.
Stay in the Loop
Subscribe to our newsletter and be the first to receive exclusive content and updates on my latest articles.