C# 9.0: Records – Work With Immutable Data Classes
In the previous blog posts you learned about different C# 9.0 features:
In this blog post, let’s look at another very interesting feature of C# 9.0 that is called record types or just records.
Working with immutable data is quite powerful, leads often to fewer bugs, and it forces you to transform objects into new objects instead of modifying existing objects. F# developers are used to this, as F# treats everything by default as immutable. Now you get immutable types in C# 9.0 as well, with the so-called record types, or just records. Records make it easier for you to work with immutable data in C#. Before we look at records in this blog post, let’s start with a simple class.
Let’s Start With a Class
In the previous blog post you learned about init-only properties in C# 9.0. I created the Friend
class below with the two init-only properties FirstName
and LastName
. If you don’t know what init-only properties are, please read that previous blog post:
public class Friend
{
public string FirstName { get; init; }
public string LastName { get; init; }
}
With init-only properties you get immutable properties. In the Friend
class in the code snippet above, all its properties are init-only properties and so immutable, you can’t change them. That means, when working with this class, you don’t change the property values, because you can’t. If you need to change something, you create a new Friend
object with the updated data. This is what you do when working with immutable data. Instead of changing an object over time, you create a new object when a change is needed. This means that your object represents the state of data at a specific point in time.
Let me give you an example of how this is done. Let’s create a Friend
object as below:
var friend = new Friend
{
FirstName = "Thomas",
LastName = "Huber"
};
Now let’s assume that at some point in your program you need to change the lastname of that friend to Mueller, which is a very common lastname in Germany (You might know the famous soccer scorers Gerd Mueller and Thomas Mueller). As you work with immutable data, you can’t change the LastName
property. Instead of doing this, you create a new Friend
object that represents the new state. You might create that new Friend
object like in the code snippet below. Note how I assign the value of the FirstName
property from the first Friend
object to the FirstName
property of the second Friend
object:
var friend = new Friend
{
FirstName = "Thomas",
LastName = "Huber"
};
var newFriend = new Friend
{
FirstName = friend.FirstName,
LastName = "Mueller"
};
But somehow this approach gets annoying when you have more properties. Let’s add a Middlename
property to the Friend
class:
public class Friend
{
public string FirstName { get; init; }
public string MiddleName { get; init; }
public string LastName { get; init; }
}
Now you see in the code snippet below that the creation of the second Friend
object with a new lastname also gets an additional code line for that new Middlename
property. That is because you also have to copy that property from the old Friend
object:
var friend = new Friend
{
FirstName = "Thomas",
MiddleName = "Claudius",
LastName = "Huber"
};
var newFriend = new Friend
{
FirstName = friend.FirstName,
MiddleName = friend.MiddleName,
LastName = "Mueller"
};
That means the more properties you have, the harder this gets. Of course you can implement some copy logic with reflection or serialization, or you could use an auto-mapping library. But C# 9.0 has a better way for you to work with immutable data classes: Records.
Create Your First Record
To change our Friend
class to a record, you use the record
keyword instead of the class
keyword. Below you see the corresponding type as a record type:
public record Friend
{
public string FirstName { get; init; }
public string MiddleName { get; init; }
public string LastName { get; init; }
}
Defining the Friend
type as a record type means that you want to treat objects of this type like an immutable data value. Defining a type as a record gives you support for the new with
expression that is also introduced with C# 9.0.
Create Copies With the With Expression
In the code snippet below you see the way that we used in this blog post to create a new Friend
object with a new lastname. This approach works also with the record type, but it is not really efficient: You have to copy all the property values from the original object manually, and you might forget a property in your code if you write it manually as in the snippet below.
var friend = new Friend
{
FirstName = "Thomas",
MiddleName = "Claudius",
LastName = "Huber"
};
var newFriend = new Friend
{
FirstName = friend.FirstName,
MiddleName = friend.MiddleName,
LastName = "Mueller"
};
The with
expression allows you to create a new object more efficiently. You see it in action in the code below, and that code leads to the same result as the code that you see in the snippet above. The last statement in the code below uses the with
expression to create a new Friend
object from the existing Friend
object stored in the friend
variable. You can read that statement like this: Use the property values of the existing Friend
object stored in the friend
variable to create a new Friend
object and set the LastName
property of that new Friend
object to Mueller. Store the new Friend
object that gets generated by the with
expression with the property values shown in the comments in the newFriend
variable.
var friend = new Friend
{
FirstName = "Thomas",
MiddleName = "Claudius",
LastName = "Huber"
};
var newFriend = friend with { LastName = "Mueller" };
// newFriend.FirstName is "Thomas"
// newFriend.MiddleName is "Claudius"
// newFriend.LastName is "Mueller"
As you can see in the snippet above, the with
expression uses the curly syntax that you know from object initializers to define new values for specific properties. That means if you’re familiar with object initializers, you’ll get up to speed with this new syntax quite fast. But remember, the with
expression works only with record types and not with normal classes.
When you work with immutable data, you create a copy of your object for a mutation. This technique is known as non-destructive mutation. Instead of having a single object that represents the state over time, you have immutable objects, each representing the state at a given time.
Check if Your Records are Equal
Record types are reference types and not value types as structs. But their Equals
method is implemented for you, so that it compares all the property values for equality. Actually, the C# compiler generates that Equals
method for you. And the compiler also generates operator overloads for ==
and !=
, so that these operators use that Equals
method too. This is another feature of record types. That means you can compare two records by their property values for equality. The code snippet below shows this in action. First a Friend
object is created and stored in the friend
variable. Then the with
expression is used to create another Friend
object from that existing Friend
object. The LastName
property of the new Friend
object is set to Mueller. Then the two Friend
objects are compared with the ==
operator. The result is false, as the LastName
property of the new Friend
is not Huber, but Mueller.
var friend = new Friend
{
FirstName = "Thomas",
LastName = "Huber"
};
var newFriend = friend with { LastName = "Mueller" };
Console.WriteLine(friend == newFriend); // false, as property values are different
var anotherFriend = newFriend with { LastName = "Huber" };
Console.WriteLine(friend == anotherFriend); // true, as property values are the same
After the Console.WriteLine
statement a third Friend
object is created from the newFriend
object. The with
expression is used to set the LastName
property to Huber, and the created Friend
object is stored in the anotherFriend
variable. This means that the object stored in the anotherFriend
variable has the same property values as the very first Friend
object that is stored in the friend
variable. Then that very first Friend
object is compared with the ==
operator to that third Friend
object stored in the anotherFriend
variable. The result is written again to the Console with a Console.WriteLine
statement. In this case the result is true, as the properties of the two Friend
objects contain the same values.
Checking for equality is another powerful feature of record types. Calling the Equals
method or using the ==
operator compares all the property values. Actually, a record type implements IEquality<T>
, in case of our Friend
type it implements IEquality<Friend>
.
What Does the Compiler Generate?
When you open the .dll file of your app in the Intermediate Language Disassembler (ILDASM.exe – learn how to open it in this post), you can look at the Friend
type to see everything that the C# compiler has generated. In the screenshot below you can see our Friend
record type in the Intermediate Language Disassembler. The C# compiler actually generated a class for the Friend
record type with the properties FirstName
, LastName
and MiddleName
. What you can see there too is the implementation of IEquatable<Friend>
. You can also see other things, like for example a copy constructor that takes another Friend
object, shown in the screenshot as .ctor : void(class Friend)
.
When you double click that copy constructor, you can see the Intermediate Language code. The word family on the first line tells you that the constructor is protected. The constructor copies all the values of the passed in Friend
object to the new Friend
object’s properties FirstName
, MiddleName
and LastName
.
So, you can think of this copy constructor to be something like this in C#:
protected Friend(Friend original)
{
this.FirstName = original.FirstName;
this.MiddleName = original.MiddleName;
this.LastName = original.LastName;
}
When you use the with
expression like in the code snippet below, the copy constructor is called to create a new copy of the corresponding Friend
object that you specify with the with
expression. After that the property LastName
is set to the value that you’ve specified in the object initializer of the with
expression.
var friend = new Friend
{
FirstName = "Thomas",
MiddleName = "Claudius",
LastName = "Huber"
};
var newFriend = friend with { LastName = "Mueller" };
// newFriend.FirstName is "Thomas"
// newFriend.MiddleName is "Claudius"
// newFriend.LastName is "Mueller"
Beside the copy constructor, there gets more code generated for record types. That compiler-generated code is where all the magic happens. Another useful compiler-generated feature is the protected PrintMembers
method. It takes a StringBuilder
as a parameter and it adds all the property names and property values to that StringBuilder
object. The PrintMembers
method is called by the overriden ToString
method that is also generated by the compiler. The following code snippet prints the members to the Friend
object the console, as the Console.WriteLine
method calls the ToString
method on the Friend
object.
var friend = new Friend
{
FirstName = "Thomas",
MiddleName="Claudius",
LastName = "Huber"
};
Console.WriteLine(friend);
The output at the console looks like below. As you can see, first the type is printed and then all the members:
Friend { FirstName = Thomas, MiddleName = Claudius, LastName = Huber }
Hey Thomas, I Don’t Want to Read IL Code
Ok, got it. So, let’s decompile the IL Code again to C# code. Let’s look at the C# code that gets generated for this record type:
public record Friend
{
public string FirstName { get; init; }
public string MiddleName { get; init; }
public string LastName { get; init; }
}
To decompile the created C# code in the compiled .dll of the application I use ILSpy. Below you see the decompiled output that I get for the Friend
type. There you can see the copy constructor, the PrintMembers
method, the override of the ToString
method, the generated Equals
method, the overloaded ==
and !=
operators and much more.
Note that the attributes NullabeContext
and Nullable
from the System.Runtime.CompilerServices
namespace are added by the C# compiler for nullable reference types that where introduced with C# 8.0. Don’t worry about these attributes when looking at the class. Just ignore the attributes and read the logic in the constructor and in the different methods. If there are questions, just comment under this blog post
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text;
public class Friend : IEquatable<Friend>
{
[System.Runtime.CompilerServices.Nullable(1)]
protected virtual Type EqualityContract
{
[System.Runtime.CompilerServices.NullableContext(1)]
[CompilerGenerated]
get
{
return typeof(Friend);
}
}
public string FirstName { get; init; }
public string MiddleName { get; init; }
public string LastName { get; init; }
public override string ToString()
{
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.Append("Friend");
stringBuilder.Append(" { ");
PrintMembers(stringBuilder);
stringBuilder.Append(" } ");
return stringBuilder.ToString();
}
[System.Runtime.CompilerServices.NullableContext(1)]
protected virtual bool PrintMembers(StringBuilder builder)
{
builder.Append("FirstName");
builder.Append(" = ");
builder.Append((object)FirstName);
builder.Append(", ");
builder.Append("MiddleName");
builder.Append(" = ");
builder.Append((object)MiddleName);
builder.Append(", ");
builder.Append("LastName");
builder.Append(" = ");
builder.Append((object)LastName);
return true;
}
[System.Runtime.CompilerServices.NullableContext(2)]
public static bool operator !=(Friend r1, Friend r2)
{
return !(r1 == r2);
}
[System.Runtime.CompilerServices.NullableContext(2)]
public static bool operator ==(Friend r1, Friend r2)
{
return (object)r1 == r2 || (r1?.Equals(r2) ?? false);
}
public override int GetHashCode()
{
return ((EqualityComparer<Type>.Default.GetHashCode(EqualityContract) * -1521134295
+ EqualityComparer<string>.Default.GetHashCode(FirstName)) * -1521134295
+ EqualityComparer<string>.Default.GetHashCode(MiddleName)) * -1521134295
+ EqualityComparer<string>.Default.GetHashCode(LastName);
}
[System.Runtime.CompilerServices.NullableContext(2)]
public override bool Equals(object obj)
{
return Equals(obj as Friend);
}
[System.Runtime.CompilerServices.NullableContext(2)]
public virtual bool Equals(Friend other)
{
return (object)other != null
&& EqualityContract == other.EqualityContract
&& EqualityComparer<string>.Default.Equals(FirstName, other.FirstName)
&& EqualityComparer<string>.Default.Equals(MiddleName, other.MiddleName)
&& EqualityComparer<string>.Default.Equals(LastName, other.LastName);
}
[System.Runtime.CompilerServices.NullableContext(1)]
public virtual Friend <Clone>$()
{
return new Friend(this);
}
protected Friend([System.Runtime.CompilerServices.Nullable(1)] Friend original)
{
FirstName = original.FirstName;
MiddleName = original.MiddleName;
LastName = original.LastName;
}
public Friend()
{
}
}
So, this code snippet above makes it clear that the magic of record types is a lot of compiler-generated code. And there’s even more that can be compiler-generated, so let’s continue.
Create a Constructor and a Deconstructor
Maybe you want to work with your records with a constructor instead of using object initializers. And maybe you also want to have positional deconstruction. Then you can implement a constructor and a Deconstruct
method. The following Friend
record does this:
public record Friend
{
public string FirstName { get; init; }
public string MiddleName { get; init; }
public string LastName { get; init; }
public Friend(string firstName, string middleName, string lastName)
{
FirstName = firstName;
MiddleName = middleName;
LastName = lastName;
}
public void Deconstruct(out string firstName,
out string middleName, out string lastName)
{
firstName = FirstName;
middleName = MiddleName;
lastName = LastName;
}
}
Now, with the constructor and the Deconstruct
method in place, you can construct and deconstruct your Friend
type like this:
var friend = new Friend("Thomas", "Claudius", "Huber"); // This is the construction
var (first, middle, last) = friend; // This is the deconstruction
Console.WriteLine(first); // Thomas
Console.WriteLine(middle); // Claudius
Console.WriteLine(last); // Huber
As you can see, the deconstruction allows you to assign the Friend
object to a tuple that specifies the individual variables to store the values. If you’re not interested in a value, you can use a discard, which is an underscore. If you don’t need the middlename for example, you can write the tuple like this: (first, _, last)
.
The Deconstruct
method is supported since C# 7.0. It was introduced with the concept of Tuples. You can add a public Deconstruct
method to any type in .NET. It must return void and have only out
parameters. Then you can use the deconstruction syntax to deconstruct the type to a tuple as shown in the snippet above. Read more about Tuples and the Deconstruct
method in the official docs.
Now the important thing is that all that construction and deconstruction would also work if the Friend
type would be a class instead of a record. The power of record types – beside the with
expression and generated members like the overridden ToString
method – is that the C# compiler can also generate all that constructor and deconstructor boilerplate for you, including the init-only properties.
Generate Constructor, Deconstructor and Init-only Properties
Look at this beautiful piece of code below. This is a short-hand syntax to create a record type. This syntax is also known as a Positional Record:
public record Friend(string FirstName,string MiddleName, string LastName);
The code snippet above creates a Friend
record type with the public init-only properties FirstName
, MiddleName
, LastName
AND it creates a public constructor AND it creates a Deconstruct
method. Isn’t that powerful? Note that the parameters in the code snippet above are written in PascalCase, which means they start with an uppercase character. PascalCase instead of camelCase is used, because properties are generated from these parameters, and properties in .NET are written in PascalCase.
With a constructor you have to pass in the values by position to create an object. That is a positional object creation. When you use an object initializer, the order of the properties does not matter. That’s a so-called nominal object creation. The record defined above creates a constructor that takes all the property values as parameters. That is the reason why it is called a positional record. You have to pass in all the values by position to the constructor to create an object.
When you look at the generated Friend
type in ILDASM in the screenshot below, you can see that the properties FirstName
, MiddleName
, and LastName
were generated for the positional record. Also a public constructor with three string parameters is generated. These parameters are for firstname, middlename and lastname. Also the Deconstruct
method is generated for you, beside all the other stuff that you saw already before, like for example the protected copy constructor that has a single Friend
parameter.
The code snippet below shows a full top-level program that uses the short-hand syntax to define the record type Friend
and that constructs and deconstructs a Friend
object. As the Friend
type is a record type, the Friend
object is of course also immutable.
using System;
var friend = new Friend("Thomas", "Claudius", "Huber");
Console.WriteLine(friend.FirstName); // Thomas
var (first, middle, last) = friend; // deconstruct
Console.WriteLine(first); // Thomas
Console.WriteLine(middle); // Claudius
Console.WriteLine(last); // Huber
public record Friend(string FirstName,string MiddleName, string LastName);
Positional Records and the With Expression
When you use a positional record like the one below, you get a constructor with three parameters, and there’s no default constructor anymore:
public record Friend(string FirstName,string MiddleName, string LastName);
So, you might think that you can’t use the with
expression anymore, as there’s no default constructor. But actually, the protected copy constructor still exists, and that one is used by the with
expression. That means, also a positional record like the one created above can be used with the with
expression like in the following top-level program:
using System;
var friend = new Friend("Thomas", "Claudius", "Huber" );
var newFriend = friend with { LastName = "Mueller" };
Console.WriteLine(newFriend);
public record Friend(string FirstName, string MiddleName, string LastName);
The output at the console is this:
Friend { FirstName = Thomas, MiddleName = Claudius, LastName = Mueller }
Inherit Records from Other Records
Inheritance works also with record types. You can inherit your records from other records or from object
. You can not inherit a record from any other class than object
, and you can not inherit a class from a record. The following code snippet shows two record types. The record Developer
inherits from the record Person
.
public record Person
{
public string FirstName { get; init; }
public string LastName { get; init; }
}
public record Developer : Person
{
public string FavoriteLanguage { get; init; }
}
Everything works as you would expect it. Let’s try some typical stuff that you do usually when you work with inheritance. For example, let’s store a Developer
object in a Person
variable. As you can see in the screenshot below, the with
expression let’s you only set the properties FirstName
and LastName
that are available in the Person
class. The FavoriteLanguage
property is not available in the object initializer, as it is defined in the Developer
class, but we have here a Person
variable.
Let’s set the FirstName
property to Tom like you see it in the code snippet below. Now the question is, what does the with
expression create: A new Person
object or a new Developer
object?
Person person = new Developer
{
FirstName = "Thomas",
LastName = "Huber",
FavoriteLanguage = "C#"
};
var newPerson = person with { FirstName = "Tom" };
The with
expression is smart enough to find out at runtime that the person
variable of type Person
contains a Developer
object, so it creates a new Developer
object and not a new Person
object. The code snippet below writes the value of the FavoriteLanguage
property of that new Developer
object to the Console.
Person person = new Developer
{
FirstName = "Thomas",
LastName = "Huber",
FavoriteLanguage = "C#"
};
var newPerson = person with { FirstName = "Tom" };
if (newPerson is Developer newDev) // is true, as newPerson variable contains a Developer
{
Console.WriteLine(newDev.FavoriteLanguage); // C#
}
How is this possible that the with
expression is so smart? The with
expression actually calls the copy constructor to create a new object via the compiler-generated <Clone>$
method. In the code snippet below you can see the decompiled <Clone>$
method for the Person
record type:
public virtual Person <Clone>$()
{
return new Person(this);
}
As you can see in the snippet above, the <Clone>$
method calls internally the copy constructor to clone the current instance. Now, the interesting bit is that this <Clone>$
method is virtual
. And now you can guess what the decompiled <Clone>$
method of our Developer
type that inherits from Person
looks like. Here we go:
public override Person <Clone>$()
{
return new Developer(this);
}
As you can see in the code snippet above, the generated code for the Developer
record type overrides the <Clone>$
method of the Person
type, and internally it creates and returns a new Developer
instance. It allows the with
expression to create a Developer
copy, even if the original Developer
instance is stored in a Person
variable like in our code above. Because then, the overridden <Clone>$
method of the Developer
class is called that calls the Developer
constructor. This is the whole magic behind the with
expression when it is used with inheritance like we did in this section. If you’re familiar with object-oriented programming, you know that this override of the <Clone>$
method is called polymorphism.
Inheritance and Equality
Record types work also as expected when you use inheritance and when you check for equality. In the code snippet below I create a new Person
and a new Developer
, both have the same values for the properties FirstName
and LastName
. But when you compare the two objects with the ==
operator, you get false
as a result, because the types Person
and Developer
are different types.
var person = new Person
{
FirstName = "Thomas",
LastName = "Huber",
};
var dev = new Developer
{
FirstName = "Thomas",
LastName = "Huber",
};
Console.WriteLine(person == dev); // false, because types are different
This means that beside the property values, the Equals
method and the overloaded ==
operator of a record type also take the type into account. Let’s take a look at how this works. When you decompile the generated code, you find on the Person
type this readonly property called EqualityContract
. As you can see, it returns typeof(Person)
, and it is also a virtual property.
[System.Runtime.CompilerServices.Nullable(1)]
protected virtual Type EqualityContract
{
[System.Runtime.CompilerServices.NullableContext(1)]
[CompilerGenerated]
get
{
return typeof(Person);
}
}
The sub type Developer
overrides that EqualityContract
property, you can see that override in the code snippet below. As you can see, the override returns typeof(Developer)
.
[System.Runtime.CompilerServices.Nullable(1)]
protected override Type EqualityContract
{
[System.Runtime.CompilerServices.NullableContext(1)]
[CompilerGenerated]
get
{
return typeof(Developer);
}
}
Now, when you look at the decompiled Equals
methods in the Person
type, you can see that the Equals
method that takes a Person
as a parameter first compares the EqualityContract
properties before it compares the values of the FirstName
and LastName
properties. That’s the reason why a Person
instance and a Developer
instance will never be equal.
[System.Runtime.CompilerServices.NullableContext(2)]
public override bool Equals(object obj)
{
return Equals(obj as Person);
}
[System.Runtime.CompilerServices.NullableContext(2)]
public virtual bool Equals(Person other)
{
return (object)other != null
&& EqualityContract == other.EqualityContract
&& EqualityComparer<string>.Default.Equals(FirstName, other.FirstName)
&& EqualityComparer<string>.Default.Equals(LastName, other.LastName);
}
When you look at the decompiled Equals
methods of the Developer
class in the code snippet below, you can see that the Developer
type overrides the two Equals
methods from the Person
type (Actually, the very first Equals
method is from System.Object
). The second Equals
method calls the first Equals
method. The first Equals
method calls the third Equals
method. That means, the third Equals
method gets always called. There you can see that it first calls base.Equals
, which is the Equals
implementation in the Person
base type that you see in the code snippet above. If that base.Equals
method returns true
, the Equals
method of the Developer
type also compares the value of the FavoriteLanguage
property that is defined in the Developer
record type.
[System.Runtime.CompilerServices.NullableContext(2)]
public override bool Equals(object obj)
{
return Equals(obj as Developer);
}
[System.Runtime.CompilerServices.NullableContext(2)]
public sealed override bool Equals(Person other)
{
return Equals((object)other);
}
[System.Runtime.CompilerServices.NullableContext(2)]
public virtual bool Equals(Developer other)
{
return base.Equals(other)
&& EqualityComparer<string>.Default.Equals(FavoriteLanguage, other.FavoriteLanguage);
}
So, everything you need for your record types regarding equality checks is generated for you by the C# compiler, also when you’re using inheritance as you saw in this section.
Use Positional Records and Inheritance
You can also use positional records and inheritance. Below you see the types Person
and Developer
. Developer
inherits from Person
. The Developer
constructor passes the values of the FirstName
and LastName
parameter to the Person
constructor.
public record Person(string FirstName, string LastName);
public record Developer (string FirstName, string LastName, string FavoriteLanguage)
: Person(FirstName, LastName);
Now, when you look at the code snippet above, you could think that the properties FirstName
and LastName
are generated on the Person
type and on the Developer
type. But actually, the C# compiler is a smart beast. Below you see the Developer
type in the Intermediate Language Disassembler. As you can see, it contains only the FavoriteLanguage
property, but not the properties FirstName
and LastName
. Those are defined in the base type Person
.
In the screenshot above you can also see the generated constructor (.ctor) with the three string parameters. When you double-click that constructor, you see the Intermediate Language code like in the screenshot below. You can see there that the constructor of the Developer
type has the three string
parameters FirstName
, LastName
and FavoriteLanguage
. In the constructor, the FavoriteLanguage
property is set, and after that the base constructor of the Person
type is called.
When you decompile the Intermediate Language code with the Developer
constructor with a tool like ILSpy, you see that the C# variant of such a constructor looks like below. Note the call to the base constructor defined in the Person
type that takes the firstname and the lastname as arguments:
public Developer(string FirstName, string LastName, string FavoriteLanguage)
: base(FirstName, LastName)
{
this.FavoriteLanguage = FavoriteLanguage;
}
So, positional records work also well with inheritance. Below you see a full-blown top-level program that shows the Person
and the Developer
type in action.
using System;
using System.Collections.Generic;
var persons = new List<Person>
{
new Person("Julia","Huber"),
new Developer("Thomas", "Huber", "C#"),
};
foreach(var person in persons)
{
Console.WriteLine(person);
}
public record Person(string FirstName, string LastName);
public record Developer (string FirstName, string LastName, string FavoriteLanguage)
: Person(FirstName, LastName);
The console output looks like this:
Person { FirstName = Julia, LastName = Huber }
Developer { FirstName = Thomas, LastName = Huber, FavoriteLanguage = C# }
Summary
You learned in this blog post about record types that are introduced with C# 9.0. They make working with immutable data objects in C# a joy. For F# developers this is nothing new, but for C# developers it’s a huge improvement to the language. The with
expression is a powerful and nice syntax that is only available for records, and also the positional records are great to generate properties and constructors/deconstructors.
Happy coding,
Thomas
Comments (9)
Hi Thomas,
thanks for this blog post. It was worth every word :)
One question I am curious is what happens if you use other records or classes as field-type (e. g. an Adress field) or even a list of adresses. Is this also supported and does the with-expression create deep copies?)
best regards, Jan
Jan, I’m also curious how it can be done using records in C#9 but couldn’t find anything.
I know you can do that with “normal” immutable classes using remute library – https://github.com/ababik/Remute
So you can do `person.With(x => x.Address.Street1, “….”);`
Would be great to do it out of the box though. I guess “record” means flat structure.
Really helpful that Thomas, thanks! Enjoyed reading it.
Do you know if there’s a way to generate structs instead of classes? Just thinking that if you were to do this indiscriminately with a lot of objects, you could end up with an awful lot of copied objects filling up the heap.
Cheers.
Thanks Richard. I haven’t heard about any compiler flag that would allow you to create structs instead of classes, but I like that idea.
Really clear and helpful explanation of record types – thanks Thomas!
When using positional records, is there a way to still include an init code block to be able to add e.g. validation logic to the properties? And would this be possible to do individually for each property?
Thanks
I guess no way to force a type to be immutable. Not even using record.
A developer can still need to make sure not to use properties like:
public List MutableProperty { get; init; }
Correct?
Isn’t there some attribute or different keyword to make VS worn when doig such nested mistakes?
Maybe like
public immutable record SomeImmutableRecord {}
Not sure I get the point. Yes, a developer can use other properties. But records are a tool for developers, and they can use it or not. So, if the developer decides to create a record with no init-only properties, that’s their decision. :) There’s no other keyword.
@Meir Kriheli, I agree it was a valuable feature having the compiler assert for “deep immutability”, i.e. only allow properties which are immutable themselves.
What I find most unfortunate though, is the fact records still allow for traditional properties to be contained, exposing public setters, like any another reference type.
@Thomas, nice post – thank you!
Thanks Christof