How Init-Only Setters Work for Immutability in C
Init-only setters, introduced in C# 9.0, provide a mechanism to make
individual object properties immutable while still allowing them to be
configured using object initializers. By replacing the traditional
set accessor with the init accessor,
developers can create flexible, clean models that forbid any state
modification once construction has completed, solving the long-standing
tension between constructor boilerplate and data mutability.
The Problem Init-Only Setters Solve
Prior to C# 9, achieving immutability in C# typically required one of two approaches:
- Read-only properties with parameterized
constructors: Properties only define a
getaccessor and are backed byreadonlyfields. While this guarantees immutability, it forces developers to write boilerplate constructors, especially as the number of properties grows. - Private setters: Properties use
private set;, which allows methods within the class to alter state, compromising strict immutability. Additionally, this approach prevents the use of object initializer syntax from external callers.
Init-only setters resolve this by allowing properties to be assigned during object construction—including via object initializers—while strictly preventing reassignment afterward.
Syntax and Basic Usage
An init-only property uses the init keyword in place of
set:
public class User
{
public int Id { get; init; }
public string Name { get; init; }
}Once declared, you can assign values to these properties during instantiation using standard object initialization syntax:
var user = new User
{
Id = 1,
Name = "Alice"
};Any attempt to modify the property after the initialization phase will trigger a compile-time error:
// Compile-time error: CS8852: Init-only property or indexer 'User.Name'
// can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor.
user.Name = "Bob"; Where Can
init Properties Be Mutated?
The compiler restricts assignments to init properties to
specific execution contexts:
- Object initializers: Property values assigned
within
{ ... }at creation. - Instance constructors: Assigned via
this.PropertyNameinside the declaring type. - Derived class constructors: Assigned via
base.PropertyNameorthis.PropertyNamein child classes. withexpressions: When used withrecordtypes or structs, non-destructive mutation creates a new instance and leveragesinitaccessors during that new instance's creation.
How the Runtime Enforces Init-Only Properties
Under the hood, C# enforces init-only setters using Intermediate
Language (IL) metadata called required modifiers
(modreq).
When the C# compiler compiles a property with an init
accessor, it emits a standard setter method for that property, but marks
the return type with
System.Runtime.CompilerServices.IsExternalInit.
Compilers that understand this modifier (such as Roslyn) recognize
that this setter may only be called during initialization. If a consumer
attempts to call the setter outside of an initialization scope,
compilation fails. If an older compiler that does not understand the
modreq tries to interact with the member, it refuses to
compile the call, thereby protecting the property's immutability across
different consumers.
Shallow vs. Deep Immutability
It is important to note that init-only setters enforce shallow immutability.
If an init property holds a reference to a mutable
object, the reference itself cannot be swapped, but the internal state
of that referenced object can still be modified:
public class Team
{
public List<string> Members { get; init; }
}
var team = new Team
{
Members = new List<string> { "Alice", "Bob" }
};
// Allowed: The collection reference remains the same, but its state changes
team.Members.Add("Charlie");
// Compiler error: Cannot reassign the reference
team.Members = new List<string>(); To ensure true deep immutability, pair init setters with
immutable collection types (such as ImmutableList<T>)
or read-only interfaces.