C# init-only Properties
init-only properties let you set a property while an object is being created, then make that property effectively read-only afterward. They are especially useful for records, value objects, configuration objects, and data transfer models where you want clear initialization syntax without leaving the object freely mutable forever. The result is a practical middle ground between ordinary set properties and constructor-only immutable types.
Overview: How init-only Properties Work
An init-only property looks much like an auto-property with get and set, but the setter is replaced with init. Code may assign the property in an object initializer, inside a constructor of the same type, or inside another init accessor during initialization. After that initialization phase ends, normal code cannot assign the property again.
This feature was added to make immutable objects easier to write. Before init, you often had to choose between a constructor with many parameters or public setters that could be changed at any time. Constructors are still excellent when values are truly required and positional, but object initializers are easier to read when a type has many named options. With init, you can write new Product { Name = "Notebook", Price = 6.50m } and still prevent product.Price = 7.00m later.
Under the hood, an init accessor is compiled as a special setter with metadata that tells the C# compiler it may only be called during object initialization. The CLR stores the property value just like any other property backing field; the restriction is enforced primarily by the compiler. That means init is about source-level immutability, not a magic runtime freeze of the whole object graph. If an init property refers to a mutable object, such as a list, the reference cannot be replaced after initialization, but the list itself may still be modified unless you choose an immutable collection or expose a read-only view.
init-only properties work with classes, structs, records, and record structs. They are very common in records because records already emphasize value-like modeling and support with expressions, which create a copy with selected properties changed. A with expression is not mutating the old object; it creates a new object and assigns init properties during the copy initialization process.
Syntax
public class TypeName
{
public PropertyType PropertyName { get; init; }
}
| Part | Meaning |
|---|---|
get |
Allows code to read the property value. |
init |
Allows assignment only during initialization, not during ordinary later code. |
| Object initializer | The usual place where callers assign init properties. |
required |
Optional modifier that forces callers to initialize the property before construction is complete. |
You may combine init with auto-properties, backing fields, validation logic, default values, nullable annotations, and required. If a property must always have a caller-provided value, required is often a good companion.
Examples
A simple immutable class shape
using System;
public class Product
{
public string Name { get; init; } = "";
public decimal Price { get; init; }
}
public class Program
{
public static void Main()
{
Product product = new Product
{
Name = "Notebook",
Price = 6.50m
};
Console.WriteLine($"{product.Name}: ${product.Price}");
}
}
Output:
Notebook: $6.50
The object initializer assigns Name and Price while the Product is being created. After the semicolon that completes the assignment to product, those properties cannot be assigned again by ordinary C# code. The default value for Name prevents a nullable warning if a caller omits it.
Records and with expressions
using System;
public readonly record struct Money(decimal Amount, string Currency);
public record Invoice
{
public string Number { get; init; } = "";
public Money Total { get; init; }
}
public class Program
{
public static void Main()
{
Invoice original = new Invoice
{
Number = "A100",
Total = new Money(19.99m, "USD")
};
Invoice revised = original with
{
Total = new Money(24.99m, "USD")
};
Console.WriteLine($"{original.Number} {original.Total.Currency} {original.Total.Amount}");
Console.WriteLine($"{revised.Number} {revised.Total.Currency} {revised.Total.Amount}");
Console.WriteLine($"Original still {original.Total.Currency} {original.Total.Amount}");
}
}
Output:
A100 USD 19.99
A100 USD 24.99
Original still USD 19.99
The with expression copies original and changes only Total on the new record. This is the typical immutable update pattern: keep existing objects stable and create a new value for the changed state. The readonly record struct Money is a compact value type whose positional properties are init-only by design.
Validation inside an init accessor
using System;
public class UserProfile
{
private string _displayName = "";
public required string DisplayName
{
get { return _displayName; }
init
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Display name is required.");
}
_displayName = value.Trim();
}
}
public int Id { get; init; }
}
public class Program
{
public static void Main()
{
UserProfile profile = new UserProfile
{
DisplayName = " Ava Stone ",
Id = 42
};
Console.WriteLine($"{profile.DisplayName} (#{profile.Id})");
}
}
Output:
Ava Stone (#42)
An init accessor can contain logic just like a set accessor. Here it trims the input and rejects blank names. The required modifier tells the compiler that callers must initialize DisplayName, which is stronger than only giving it a non-nullable type.
How it Works Step by Step
- The constructor runs first, including field initializers and property initializers.
- The object initializer then assigns the listed properties. During this phase,
initaccessors are callable. - The assignment expression finishes, and the object is now outside its initialization phase.
- Later code can read the properties, but assignment to
init-only properties is a compile-time error.
For records, a with expression follows a similar idea. The compiler creates a copy, then applies the object-initializer-style assignments to the copy. Because the assignments happen during initialization of the new object, init properties can be changed on that new object even though the original object remains unchanged.
Common Mistakes
Trying to assign after construction
Product product = new Product { Name = "Notebook", Price = 6.50m };
product.Price = 7.00m; // Error: init-only property can only be assigned during initialization.
This does not compile because Price is no longer in its initialization phase. If the value needs to change often, use a normal set property. If the type represents a value, create a new value instead.
using System;
public record Settings
{
public bool DarkMode { get; init; }
public int PageSize { get; init; } = 20;
}
public class Program
{
public static void Main()
{
Settings first = new Settings { DarkMode = true };
Settings second = first with { PageSize = 50 };
Console.WriteLine(first.PageSize);
Console.WriteLine(second.PageSize);
}
}
Output:
20
50
Confusing reference immutability with object immutability
using System;
using System.Collections.Generic;
public class Team
{
public List<string> Members { get; init; } = new List<string>();
}
public class Program
{
public static void Main()
{
Team team = new Team { Members = new List<string> { "Mina" } };
team.Members.Add("Omar");
Console.WriteLine(string.Join(", ", team.Members));
}
}
Output:
Mina, Omar
The Members property cannot be assigned to a different list after initialization, but the list object is still mutable. For stronger immutability, expose IReadOnlyList<T>, copy incoming collections, or use immutable collection types.
Best Practices
- Use
initfor values that should be chosen when the object is created and then remain stable. - Use constructors for small sets of essential values, especially when order is obvious and every value is required.
- Use
requiredwithinitwhen omitting the property would create an invalid object. - Validate in the
initaccessor when a property has rules such as non-empty text or positive numbers. - Prefer records and
withexpressions for immutable data models that need copy-and-change behavior. - Remember that
initprotects the property assignment, not necessarily the object referenced by the property. - Avoid using
initfor state that naturally changes over time, such as a retry count, current balance, or progress value.
Practice Exercises
- Create a
Bookclass withTitle,Author, andYearas init-only properties. Print a formatted line for one book. - Create a
recordnamedApiOptionswithBaseUrlandTimeoutSeconds. Use awithexpression to make a second options object with a different timeout. - Create a
Customerclass with a required init-onlyEmailproperty that rejects blank strings and trims whitespace.
Summary
init-only properties can be assigned during object initialization but not afterward.- They make immutable or mostly immutable objects easier to read and construct.
- The compiler enforces the assignment rule; the underlying value is stored like an ordinary property value.
- Records combine naturally with
initandwithexpressions for copy-and-change workflows. initdoes not automatically make referenced objects immutable, so choose collection and reference types carefully.
