Choosing the Right Access Pattern
If you've ever struggled deciding whether your type should expose data through properties or indexers, you're not alone. Both mechanisms provide controlled access to internal data, but they serve different purposes and communicate different intents to API consumers.
Properties work best for named, distinct values that belong to an object. Indexers shine when your type represents a collection or mapping where users naturally think in terms of keys or positions. Getting this choice right makes your API intuitive and self-documenting.
You'll learn when each pattern fits, how to implement both correctly, and where they overlap. By the end, you'll recognize the signals in your design that point to one approach over the other.
Properties for Named Values
Properties represent named characteristics of an object. When someone uses your type, they expect properties to describe what the object is or has. Each property has a distinct name that conveys its purpose.
Modern C# properties support auto-implementation, expression bodies, and init-only setters. These features make it easy to expose data with appropriate encapsulation while keeping your code concise.
namespace ProductCatalog;
public class Product
{
// Auto-implemented properties
public int Id { get; init; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public int StockCount { get; set; }
// Computed property with expression body
public bool InStock => StockCount > 0;
// Property with validation in setter
private decimal _discountPercent;
public decimal DiscountPercent
{
get => _discountPercent;
set
{
if (value < 0 || value > 100)
throw new ArgumentOutOfRangeException(nameof(value));
_discountPercent = value;
}
}
// Computed property combining other properties
public decimal FinalPrice => Price * (1 - DiscountPercent / 100);
}
Each property represents a distinct aspect of a product. Users access them by name: product.Name, product.Price. This naming makes the API self-documenting and discoverable through IntelliSense.
Indexers for Collection-Like Access
Indexers let you treat objects like arrays or dictionaries using bracket notation. When your type wraps or represents a collection of values, indexers make the API natural and concise. Users don't need to remember property names for values they access by key or position.
The indexer syntax uses this[type parameter] and supports get and set accessors just like properties. You can implement read-only indexers, write-only ones, or both directions.
namespace MathLib;
public class Matrix
{
private readonly double[,] _data;
public int Rows { get; }
public int Cols { get; }
public Matrix(int rows, int cols)
{
Rows = rows;
Cols = cols;
_data = new double[rows, cols];
}
// Indexer for element access
public double this[int row, int col]
{
get
{
if (row < 0 || row >= Rows || col < 0 || col >= Cols)
throw new IndexOutOfRangeException();
return _data[row, col];
}
set
{
if (row < 0 || row >= Rows || col < 0 || col >= Cols)
throw new IndexOutOfRangeException();
_data[row, col] = value;
}
}
}
Users access matrix elements with matrix[0, 2] instead of calling methods like GetElement(0, 2). The bracket syntax matches how people mentally model matrices and makes the code read naturally.
String-Keyed Indexers for Dictionaries
Indexers aren't limited to integer indices. When your type behaves like a string-keyed dictionary, a string indexer makes the API feel like built-in collection types. This works well for configuration stores, property bags, and lookup tables.
You can combine indexers with underlying Dictionary or custom storage. The indexer provides controlled access while hiding implementation details from consumers.
namespace AppConfig;
public class Settings
{
private readonly Dictionary<string, string> _values = new();
// String-keyed indexer
public string? this[string key]
{
get => _values.TryGetValue(key, out var value) ? value : null;
set
{
ArgumentNullException.ThrowIfNull(key);
if (value == null)
_values.Remove(key);
else
_values[key] = value;
}
}
public bool ContainsKey(string key) => _values.ContainsKey(key);
public int Count => _values.Count;
}
This Settings class exposes a dictionary-like interface through the indexer. Users write settings["theme"] = "dark" which feels natural for configuration access. The indexer handles null values gracefully by removing entries.
Overloading Indexers by Type
You can provide multiple indexers with different parameter types, letting users access data in whatever way makes sense for their scenario. This flexibility improves usability when your type supports different lookup strategies.
Each indexer overload needs a unique signature based on parameter types. Common patterns include offering both positional and key-based access to the same underlying data.
namespace TeamManagement;
public class Roster
{
private readonly List<Player> _players = new();
// Positional indexer
public Player this[int index]
{
get => _players[index];
set => _players[index] = value;
}
// String-based indexer for lookup by name
public Player? this[string name]
{
get => _players.FirstOrDefault(p =>
p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
set
{
var existing = _players.FirstOrDefault(p =>
p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (existing != null && value != null)
{
var index = _players.IndexOf(existing);
_players[index] = value;
}
}
}
public void Add(Player player) => _players.Add(player);
public int Count => _players.Count;
}
public record Player(string Name, int Number, string Position);
The Roster class supports both roster[0] for positional access and roster["Smith"] for name-based lookup. Users pick whichever makes sense in context without needing separate methods for each access pattern.
Choosing the Right Approach
Choose properties when each value has a distinct semantic meaning. If consumers need to know what they're getting by name, properties provide clarity. Use properties for configuration values, object state, and calculated fields.
Choose indexers when your type represents a collection or mapping. If users think of your object as containing multiple values accessed by key or position, indexers match that mental model. Indexers work well for matrices, custom collections, lookup tables, and wrappers around dictionaries or arrays.
Sometimes you'll use both. A collection type might expose a Count property while providing indexers for element access. The key is matching the access pattern to how users conceptualize your type's data.
If unsure, ask whether users would naturally say "get the X" (property) or "get the item at X" (indexer). Properties name things; indexers locate things within a container.
Try It Yourself
Build a simple grade book that demonstrates both properties and indexers working together. You'll see how properties describe the object while indexers provide collection-like access to student grades.
Steps
- Create a new console project:
dotnet new console -n GradeBookDemo
- Navigate to the directory:
cd GradeBookDemo
- Replace Program.cs with the code below
- Update the project file as shown
- Execute with
dotnet run
var gradeBook = new GradeBook("CS101");
gradeBook["Alice"] = 95;
gradeBook["Bob"] = 87;
gradeBook["Carol"] = 92;
Console.WriteLine($"Course: {gradeBook.CourseName}");
Console.WriteLine($"Students: {gradeBook.Count}");
Console.WriteLine($"Average: {gradeBook.Average:F1}");
Console.WriteLine($"Alice's grade: {gradeBook["Alice"]}");
class GradeBook
{
private readonly Dictionary<string, int> _grades = new();
// Properties describe the grade book
public string CourseName { get; init; }
public int Count => _grades.Count;
public double Average => _grades.Count > 0
? _grades.Values.Average()
: 0;
public GradeBook(string courseName)
{
CourseName = courseName;
}
// Indexer provides collection-like access
public int this[string studentName]
{
get => _grades.TryGetValue(studentName, out var grade) ? grade : 0;
set => _grades[studentName] = value;
}
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Output
Course: CS101
Students: 3
Average: 91.3
Alice's grade: 95
Notice how CourseName, Count, and Average are properties describing the grade book, while the string indexer provides natural access to individual student grades.