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:

  1. Read-only properties with parameterized constructors: Properties only define a get accessor and are backed by readonly fields. While this guarantees immutability, it forces developers to write boilerplate constructors, especially as the number of properties grows.
  2. 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.PropertyName inside the declaring type.
  • Derived class constructors: Assigned via base.PropertyName or this.PropertyName in child classes.
  • with expressions: When used with record types or structs, non-destructive mutation creates a new instance and leverages init accessors 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.