Properties
Properties are how C# classes expose data. To the caller they look like fields — person.Age = 30 — but behind the scenes they run code, so the class stays in control: it can validate new values, compute values on the fly, or make data read-only. Public data in C# is almost always a property, never a public field.
Auto-implemented properties
The simplest property has an empty get and set. The compiler creates a hidden field to store the value:
var p = new Product { Name = "Kettle", Price = 29.99m }; p.Price = 24.99m; Console.WriteLine($"{p.Name}: {p.Price}"); class Product { public string Name { get; set; } = ""; public decimal Price { get; set; } }
Kettle: 24.99
= "" gives the property an initial value. Why not just use a public field? Because a property can later gain validation or logic without changing how callers use it, and much of .NET (data binding, JSON serialisation, ORMs) works with properties, not fields.
Read-only and init-only properties
Leave out the setter, make it private, or use init to control who can change a value:
var order = new Order("A-1001") { Customer = "Lin" }; order.AddItem(15m); order.AddItem(7.5m); Console.WriteLine($"{order.Id} for {order.Customer}: {order.Total} ({order.ItemCount} items)"); class Order(string id) { public string Id { get; } = id; // read-only: set only in the constructor public string Customer { get; init; } = ""; // settable only during initialisation public decimal Total { get; private set; } // read publicly, written only inside the class public int ItemCount { get; private set; } public void AddItem(decimal price) { Total += price; ItemCount++; } }
A-1001 for Lin: 22.5 (2 items)
var order = new Order { Customer = "Lin" }; order.Customer = "Someone else"; order.Total = 0; class Order { public string Customer { get; init; } = ""; public decimal Total { get; private set; } }
Program.cs(2,1): error CS8852: Init-only property or indexer 'Order.Customer' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor. Program.cs(3,1): error CS0272: The property or indexer 'Order.Total' cannot be used in this context because the set accessor is inaccessible
Computed properties
A property with only a getter can calculate its value each time it's read. Use => for a one-line getter:
var r = new Rectangle { Width = 4, Height = 3 }; Console.WriteLine($"{r.Area} {r.Perimeter} {r.IsSquare}"); r.Width = 3; Console.WriteLine($"{r.Area} {r.Perimeter} {r.IsSquare}"); class Rectangle { public double Width { get; set; } public double Height { get; set; } public double Area => Width * Height; public double Perimeter => 2 * (Width + Height); public bool IsSquare => Width == Height; }
12 14 False 9 12 True
Computed properties are always up to date — there's no stored copy that could get out of sync. Keep them cheap; if a calculation is expensive, a method (CalculateTax()) signals that better.
Validation with the field keyword
To check values in a setter you need somewhere to store them. C# 14 adds the field keyword, which refers to the property's compiler-generated storage, so you can add logic without declaring a separate field:
var player = new Player { Name = " Zed " }; player.Health = 80; player.Health += 50; // clamped to 100 Console.WriteLine($"{player.Name}: {player.Health}"); player.Name = ""; class Player { public string Name { get; set => field = string.IsNullOrWhiteSpace(value) ? throw new ArgumentException("Name cannot be empty.") : value.Trim(); } = "Player"; public int Health { get; set => field = Math.Clamp(value, 0, 100); } = 100; }
Zed: 100 Unhandled exception. System.ArgumentException: Name cannot be empty.
Inside a setter, value is the incoming value. Before C# 14 you'd write the same thing with an explicit backing field, a pattern you'll see in a lot of existing code:
var t = new Thermostat(); t.Target = 35; Console.WriteLine(t.Target); class Thermostat { private double _target = 20; public double Target { get => _target; set => _target = Math.Clamp(value, 5, 30); } }
30
Properties that notify or record
Because setters are code, they can do more than store a value — for example keep a history:
var doc = new Document(); doc.Title = "Draft"; doc.Title = "Final"; doc.Title = "Final v2"; Console.WriteLine(doc.Title); Console.WriteLine(string.Join(" -> ", doc.TitleHistory)); class Document { private readonly List<string> _history = []; public IReadOnlyList<string> TitleHistory => _history; public string Title { get; set { if (field != value) { _history.Add(value); field = value; } } } = ""; }
Final v2 Draft -> Final -> Final v2
IReadOnlyList<string> lets callers read the history without being able to add to it — the list itself stays private.
Indexers
An indexer is a property that takes an index, letting objects be used with square brackets like arrays and dictionaries:
var week = new WeekPlan(); week["Mon"] = "Gym"; week["Wed"] = "Swim"; Console.WriteLine(week["Mon"]); Console.WriteLine(week["Tue"]); class WeekPlan { private readonly Dictionary<string, string> _plans = []; public string this[string day] { get => _plans.GetValueOrDefault(day, "(rest day)"); set => _plans[day] = value; } }
Gym (rest day)
Property cheat sheet
| Declaration | Meaning |
|---|---|
{ get; set; } | Read and write from anywhere |
{ get; private set; } | Read anywhere, write only inside the class |
{ get; init; } | Write only during construction or an object initialiser |
{ get; } | Read-only; set in the constructor or initialiser |
=> expression | Computed every time it's read |
required | Callers must set it when creating the object |
set => field = … | Custom logic with compiler-generated storage (C# 14) |
Try it yourself
Add a Percentage property to ProgressBar that's computed from Current and Max, and make Current stay between 0 and Max.
[#####...............] 25% [####################] 100%