C# & .NET (C# 14, .NET 10 LTS) Module 6 – Classes, objects and encapsulation
Properties, indexers and the field keyword
Write C# auto-properties and init-only and computed properties, put a rule into a setter with the C# 14 field keyword, and give a type its own indexer.
What you will learn
- Write auto-properties and get-only, init-only and computed properties, and choose the one that fits
- Validate and normalise a value in a setter with the C# 14 field keyword
- Add indexers that reach a type's items by position or by a key
- Diagnose the warnings and silent changes that field-backed properties can bring
- Design setters and indexers that refuse bad values and report only real changes
Before you start
What you’ll learn · Before you start
What you will learn
- Write auto-properties and get-only, init-only and computed properties, and choose the one that fits
- Validate and normalise a value in a setter with the C# 14 field keyword
- Add indexers that reach a type's items by position or by a key
- Diagnose the warnings and silent changes that field-backed properties can bring
- Design setters and indexers that refuse bad values and report only real changes
Before you start
On this page
A Name that is a public field accepts " Ravi Iyer" with its spaces and null as well, and every caller has to
remember to clean it. Made a property, Name cleans and checks each value itself, while callers still write
person.Name = … as if it were a field.
You will write the kinds of property C# offers, add a rule to a setter with C# 14’s field keyword, and give a type
an indexer so that callers can write hall["C7"].
A property looks like a field and runs like a method
course.Room = "C-110"; looks like an assignment to a field, but Room is a property: the assignment calls its set
accessor, and reading course.Room calls its get accessor. Most accessors need no code. { get; set; } is an
auto-property: A property whose accessors have no body, such as { get; set; }. The compiler declares a hidden backing field and writes accessors that only read and store the value.: the compiler declares a hidden
backing field: The private field that stores a property's value. For an auto-property the compiler declares it, and from C# 14 an accessor can name it with the field keyword. and writes both accessors for you. kinds.cs gives one class five
kinds of property:
// Five kinds of property on one class, and what code outside the class may do with each.
var course = new Course("CS101", capacity: 60) { Title = "Programming in C#" };
course.Room = "B-204";
course.Enrol(45);
Console.WriteLine($"{course.Code} {course.Title}, room {course.Room}: {course.Enrolled} of {course.Capacity}");
Console.WriteLine($"seats left: {course.SeatsLeft}");
course.Room = "C-110";
course.Enrol(10);
Console.WriteLine($"moved to {course.Room}: {course.Enrolled} enrolled, {course.SeatsLeft} seats left");
class Course
{
public Course(string code, int capacity)
{
Code = code;
Capacity = capacity;
}
// Get-only: the constructor sets it once.
public string Code { get; }
public int Capacity { get; }
// Init-only: an object initializer sets it, and nothing can change it afterwards.
public required string Title { get; init; }
// Read-write: any code may read it and assign it.
public string? Room { get; set; }
// Readable everywhere, assigned only by code inside the class.
public int Enrolled { get; private set; }
// Computed: nothing is stored; the value is worked out on every read.
public int SeatsLeft => Capacity - Enrolled;
public void Enrol(int students) => Enrolled += students;
} Output
CS101 Programming in C#, room B-204: 45 of 60 seats left: 15 moved to C-110: 55 enrolled, 5 seats left
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run kinds.cs
What each declaration lets the code outside the class do:
| Declared as | Outside code may | In kinds.cs |
|---|---|---|
{ get; } |
read it; a constructor sets it | Code, Capacity |
{ get; init; } |
read it, and set it in an object initializer | Title |
{ get; set; } |
read and assign it | Room |
{ get; private set; } |
read it; the class assigns it | Enrolled |
=> expression |
read a value worked out on each read | SeatsLeft |
SeatsLeft is a computed property: A property whose get accessor works out its value on every read, such as int SeatsLeft => Capacity - Enrolled. It stores nothing of its own.: it stores nothing, and it gave 15 after the
first enrolment and 5 after the second, because each read subtracts Enrolled from Capacity again. A get-only
property stops callers from storing another value in it, not from changing the object it returns: a List<string>
behind { get; } still accepts Add, so expose IReadOnlyList<string> when callers should only read the items.
Checkpoint
Check: Kinds of property and what callers may do
The field keyword puts a rule into one accessor
A name should be stored without the spaces people type around it, and never as null. Before C# 14 that took a
private field declared by hand and both accessors written out. The field keyword of C# 14 names the compiler’s
backing field inside an accessor, so you write the accessor that needs the rule and leave the other as get;:
// The C# 14 field keyword: a setter with a rule, and no backing field to declare.
var person = new Person(" Asha Rao ");
Console.WriteLine($"[{person.Name}]");
person.Name = " Ravi Iyer";
Console.WriteLine($"[{person.Name}]");
try
{
// null! silences the compiler's warning, as code that reads JSON or ignores warnings can.
person.Name = null!;
}
catch (ArgumentNullException e)
{
Console.WriteLine($"{e.GetType().Name}: {e.Message}");
}
Console.WriteLine($"still [{person.Name}]");
class Person
{
// The constructor assigns the property, so its setter trims this value too.
public Person(string name) => Name = name;
public string Name
{
get;
set => field = value?.Trim() ?? throw new ArgumentNullException(nameof(value));
}
} Output
[Asha Rao] [Ravi Iyer] ArgumentNullException: Value cannot be null. (Parameter 'value') still [Ravi Iyer]
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run trim.cs
The constructor assigns Name, so the setter trimmed " Asha Rao " too. When null arrived, the setter threw
ArgumentNullException before storing anything, and Name kept Ravi Iyer. Step through the assignments:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The constructor assigns. new Person(" Asha Rao ") runs the constructor, and its body, Name = name, calls the set accessor with the value as typed, spaces and all.
- Trimmed and stored. value?.Trim() makes "Asha Rao", and field = stores it in the backing field that the compiler declared. The get accessor, written as get;, returns that field, so the program printed [Asha Rao].
- A second assignment. person.Name = " Ravi Iyer" goes through the same accessor, and the backing field now holds "Ravi Iyer" without the leading spaces.
- null is refused. person.Name = null! passes null in. value?.Trim() gives null, so ?? throws ArgumentNullException before field = runs, and the exception travels back to the caller.
- The old value stays. Reading Name afterwards returns "Ravi Iyer": the setter threw before it stored anything, so the property kept its previous value, as the property design guidelines ask.
field works in the get, set and init accessors of properties, not of indexers, and only from C# 14, the
language version of the .NET 10 SDK. In a class that already has a member named field, the word inside an accessor
now means the backing field; write this.field or @field to reach the member.
Key idea
With the field keyword, one accessor holds the rule: it normalises or refuses the value before field = stores
it, so a refused value leaves the old one in place.
Checkpoint
Check: The field keyword in an accessor
An indexer lets callers index your type like an array
A cinema hall of 5 rows of 10 seats is a collection, and callers expect hall[2, 6] or hall["C7"] rather than a
method name. An indexer: A member declared as this[...] that lets code use square brackets on an object, as in hall[2, 6] or hall["C7"]. It is a property whose accessors take parameters. is a property with parameters, declared as this[...]: its
accessors receive the arguments written between the brackets. A type may have several indexers whose parameter lists
differ:
// An indexer lets code use [] on your own type: here by row and seat number, or by a label such as "C7".
var hall = new SeatMap(rows: 5, seatsPerRow: 10);
hall[2, 6] = "Asha";
hall["D1"] = "Ravi";
Console.WriteLine($"C7: {hall["C7"] ?? "free"}");
Console.WriteLine($"[3, 0]: {hall[3, 0] ?? "free"}");
Console.WriteLine($"A1: {hall["A1"] ?? "free"}");
Console.WriteLine($"booked: {hall.Booked} of {hall.Rows * hall.SeatsPerRow}");
class SeatMap
{
private readonly string?[,] _seats;
public SeatMap(int rows, int seatsPerRow) => _seats = new string?[rows, seatsPerRow];
public int Rows => _seats.GetLength(0);
public int SeatsPerRow => _seats.GetLength(1);
public int Booked { get; private set; }
// By position, counting from 0 as arrays do.
public string? this[int row, int seat]
{
get => _seats[row, seat];
set
{
if (_seats[row, seat] is null && value is not null)
{
Booked++;
}
_seats[row, seat] = value;
}
}
// By the label on the seat: a row letter from A and a seat number from 1.
public string? this[string label]
{
get => this[label[0] - 'A', int.Parse(label[1..]) - 1];
set => this[label[0] - 'A', int.Parse(label[1..]) - 1] = value;
}
} Output
C7: Asha [3, 0]: Ravi A1: free booked: 2 of 50
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run seats.cs
hall["D1"] and hall[3, 0] reached the same seat, and Booked counted 2 seats, because the string indexer only
turns a label into two positions and then calls the int indexer:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- A label. The caller writes hall["C7"]. The get accessor of this[string label] runs with label set to "C7".
- Letter to row. label[0] - 'A' subtracts the char codes: C is 2 places after A, so the row is 2, counting from 0.
- Number to seat. label[1..] is "7", and int.Parse(...) - 1 gives 6, because the labels count seats from 1 and the array counts from 0.
- The other indexer. The string indexer then calls this[2, 6], so reading and booking a seat stay written once, in the int indexer.
- The stored name. _seats[2, 6] holds "Asha", booked earlier with hall[2, 6] = "Asha", so the program printed C7: Asha.
Microsoft’s property design guidelines keep indexers for types that hold a logical collection of items, as a seat map does. Unlike other getters, an indexer is expected to throw when its arguments name no item.
Checkpoint
Check: Indexers and their overloads
The field keyword's traps, reproduced: initializers, nulls and old names Pro: opens with a Pro, Premium or Ultimate pass
The field keyword saves a declaration, but three of its rules surprise code that looked finished: what a property
initializer does, when the compiler warns about null, and what field means in code written before C# 14. Each is
reproduced below on the .NET 10 SDK.
In this part:
- A clamping setter and its default: a page size of 500, measured
- CS9264: a string property that stays null, and three fixes
- C# 14 changes what field means in old code: one file built twice
5 runnable examples (C#, Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Setters that clamp, refuse and notify, with every change counted Pro: opens with a Pro, Premium or Ultimate pass
A setter decides what happens to each value it receives: store it, change it or refuse it, and whether anyone is told about the change. The order of those decisions matters, and the run below counts what two orders do with the same six requests.
In this part:
- Clamp first, compare second: two thermostats, six requests, events counted
- What a setter should do with each kind of value: a decision table
1 runnable example (C#) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Fields, properties and indexers across a library boundary Pro: opens with a Pro, Premium or Ultimate pass
Inside one project a public field and a property look the same where they are used. Across a library boundary they are different members, and an indexer’s behaviour for a bad argument becomes a promise to every caller. Both are measured below.
In this part:
- A public field becomes a property: MissingFieldException, reproduced
- Indexers by design: labels checked, exceptions named, Item kept
4 runnable examples (C#, Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: a thermostat with a weekly schedule
thermostat.cs checks a Thermostat that you complete: its TargetCelsius keeps values between 10 and 32 degrees
and refuses NaN, and an indexer, this[DayOfWeek day], holds a target for each day, 21 degrees to start with. The
property and the indexer you start from only throw, so all 8 checks fail:
using System.Globalization;
// Practice: write the TargetCelsius property and the day indexer of Thermostat, then run this file until all eight
// checks pass. A target is kept between 10 and 32 degrees: a lower value becomes 10 and a higher one 32. NaN is not a
// temperature, so it is rejected. Every day of the week starts at 21 degrees.
(string Check, Func<string> Run, string Expected)[] checks =
[
("a new thermostat", () => Text(new Thermostat().TargetCelsius), "21"),
("set to 5", () => Text(new Thermostat { TargetCelsius = 5 }.TargetCelsius), "10"),
("set to 40", () => Text(new Thermostat { TargetCelsius = 40 }.TargetCelsius), "32"),
("set to 22.5", () => Text(new Thermostat { TargetCelsius = 22.5 }.TargetCelsius), "22.5"),
("set to NaN", () => Text(new Thermostat { TargetCelsius = double.NaN }.TargetCelsius), "throws ArgumentException"),
("Monday set to 35", () => Text(Monday(35)), "32"),
("Sunday, never set", () => Text(new Thermostat()[DayOfWeek.Sunday]), "21"),
("day number 7", () => Text(new Thermostat()[(DayOfWeek)7]), "throws ArgumentOutOfRangeException"),
];
int passed = 0;
foreach (var (check, run, expected) in checks)
{
string got;
try
{
got = run();
}
catch (Exception e)
{
got = $"throws {e.GetType().Name}";
}
bool ok = got == expected;
if (ok)
{
passed++;
}
Console.WriteLine(ok ? $"{check}: ok" : $"{check}: FAIL, {got} instead of {expected}");
}
Console.WriteLine($"{passed} of {checks.Length} checks pass");
return passed == checks.Length ? 0 : 1;
static string Text(double celsius) => celsius.ToString(CultureInfo.InvariantCulture);
static double Monday(double celsius)
{
var thermostat = new Thermostat();
thermostat[DayOfWeek.Monday] = celsius;
return thermostat[DayOfWeek.Monday];
}
class Thermostat
{
public double TargetCelsius
{
get => throw new NotImplementedException();
set => throw new NotImplementedException();
}
public double this[DayOfWeek day]
{
get => throw new NotImplementedException();
set => throw new NotImplementedException();
}
} Output (exit status 1)
a new thermostat: FAIL, throws NotImplementedException instead of 21 set to 5: FAIL, throws NotImplementedException instead of 10 set to 40: FAIL, throws NotImplementedException instead of 32 set to 22.5: FAIL, throws NotImplementedException instead of 22.5 set to NaN: FAIL, throws NotImplementedException instead of throws ArgumentException Monday set to 35: FAIL, throws NotImplementedException instead of 32 Sunday, never set: FAIL, throws NotImplementedException instead of 21 day number 7: FAIL, throws NotImplementedException instead of throws ArgumentOutOfRangeException 0 of 8 checks pass
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run thermostat.cs
Write the property and the indexer
Copy thermostat.cs to your computer. Give TargetCelsius a get; and a set that throws ArgumentException
when double.IsNaN(value) and otherwise stores Math.Clamp(value, 10, 32) in field, with a default of 21. The
indexer cannot use field: keep a double[] of 7 targets that all start at 21, throw
ArgumentOutOfRangeException when Enum.IsDefined(day) is false, and clamp what it stores the same way. Run
dotnet run thermostat.cs until it prints 8 of 8 checks pass.
Then try the questions on properties, the field keyword and indexers. They start with the idea you are weakest on,
and every answer explains itself.
Practice that adapts to you
Practise this lesson
6 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · Why do C# classes expose properties instead of public fields?
A property reads like a field where it is used, but its accessors are methods, so the class keeps control. A setter
can check or normalise a value, a getter can compute one, and the two can have different access, such as a public
get with a private set. A property can also gain such a rule later without any change to its callers’ source,
while turning a public field into a property changes the member that compiled callers refer to. The Framework design
guidelines therefore allow no public or protected instance fields at all, with constants and static readonly fields
as the exceptions.
-
Fresher to mid
A PageSize property keeps values between 1 and 100 in a field-backed setter, and its declaration ends with = 500. In production a list endpoint returns 500 rows a page. Explain why the rule did not apply, and how you would make it hold for defaults as well as for assigned values.
-
Fresher to mid
An Order class exposes public List<OrderLine> Lines { get; } = []; and a colleague accepts it as read-only. Weeks later a report finds orders whose total does not match their lines, because another service added lines directly. What went wrong, and how would you expose the lines?
-
Fresher to mid
You are designing a SeatMap type for a cinema booking library. Callers need seats by row and seat number and by labels such as "F12". Would you offer indexers, methods or both, and what should happen for a seat that does not exist?
Key takeaways
- A property reads like a field and runs accessors: auto-properties, get-only, init-only, private-set and computed properties each let outside code do something different.
- The field keyword of C# 14 names the compiler’s backing field inside a property accessor, so one accessor can trim,
check or refuse a value while the other stays
get;. - An indexer is a property with parameters,
this[...]; a type may overload it, and one indexer can call another so that the rules live in one place.
Terms in this lesson
- auto-property
- A property whose accessors have no body, such as { get; set; }. The compiler declares a hidden backing field and writes accessors that only read and store the value.
- backing field
- The private field that stores a property's value. For an auto-property the compiler declares it, and from C# 14 an accessor can name it with the field keyword.
- computed property
- A property whose get accessor works out its value on every read, such as int SeatsLeft => Capacity - Enrolled. It stores nothing of its own.
- indexer
- A member declared as this[...] that lets code use square brackets on an object, as in hall[2, 6] or hall["C7"]. It is a property whose accessors take parameters.
References
- Properties (C# programming guide) (Microsoft Learn)
- The field contextual keyword (Microsoft Learn)
- What's new in C# 14 (the field keyword) (Microsoft Learn)
- Feature specification: the field contextual keyword (Microsoft Learn (C# language design))
- C# compiler breaking changes since C# 13 (field in property accessors) (Microsoft Learn)
- Indexers (C# programming guide) (Microsoft Learn)
- The init keyword (Microsoft Learn)
- Property design (Framework Design Guidelines) (Microsoft Learn)
- Field design (Framework Design Guidelines) (Microsoft Learn)
- Double.NaN field (Microsoft Learn)
- Math.Clamp method (Microsoft Learn)
- .NET API changes that affect compatibility (Microsoft Learn)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress