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("First 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("First 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 codebases 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.

References