NDepend Blog

Improve your .NET code quality with NDepend

C# Record Explained

January 23, 2024 7 minutes read

C#-record

A C# record is a data-centric type that usually doesn’t contain behaviors.  C# 9 introduced the keyword record to quickly declare a class primarily designed for data representation. In C# 10 and upper, the new syntax record class is equivalent to C# 9 record and the syntax record struct declares a structure-based record.

This tutorial dives into C# records’ syntax, applications and benefits.

Declaring a Record Type

In C# 9, the Primary Constructor syntax was introduced as a concise means to declare a record’s constructor and properties. This syntax avoids several lines of boilerplate code:

The Primary Constructor syntax is optional and a C# record can be declared without it:

The Primary Constructor syntax also applies to record struct:

Note: In C# 12 the primary Constructor syntax becomes available for classes and structures that are not declared as record. In this non-record context, the primary constructor syntax generates private fields instead of public properties as when used with records. This potentially confusing behavior is explained here: C#12 class and struct Primary Constructors

Comparing Records with Classes in C#: Value-Based Semantics

Up until this point, we haven’t discussed anything that distinguishes record class from regular class. The key is that – unlike a C# class – a C# record relies on Value-Based Semantics. Such semantic results from two characteristics: Immutability and Value-Based Equality.

Immutability

By default, a record class declared with the Primary Constructor syntax is immutable. This means that its instances’ state cannot be modified after creation, ensuring data integrity and thread safety:

C#-immutable-record

Here the tooltip reverse-engineers the property FirstName { get; init; } generated through the Primary Constructor record syntax. Keep in mind that this compiler behavior is different for the record struct syntax that we will explain later.

Value-based equality

Value-based equality semantic means that two C# record instances are considered equal when their data matches. This stands in contrast to reference types like classes, where comparison is based on reference equality. Here is an example that demonstrates record’s Value-based equality:

To achieve Value-based equality on record class the compiler overrides the virtual methods Object.Equals(Object) and Object.GetHashCode(). The compiler also overrides the operators == and !=.

Practical Application of Value-Based Semantics

As a .NET developer, you rely implicitly on value-based semantics daily. Indeed the class System.String adheres to Value-Based Semantics.

Non-destructive record mutation with the keyword ‘with’

The code above demonstrates that modifying a string object leads to a new string object being created to hold the modified state. This behavior is named non-destructive mutation because the original string object doesn’t get modified. Non-destructive mutation is available for C# record through the keyword with:

When decompiling this C# code above with a .NET decompiler, we can see that the compiler generates a method named <Clone>$() for our record class. The nondestructive mutation is achieved by first cloning the record object and then assigning the property LastName. Normally this property cannot be assigned because the record is immutable. However special IL code generated by the C# compiler relying on IsExternalInit can assign it.

C#-with-syntax-with-clone-method

Caution: Non-destructive mutation of strings is notoriously known for generating numerous string objects in memory. Doing so pressures the Garbage Collector and leads to reduced performance. The goal of the class StringBuilder is to mitigate this impact. Similarly, non-destructive mutation of records can yield similar degraded performance outcomes.

Declaring mutable record class

Let’s mention that it is possible to declare mutable record classes when not relying on the Primary Constructor syntax:

Caution: Developers expect records to be immutable. As a consequence, mutable records can lead to confusion and error-prone code.

Formatting

Another difference between C# classes and C# records is that the compiler overrides the Object.ToString() method to present a record as a string in a well-formatted manner:

The same string representation is available at debug time:

C#-record-formatting

C# record struct vs. C# record class

Unlike record class, by default a record struct is mutable. This means that a setter is generated for each property declared through the Primary Constructor syntax:

To get an immutable record struct the keyword readonly must be used:

C#-immutable-record-struct

Another record struct specificity is that a default parameterless constructor is provided: it sets each field to its default value:

Record deconstruction

When using the Primary Constructor syntax, the compiler generates a Deconstruct() method with an out parameter for each positional parameter provided in the record declaration. Here is record deconstruction in action:

Notice that deconstruction makes records work seamlessly with C# Pattern Matching:

Record and Inheritance

record class can deal with regular C# class inheritance. A C# record can inherit from another C# record. Nevertheless, a record cannot inherit from a class, nor can a class inherit from a record.

While this feature is beneficial, it can introduce complexities in certain situations when dealing with the with syntax and value-based equality.

Inheritance and value-based equality

Viewed from the perspective of the Person  record, in the following code sample, both references hold identical values since they both have the same FirstName  and LastName values.

Fortunately, the compiler-generated code ensures that these two objects are treated as distinct. Upon revisiting the code produced by the compiler, you’ll notice that the implementation of the Equals() method relies on the virtual property protected virtual Type EqualityContract => typeof(Person);. This property is used to verify that the two compared objects share the same type.

Inheritance and the keyword ‘with’

In the code sample below it is not clear that person2 is a Student since it is inferred from a Person reference using the with syntax. As we saw in the Nondestructive mutation section, under the hood the generated virtual <Clone>$() method is called by the compiler. This virtual method is overridden by Student and its implementation calls the Student copy constructor:

Declaring attributes

It is possible to declare attributes for any of the elements generated by the compiler based on the C# record definition. You can specify a target for any attribute you use on the positional properties of the record. In the following example, the System.Text.Json.Serialization.JsonPropertyNameAttribute is assigned to each property generated by the C# compiler, of the Person record. The property target is used to specify that the attribute applies to the compiler-generated property, while other targets such as field and param can be used to apply the attribute to the field or parameter, respectively.

Generic Record

Let’s note that a record can be a generic class or structure, which offers flexibility. However, developers should keep in mind that EqualityComparer<T>.Default is employed for each property typed with T in the generated code to perform state comparisons. This can potentially result in confusing behavior like in the following short program

Situations where records shine

C# Records find practical applications in various contexts:

  • Simplifying Code: Records can help reduce boilerplate code, making your codebase more concise and easier to maintain. They are particularly beneficial when working with DTOs (Data Transfer Objects) and POCOs (Plain Old CLR Objects).
  • Domain Models: Records serve as effective tools for representing domain models in your application. You can define a record with properties that align with the data you’re modeling. Records offer the advantages of working with data in a strongly typed and immutable manner, and they provide built-in capabilities for equality checking, formatting, and nondestructive mutation.
  • Configuration Settings: Records are well-suited for representing configuration settings within an application. By creating a record with properties corresponding to different settings, you can easily pass these settings between methods and services, maintaining their immutability throughout.
  • Concurrent Access Scenario: Since C# record classes are immutable by default, they inherently avoid issues related to concurrent modifications because a record object cannot be modified. For the same reason, no synchronization effort is required.
  • API Response Models: Records are valuable for representing response models in RESTful APIs. They enable you to define a record with properties that mirror the data returned by the API. Records simplify the deserialization process, providing a strongly typed object for working with the data effortlessly.
  • Debugging and Logging: Records provide a clear and concise string representation of their data, making them valuable for debugging and logging. When you log or print a record, you get a well-formatted view of its contents.

Conclusion

In conclusion, understanding C# records is crucial for modern C# programming, offering a streamlined, efficient way to handle immutable data. Throughout this article, we’ve explored the intricacies of C# record syntax, compared them with traditional classes, and delved into their practical applications. The advantages of using records, such as improved readability and thread safety, make them an invaluable feature in your C# development toolkit. Whether you’re a seasoned developer or new to C#, embracing records can significantly enhance your coding practices.

As the C# language continues to evolve, staying updated with features like records is essential for writing clean, efficient, and maintainable code.