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
.
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.
We can then use the with
keyword to create a duplicate copy of our instance.
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.
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.
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.
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.
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.
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.
Looking at the IL of our Person
record shows that only the First
and Last
properties are part of the deconstructor.
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.
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.
The deconstructor for Person
will return First
followed by Last
, whereas the deconstructor for Other
will perform the inverse, returning Last
, then First
.
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.
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.
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.
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.