Introduction
With the recent release of .NET 5 and C# 9, some great new features have been added to the C# language. Arguably, the most notable addition is record types. In this article, I'll be explaining this feature, and why we might want to use it.
POCOs, aka Plain-Old C# Objects
In most applications we write, we're often dealing with objects that don't encapsulate any kind of state or behavior. These objects are typically just a collection of properties we can use for serializing/deserializing JSON data, or for dealing with database operations. You may have heard these types of objects referred to as POCOs, or POJOs, for "Plain-old C# objects" and "Plain-old Java objects" respectively. This is a very common pattern used in production software, but there is quite a bit of boilerplate that comes with these types of objects.
We've all seen this type of code. Here's an example:
- public class Employee
- {
- public string FirstName { get; set; }
- public string LastName { get; set; }
- public string EmployeeId { get; set; }
- }
Often, we want to treat these "dumb objects" as value types. One thing typically done with value types is checking for equality. When we compare two C# objects using ==, we are checking for referential equality. In other words, we're comparing the memory address of these objects. We may want to compare these objects by value instead since we're treating these objects as a collection of values, not as memory on the heap.
We can add the capability of structural equality checking to our class by just adding some more boilerplate:
- public class Employee : IEquatable<Employee>
- {
- public string FirstName { get; set; }
- public string LastName { get; set; }
- public string EmployeeId { get; set; }
-
- public bool Equals(Employee other)
- {
- if (Object.ReferenceEquals(other, null))
- {
- return false;
- }
- if (Object.ReferenceEquals(this, other))
- {
- return true;
- }
- if (this.GetType() != other.GetType())
- {
- return false;
- }
- return (FirstName == other.FirstName) && (LastName == other.LastName) && (EmployeeId == other.EmployeeId);
- }
- public override bool Equals(object obj)
- {
- return Equals(obj as Employee);
- }
- public static bool operator ==(Employee lhs, Employee rhs)
- {
- if (Object.ReferenceEquals(lhs, null))
- {
- if (Object.ReferenceEquals(rhs, null))
- {
- return true;
- }
-
- return false;
- }
- return lhs.Equals(rhs);
- }
- public static bool operator !=(Employee lhs, Employee rhs)
- {
- return !(lhs == rhs);
- }
- public override int GetHashCode()
- {
- return HashCode.Combine(FirstName, LastName, EmployeeId);
- }
- }
Look at how quickly that simple class blew up, just because we want to compare by value instead of by reference.
Even worse yet, if we want to add another property to the Employee class, we have to make sure we update the .Equals() implementation to compensate. So not only have we just drastically increased the noise in our code, but we also created an environment for bugs to live in later on.
Yet more boilerplate
When dealing with these value objects, it's very handy to be able to destructure the object's properties into local variables. In C# this feature is called Deconstruction (in other languages it's known as destructuring)
Example of Deconstruction:
- var (firstname, lastname, employeeId) = myEmployee;
In this contrived example, we have an Employee object called myEmployee, and we're destructuring the properties into local variables.
Let's add this functionality to our Employee class:
- public void Deconstruct(out string firstName, out string lastName, out string employeeId)
- {
- firstName = FirstName;
- lastName = LastName;
- employeeId = EmployeeId;
- }
When this boilerplate is added to our class, we can now do the previously-described destructuring.
The solution to all this boilerplate: Record Types
If you've ever used Kotlin before, you might be aware of a fantastic feature known as data class. The purpose of a feature like data class is to solve the boilerplate problem we're encountering here. What we want is a simple collection of properties that we can pass around our application layers, but we also want the built-in functionality we'd expect from using a value type.
Record types to the rescue!
Note
To use the new C# 9 features, you first need to make sure your C# project is targeting .net 5
You can do this by right-clicking on your .csproj in Visual Studio, and clicking "Properties."
Target Framework should be set to ".NET 5.0"
If that option is not available, be sure to update Visual Studio and try again.
Okay. First, let's review what the Employee class looks like in its entirety:
- public class Employee : IEquatable<Employee>
- {
- public string FirstName { get; set; }
- public string LastName { get; set; }
- public string EmployeeId { get; set; }
-
- public bool Equals(Employee other)
- {
- if (Object.ReferenceEquals(other, null))
- {
- return false;
- }
- if (Object.ReferenceEquals(this, other))
- {
- return true;
- }
- if (this.GetType() != other.GetType())
- {
- return false;
- }
- return (FirstName == other.FirstName) && (LastName == other.LastName) && (EmployeeId == other.EmployeeId);
- }
- public override bool Equals(object obj)
- {
- return Equals(obj as Employee);
- }
- public static bool operator ==(Employee lhs, Employee rhs)
- {
- if (Object.ReferenceEquals(lhs, null))
- {
- if (Object.ReferenceEquals(rhs, null))
- {
- return true;
- }
-
- return false;
- }
- return lhs.Equals(rhs);
- }
- public static bool operator !=(Employee lhs, Employee rhs)
- {
- return !(lhs == rhs);
- }
- public override int GetHashCode()
- {
- return HashCode.Combine(FirstName, LastName, EmployeeId);
- }
-
- public void Deconstruct(out string firstName, out string lastName, out string employeeId)
- {
- firstName = FirstName;
- lastName = LastName;
- employeeId = EmployeeId;
- }
- }
And here's the same class, using the new C# 9 Record Types feature:
- record Employee(string FirstName, string LastName, string EmployeeId);
Seriously.
Immutability and Record Types
Now, it has to be mentioned that there is a key difference between the two implementations.
In the record version of the Employee type, those public properties are completely immutable.
If you try to modify a property, you'll get this error:
This immutability is deep, not shallow. If we have a reference type as a property in the Employee record, it will still throw this error when trying to modify the reference type.
Note
What is that "init-only property?" This is another staple feature of C# 9. I'll go into that more in the next article.
Instead of modifying the properties of this record directly, we can instead create a copy of the object, but with our new desired values. This is done using the new with syntax that goes along with record types.
- var employee1 = new Employee("Sean", "Franklin", "555");
- var employee2 = employee1 with { FirstName = "Bob" };
By using the keyword alongside an initializer list, we get a new Employee object allocated with the new value.
If you really require mutability in your POCO, then this can be enabled by defining explicitly-public properties in the record.
- record Employee(string FirstName, string LastName, string EmployeeId)
- {
- public string FirstName { get; set; } = FirstName;
- public string LastName { get; set; } = LastName;
- public string EmployeeId { get; set; } = EmployeeId;
- }
We can do this because a record in C# is still just a class; a class with all the previously-mentioned features built into it.
Summary
In conclusion, this feature can drastically reduce boilerplate and code noise in your project. The use of value objects is such a common use-case, that this feature becomes an immediate favorite for any large project. I hope you can start incorporating records into your codebase, it's a fantastic feature.
Stay safe and happy coding!