C# 9 Records and Init Only Settings Without .NET 5
This blog is one of The December 11th entries on the 2020 C# Advent Calendar. Thanks for having me again Matt!
Some of the biggest new features in C# 9 have got to be Records and Init only setters. But when can you safely use these features? Officially, you can only use them if you are targeting .NET 5. However, many developers may not be ready to move to .NET 5 yet, especially since it isn’t an LTS release. And what about NuGet packages targeting .NET Standard or other versions of .NET?
TL;DR
For internal projects and/or types, you can safely use records and init only setters to target any modern version of .NET. I’ve tested .NET 4.8, .NET Core 2.1, and .NET Core 3.1. However, for any public members exposed via NuGet packages there are lots of caveats.
Note: Most of the other C# 9 features are fine to use, regardless of target frameworks or consumer C# versions:
- Top-level statements
- Switch expression pattern matching enhancements
- Native sized integers (syntactic sugar for IntPtr/UIntPtr)
- Target-typed new expression
- Static anonymous functions
- GetEnumerator extension methods
- Lambda discard patterns
- Attributes on local functions
- New features for partial methods
What are Records?
There are plenty of blog posts out there that describe records and what they do, so I won’t dig in too deep here. In simple terms, they provide a lot of boilerplate code that allows a developer to create immutable reference types with value equality, easy methods to clone the types with slightly different values, and more.
public namespace MyProgram
{
public record Person
{
public string? FirstName { get; init; }
public string? LastName { get; init; }
}
public record PersonWithHeight : Person
{
public int HeightInInches { get; init; }
public PersonWithHeight Grow(int inches) =>
this with { HeightInInches = HeightInInches + inches };
}
}
PersonWithHeight person = new()
{
FirstName = "Brant",
LastName = "Burnett",
HeightInInches = 69
};
Under the covers, records are really classes with a lot of boilerplate code already added to deal with equality comparisons, cloning, ToString, etc.
What are init only setters?
Init only setters are not really specific to records, though they are quite powerful when combined. You can see an example
of init only setters above in the example for records. Instead of a set
keyword, the property uses an init
keyword.
These properties can only be set as part of an object initializer combined with the constructor, after which they may not be
modified.
Person person = new()
{
FirstName = "Brant" // Allowed
};
person.LastName = "Burnett"; // This will cause a compiler error
Under the covers, C# does this by making the setter method have a slightly different signature. Normally, the signature would
be void set_FirstName(string value)
, but an init only setter has the signature
void modreq(System.Runtime.CompilerServices.IsExternalInit) set_FirstName(string value)
. Note that modreq
can’t be
directly added to a method signature in C#, this is an IL construct. C# adds it for you in this case.
Requirements to Create Records and Init Only Setters
LangVersion 9
Your project must be using C# 9 or later. If you’re targeting .NET 5, this should be the case already. However, if you
target other runtimes, you may need to manually enable C# 9 in your .csproj
file.
<PropertyGroup>
<LangVersion>9</LangVersion>
</PropertyGroup>
You must also be using Visual Studio 2019 16.8 or later, or MSBuild 2019 16.8 or later, or the .NET Core SDK 5.0.100 or later. These are the versions that include the C# 9 compiler.
Creating Init Only Properties on Older Frameworks
The key to init-only properties is the IsExternalInit
class, which is basically nothing but a placeholder (somewhat like
an empty attribute) that is applied to the void
return type of the setter. This type is defined as part of .NET 5, but
if you’re not targeting .NET 5 then it’s not available for the compiler to reference.
CS0518 Predefined type 'System.Runtime.CompilerServices.IsExternalInit' is not defined or imported
To fix this, you must include the type yourself in your code. I recommend a copy/paste of the following into a file in your project. It includes conditional compilation directives which work if you’re multi-targeting so that it isn’t included unless necessary.
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
#if NETSTANDARD2_0 || NETSTANDARD2_1 || NETCOREAPP2_0 || NETCOREAPP2_1 || NETCOREAPP2_2 || NETCOREAPP3_0 || NETCOREAPP3_1 || NET45 || NET451 || NET452 || NET6 || NET461 || NET462 || NET47 || NET471 || NET472 || NET48
using System.ComponentModel;
// ReSharper disable once CheckNamespace
namespace System.Runtime.CompilerServices
{
/// <summary>
/// Reserved to be used by the compiler for tracking metadata.
/// This class should not be used by developers in source code.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
internal static class IsExternalInit
{
}
}
#endif
Creating Records on Older Frameworks
Records actually work fine on older frameworks without any special changes, so long as you aren’t using init only setters.
However, records do transparently apply init only setters if you use this syntax to define the properties:
public record Person(string FirstName, string LastName)
{
}
In that case, be sure to add the IsExternalInit
class above.
Consuming Records and Init Only Properties
What about including records and init only properties in things like NuGet packages? What are the requirements for package consumers to be able to use these features?
Private Members
It is safe to include private
or internal
records or properties with init only setters in public facing
assemblies. Records are basically just syntactic sugar on top of classes, and modreq
used by init only setters
have been around a long time.
Public Init Only Setters
To use a public
or protected
property with an init only setter, the consumer must be using a C# compiler that
supports C# 9. Even if they aren’t using LangVersion 9 in their csproj, the compiler must be capable of understanding
the modreq
. If the consumer doesn’t understand the init only setter, it will be unable to access the setter at all
and will appear as a read-only property.
Similarly, other languages like VB.NET don’t like init only setters. In fact, VB.NET won’t recognize the property at all, even as read-only, and even with the latest version of the VB.NET compiler.
As a result, I do not recommend using init only setters for public members unless it is for an internal project with a known list of consumers. All of these consumers must be C# projects, and all developers must be using the latest C# compiler.
Public Records
The compatibility of records across different versions of C# is a bit more confusing. First of all, if you’re using init only setters on your records (you probably are) then see those rules above.
C# consumers can get most of the other features of records, except cloning. The clone method is hidden under the
special name <Clone>$
and you can’t reach it directly in C#, you must use the with
operator which is only
available in C# 9.
The same limitation applies in VB.NET, except even on the latest version there is no equivalent of the with
operator. This makes cloning completely unavailable.
Summary
Okay, all of that was pretty convoluted. Variables include your target framework, your consumer’s target framework, your LangVersion, and the version of MSBuild or .NET Core SDK installed on the consumer’s development machine and build agents.
For developers of NuGet packages which must be consumed by many users, here’s a matrix that will hopefully make things a bit clearer. These are my recommendations, some of these listed as Not Available could be considered partially available. But in my opinion the limitations aren’t worth it and it should be avoided.
Private/Internal Members | .NET 5.0 Target | Older Targets |
---|---|---|
C# .NET 5.0 Consumer | Available | Available (*) |
VB .NET 5.0 Consumer | Available | Available (*) |
C# 9 Older Framework Consumer | n/a | Available (*) |
C# <9 Older Framework Consumer | n/a | Available (*) |
VB Older Framework Consumer | n/a | Available (*) |
Public/Protected Members | .NET 5.0 Target | Older Targets |
---|---|---|
C# .NET 5.0 Consumer | Available | Available (*) |
VB .NET 5.0 Consumer | Available | Available (*) |
C# 9 Older Framework Consumer | n/a | Available (*) |
C# <9 Older Framework Consumer | n/a | Not Available |
VB Older Framework Consumer | n/a | Not Available |
(*) = Must add IsExternalInit class snippet above
Conclusion
To boil it all down even more simply, here are my overall recommendations for when you should and should not use records and init only setters.
Project Type | Public Records | Internal/Private Records | Public/Protected Init Only Setters | Internal/Private Init Only Setters |
---|---|---|---|---|
Internal Use | Yes | Yes | Yes | Yes |
NuGet | No | Yes | No | Yes |
NuGet .NET 5 Target Only | Yes | Yes | Yes | Yes |
Comments