C# is quickly evolving, and it may be overwhelming to keep up with every new feature. C# 9 has been out for over a month now, and I thought it would be a good idea to consolidate my thoughts on what I consider to be the most exciting feature: Record types. I don’t consider myself an expert by any means, and I doubt anyone outside of Microsoft has had enough experience to genuinely know the ins and outs of the record
type. That said, in this post, we’ll explore “gotchas” that may confuse folks as they make the transition from class
to record
.
Here are the elements of using a record
type that may be the source of bugs and hours of frustrating debugging in no particular order. Keep these in mind when considering using records in your codebase.
What Is A Record?
Don’t know what the record
type is? Don’t worry. It’s only been a month since the release of .NET 5, and you’re likely not alone. The record
type is a new C# language type that allows developers to create immutable objects with additional value-based equality methods.
C# 9.0 introduces record types, a reference type that provides synthesized methods to provide value semantics for equality. Records are immutable by default. –Microsoft
Immutability and lack of side-effects can be advantageous for folks working in multi-threaded applications or adopting a more functional approach to C# development. Passing data by value ensures that there are fewer opportunities for resource contention and deadlocks. Time will tell if record
types deliver on that promise.
The most crucial keyword when dealing with record types is unsurprisingly the record
keyword. We can convert most class
types to a record
by switching the class
keyword to record
.
public class Pet {
public string Name {get;set;}
}
// change to
public record Pet {
public string Name {get;set;}
}
To get the most of the record
types abilities, we may want to consider changing all properties to use the init
keyword. By applying the keyword, we enforce compiler directives only to set the value once during object initialization.
public record Pet {
public string Name {get;init;}
}
We can then use the with
keyword to create a duplicate copy of our instance.
var samson = new Pet { Name = "Samson" };
var guinness = samson with { Name = "Guinness" };
Great! Now that we’ve had a quick crash course on record types let’s get to some issues folks may run into when using them.
Positional Parameter Syntax
One of the most significant advantages to the record
type is a shorthand syntax for declarations.
public record Person(string First, string Last);
The record type is a definition, and the compiler synthesizes many of those features at compile time. The syntax will produce two string properties for First
and Last
on our Person
record type. What folks may not realize is that the First
and Last
declared in our code are constructor parameters, also known as positional parameters. Why is it important to make that distinction? Well, let’s look at some code that developers may expect to work but won’t.
public record Person(
[Description("First Name")] string First,
[Description("Last Name")] string Last
);
We place a Description
attribute on each parameter, and some folks might expect that the compiler will transfer our Description
attributes to the properties, but they are not. The distinction is critical for developers using metaprogramming to decorate additional data onto their types. Developers utilizing reflection will need to account for shorthand syntax and new locations that developers may place attributes. For folks using frameworks like ASP.NET, these distinctions are already handled and should work with DataAnnotation attributes.
There is a workaround to this issue. We can place attributes on properties using the property:
prefix, which tells the compiler to place these attributes on our generated properties.
public record Person(
[property:Description("First Name")] string First,
[property:Description("Last Name")] string Last
);
This technique “works”, but is dependent on both developers knowing it exists as an option, and library authors looking at attributes parameters and properties on a record
. To say the least, this will likely cause several issues for years to come in the .NET community.
Inheritance
Record types can inherit from each other, but they may not inherit from a class
. Record hierarchies and class hierarchies must remain separate and cannot share a lineage. The limitation will lead many folks to choose an all-or-nothing approach when adopting record
into their applications. While not immediately problematic, we will see where this approach could reveal more potential issues down the line.
Deconstructing Positional Parameters Of Two or More
Deconstruction is one of those synthesized features we get for free with record types. The ability to breakdown a record into its simplest parts can help reduce noise in our code and allow us to pass those deconstructed explicit values rather than entire records. One significant limitation for record deconstruction is that it only works when the record
type definition has two or more positional parameters. This is a limitation in the C# language, not an omission of the synthesized deconstruct method.
In the following example, we get a synthesized deconstructor, but we cannot call it using syntactic enhancements because we only have one positional parameter.
// one positional parameter
public record Person(string Name);
var person = new Person("Khalid");
// not going to work
var (name) = person;
// this works
// but ewwwww....
pet.Deconstruct(out var whatevs);
By adding a new positional parameter of Last
, we can now invoke a deconstructor that matches our type’s parameter order. The ,
is an essential syntax when deconstructing types into their parts.
public record Person(string Name, string Last);
var person = new Person("Khalid", "Abuhakmeh");
// works because of the `,` between the parenthesis
var (first, last) = person;
I’ll admit, this one is an extreme edge case since most record
definitions are likely to use more than one positional parameter. We also need to note that property definitions are not part of the deconstructors synthesized for our types.
public record Person(string Name, string Last)
{
public string Number { get; init; }
}
Looking at the IL of our Person
record shows that only the First
and Last
properties are part of the deconstructor.
.method public hidebysig instance void
Deconstruct(
[out] string& First,
[out] string& Last
) cil managed
{
.maxstack 8
IL_0000: ldarg.1 // First
IL_0001: ldarg.0 // this
IL_0002: call instance string Person::get_First()
IL_0007: stind.ref
IL_0008: ldarg.2 // Last
IL_0009: ldarg.0 // this
IL_000a: call instance string Person::get_Last()
IL_000f: stind.ref
IL_0010: ret
} // end of method Person::Deconstruct
Now is a great time to talk about deconstructor behavior and inheritance together.
Deconstruction Depends On The Handle Type Deconstructor
The deconstructor called will depend on the type handle to the instance we refer to in our current context, not the instance’s original record type. Let’s take a look at these two record types.
public record Person(string First, string Last);
public record Other(string Last, string First)
: Person(First, Last);
The Other
type inherits from the Person
type, with the positional parameters reversed. Let’s look at some code that shows where folks could get some unexpected output.
var other = new Other("Abuhakmeh", "Khalid");
string first = null;
string last = null;
(first, last) = (Person)other;
Console.WriteLine($"{first} {last}");
// Not Person, but Other
(first, last) = other;
Console.WriteLine($"{first} {last}");
The deconstructor for Person
will return First
followed by Last
, whereas the deconstructor for Other
will perform the inverse, returning Last
, then First
.
Khalid Abuhakmeh
Abuhakmeh Khalid
Deconstructor behavior may or may not be what we expect. Developers coming from an Object-oriented programming background may expect polymorphism to be the critical factor here. In contrast, folks invoking interface behavior may expect this to be the result they were expecting.
Different Types Can’t Be Equal
Folks who use data transfer objects or “plain old c# objects” may be familiar with adding properties of Id
. While the record
type comes with many value-based operations, there are extreme caveats. The biggest issue might be that equality is value-based and includes a check that the types match. Two records of different types are not equal, even when they share identical property values. The distinction includes types that inherit from the same base class. In the example above, with Other
and Person
, they can never be equal using the synthesized operators.
Person person = new Person("Khalid", "Abuhakmeh");
Other other = new Other("Abuhakmeh", "Khalid");
// not equal to each other
// even though values match
Console.WriteLine(person == other);
public record Person(string First, string Last);
public record Other(string Last, string First)
: Person(First, Last);
As we would expect, the result of the following code is False
.
Reflection Bypasses Init Setters
We talked about the advantage of immutability with the record
type. Well, it’s mostly an advantage during development time, but we can alter record instances the same way we can any object
instance during runtime.
using System;
using System.Linq;
Person person = new Person("Khalid", "Abuhakmeh") { Number = 1 };
var propertyInfo = typeof(Person).GetProperties()
.Where(p => p.Name == nameof(person.Number))
.First();
propertyInfo.SetValue(person, 3);
Console.WriteLine(person.Number);
public record Person(string First, string Last)
{
public int Number { get; init; }
};
Here, we can modify the value of what should be an immutable Number
property. The mutability of values is an important consideration when working in codebases that rely heavily on reflection.
Generic Constraints Mismatch
Since records are relatively new, they share some of the same DNA as the class
type. The C# language has not adapted generic constraints to support only passing a record
type, but the record
type does satisfy the class
constraint.
using System;
using System.Linq;
Person person = new Person("Khalid", "Abuhakmeh") { Number = 1 };
Hello.Greet(person);
public record Person(string First, string Last)
{
public int Number { get; init; }
};
public static class Hello
{
public static void Greet<T>(T value) where T : class
{
Console.WriteLine(value);
}
}
I could see the need to constrain parameters based on their record
interface, thus ensuring synthesized methods are available and any comparisons will be are based on value rather than reference. Generics are crucial for open-source projects, and they may want to adopt the record
type cautiously. Additionally, it may lead to strange behaviors as users begin to pass in record instances rather than class instances.
Conclusion
Record types will open up many new opportunities for us as developers and generally will make our code bases smaller and less prone to errors during development. The drastic change in syntax will likely have folks assuming behavior and introducing bugs early on into their codebase as they transition from previous C# syntax to C# 9. Not only that, but OSS maintainers who relied on generic constraints may be getting a trojan horse of unexpected behaviors. Records are an excellent addition to the language, but new bright and shiny features can distract from the sharp edges ready to hurt us.
Can you think of any other edge cases that folks should consider when looking at record
types? Please let me know in the comments, and please share this post with friends.