C# indexers: syntax, overloading, the Item property in metadata, implicit Index/Range support, ref returns as in `Span<T>`, dictionary indexer semantics and design guidelines.
An indexer lets callers use array syntax on an object: public T this[int index] { get; set; }. It is a property with parameters, compiled to get_Item/set_Item (the name Item can be changed with [IndexerName]). You can overload indexers by parameter type or count (this[string key], this[int row, int col]), and they can be virtual, abstract or in interfaces, but not static. A type with Length or Count and an int indexer gets implicit ^1 (Index) support; with a Slice(int, int) method it also gets .. (Range). Returning ref T (as Span<T> does) lets callers modify elements in place. Use an indexer only when the type is naturally a collection or lookup and access is cheap.
An indexer lets instances of a type be accessed with array-like syntax: obj[key]. It's a parameterised property — compiled to get_Item/set_Item methods. I'd cover declaring and overloading indexers, how they compile, how they integrate with Index and Range (^1, ..), ref-returning indexers, and when an indexer is or isn't a good API.
public sealed class SeatMap
{
private readonly Seat[] _seats;
private readonly Dictionary<string, int> _byLabel;
public Seat this[int index] => _seats[index]; // by position
public Seat this[string label] => _seats[_byLabel[label]]; // by key (overload)
public Seat this[int row, char column] => _seats[row * Columns + (column - 'A')]; // multiple parameters
public int Columns { get; }
}
var s1 = map[3];
var s2 = map["12C"];
var s3 = map[12, 'C'];
this[...]; can have get, set or init accessors with different accessibility.static (C# has no static indexers), but can be virtual, abstract and part of interfaces (IList<T>, IReadOnlyDictionary<TKey,TValue>).params (rarely a good idea).this[int index] becomes a property named Item with accessors get_Item(int) and set_Item(int, T) (rename with [IndexerName("Seats")] for other .NET languages). Calls map[3] compile to map.get_Item(3). There's no special runtime support — performance is the same as a method call (trivial accessors inline).
^1 is an Index (from end); 1..^1 is a Range. The compiler supports them in two ways:
Explicit indexers taking Index/Range:
public Seat this[Index i] => _seats[i];
public ReadOnlySpan<Seat> this[Range r] => _seats.AsSpan(r);
Implicit (pattern-based) support — no new members needed if the type is countable (has Length or Count) and has an int indexer (for ^) or a Slice(int start, int length) method (for ranges):
var last = list[^1]; // compiled to list[list.Count - 1]
var middle = span[1..^1]; // compiled to span.Slice(1, span.Length - 2)
List<T> gets ^ support implicitly but not ranges (no Slice; use GetRange or CollectionsMarshal.AsSpan(list)[r]). Arrays support ranges by copying (RuntimeHelpers.GetSubArray), spans by slicing (no copy). string[1..3] creates a substring.
ref-returning indexerspublic sealed class Grid<T>(int rows, int cols)
{
private readonly T[] _data = new T[rows * cols];
public ref T this[int r, int c] => ref _data[r * cols + c];
}
grid[2, 3] = value; // writes through the reference
grid[2, 3].Health -= 10; // mutates a STRUCT element in place - no copy
ref var cell = ref grid[2, 3];
Returning ref T allows in-place updates of struct elements without copying — this is how Span<T>'s indexer works. A ref indexer can't have a set accessor (assignment goes through the ref). Use ref readonly to expose without allowing writes and without copying large structs.
obj[key] reads naturally (custom collections, grids, matrices, buffers, configuration sections like IConfiguration["Logging:LogLevel:Default"]).customers[id]) hides I/O; use FindAsync(id).report[3] mean? A named method is clearer.TryGetValue.async.A configuration helper exposed settings[string key] that fetched from Azure App Configuration on every access. A hot path read settings["Pricing:Margin"] per line item, adding network calls and throttling. We replaced it with the options pattern (IOptionsMonitor<PricingOptions>) backed by the App Configuration provider with refresh, and kept an indexer only on a local, in-memory FeatureFlagSet where lookups were O(1).
Checks knowledge of indexer mechanics and API judgement.
Green flags: syntax and overloading; Item/IndexerName; implicit Index/Range pattern; ref returns and the List<T> copy issue; dictionary get/set semantics; avoids I/O in indexers.
Red flags: thinks indexers can be static; uses indexers for expensive lookups; unaware of KeyNotFoundException and overwrite semantics.
Indexers are properties named Item (by default) with parameters. The containing type gets [DefaultMember("Item")], which tells VB.NET and reflection which property is the default (indexer). Type.GetProperty("Item", returnType, new[] { typeof(int) }) finds it via reflection. [IndexerName("Chars")] is what string uses (get_Chars).
For a countable type T with int Count and this[int]:
var x = items[^2];
// lowered
var tmp = items; var x = tmp[tmp.Count - 2];
For ranges with a Slice(int, int) method:
var s = buffer[2..^1];
// lowered
var tmp = buffer; int len = tmp.Length; int start = 2; int end = len - 1;
var s = tmp.Slice(start, end - start);
Arrays and strings are special-cased (GetSubArray, Substring). If your custom collection exposes a cheap Slice, ranges become free views; if you write an indexer taking Range that copies, document it.
Index and Range typesIndex is a struct storing a value and an IsFromEnd flag; GetOffset(length) converts to an absolute position.Range holds Start and End Indexes; GetOffsetAndLength(length) returns the absolute start and length and throws if out of bounds.^0 means "one past the end" — valid for ranges (..^0 = everything) but out of range for indexing.A ref indexer returns a managed pointer into storage. Callers must not hold it across operations that reallocate the storage (e.g. resizing an internal array), or they'll write to stale memory — the same caveat as CollectionsMarshal.AsSpan. Ref safety rules prevent returning refs to locals. Ref returns also can't be used across await.
| Type | Indexer semantics on missing key/index |
|---|---|
T[], List<T>, Span<T> |
Throws IndexOutOfRangeException / ArgumentOutOfRangeException |
Dictionary<K,V> get |
Throws KeyNotFoundException (use TryGetValue) |
Dictionary<K,V> set |
Adds or overwrites |
IConfiguration[key] |
Returns null if missing |
NameValueCollection[key] |
Returns null |
ConcurrentDictionary get |
Throws; use TryGetValue/GetOrAdd |
Follow the closest BCL analogue for your own types and provide TryGetValue when absence is normal.
Trivial indexers inline like properties. For custom collections in hot loops, expose a Span<T>/ReadOnlySpan<T> (AsSpan()) so callers can iterate with bounds-check elimination, rather than calling an indexer that delegates through several layers.
Count and this[int] to a custom collection and use ^1 without declaring an Index indexer.Slice(int, int) method and use [1..^1].ref-returning indexer on a struct grid and mutate elements in place.get_Item and DefaultMemberAttribute.public sealed class RingBuffer<T>(int capacity)
{
private readonly T[] _items = new T[capacity];
private int _start, _count;
public int Count => _count; // enables implicit ^ index
public ref T this[int index] // ref return: modify in place
{
get
{
ArgumentOutOfRangeException.ThrowIfNegative(index);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, _count);
return ref _items[(_start + index) % _items.Length];
}
}
public T[] Slice(int start, int length) // enables implicit .. range
{
var result = new T[length];
for (int i = 0; i < length; i++) result[i] = this[start + i];
return result;
}
public void Add(T item)
{
_items[(_start + _count) % _items.Length] = item;
if (_count < _items.Length) _count++; else _start = (_start + 1) % _items.Length;
}
}
var buf = new RingBuffer<int>(4);
foreach (var i in Enumerable.Range(1, 6)) buf.Add(i); // holds 3,4,5,6
Console.WriteLine(buf[^1]); // 6 (implicit Index)
int[] middle = buf[1..^1]; // [4, 5] (implicit Range via Slice)
buf[0] = 100; // writes through the ref
// Overloaded indexers
public sealed class Row(string[] names, object?[] values)
{
public object? this[int ordinal] => values[ordinal];
public object? this[string name] => values[Array.IndexOf(names, name) is var i and >= 0
? i : throw new KeyNotFoundException(name)];
}
What happens: repository[id] or settings[key] performs network or database calls.
Why it is wrong: looks cheap, gets used in loops; can't be async.
Do instead: use explicit async methods like FindAsync.
What happens: report[3] returns something callers can't guess.
Why it is wrong: poor readability.
Do instead: use descriptive methods unless the type is obviously a collection or map.
What happens: indexers silently return defaults in one type and throw in another.
Why it is wrong: callers make wrong assumptions.
Do instead: follow BCL conventions and provide TryGetValue.
What happens: array[1..] used in hot loops.
Why it is wrong: array ranges allocate copies.
Do instead: use array.AsSpan(1..).
^0 as "last element"What happens: items[^0].
Why it is wrong: ^0 is one past the end — out of range.
Do instead: use ^1 for the last element.
What happens: a ref to an element is kept while the collection grows.
Why it is wrong: the ref points to old storage; writes are lost.
Do instead: use refs immediately and don't resize while holding them.
What happens: this[int] and this[long] or this[object].
Why it is wrong: surprising overload resolution.
Do instead: keep overloads clearly distinct (e.g. position vs string key).
What happens: grid[r, c].Value = 5 with a non-ref indexer returning a struct.
Why it is wrong: compile error or a copy is modified.
Do instead: use a ref-returning indexer or replace the whole element.
Seniors use indexers for collection-like types with cheap access, and pair them with Try methods.
| Situation | Senior choice |
|---|---|
| Custom collection, buffer or matrix | Indexer, plus Count for ^ support |
| Large structs in a buffer | ref or ref readonly indexer to avoid copies |
| Lookup that may miss | Indexer that throws plus TryGetValue |
| Remote or expensive lookup | Named async method |
| Multi-dimensional data | Multi-parameter indexer over a flat array |
They know the lowering rules for ^ and .., and they watch for hidden allocations, such as string[1..^1] creating a new string where a span slice would not.
A property looks like a field to callers but compiles to get_X/set_X methods, so you can add validation, change storage or compute values without changing callers. Auto-properties (public string Name { get; set; }) get a compiler-generated private backing field. init (C# 9) allows setting only during object creation; required (C# 11) forces callers to set the member in the object initialiser. The C# 14 field keyword lets an accessor use the generated backing field, so set => field = value.Trim(); no longer needs a hand-written field. Use properties for public state, private fields for internal storage, and methods for work that is slow, has side effects or may fail.
Use TryGetValue for reads: one hash lookup, no exception. ContainsKey then dict[key] hashes twice. Add throws ArgumentException on a duplicate key; the indexer setter silently overwrites; TryAdd returns false instead of throwing. The indexer getter throws KeyNotFoundException; GetValueOrDefault returns a default. Pass a comparer such as StringComparer.OrdinalIgnoreCase for case-insensitive keys. For counters and aggregations, CollectionsMarshal.GetValueRefOrAddDefault gives a ref to the value slot so you update it with one lookup. Enumeration order is not guaranteed; use OrderedDictionary<TKey,TValue> (.NET 9+) or SortedDictionary when order matters.
A struct is a value type: a variable holds the data itself, assignment and parameter passing copy it, and it is stored inline (in a local, a field or an array element) rather than as a separate heap object. Structs cannot inherit (they are sealed) but can implement interfaces. default(T) always gives all-zero fields, even when you write a parameterless constructor (allowed since C# 10). readonly struct guarantees immutability and avoids defensive copies. ref struct (like Span<T>) can live only on the stack; since C# 13 it can implement interfaces and be used with allows ref struct generics. Converting a struct to object or an interface boxes it. Mutable structs cause lost updates because you often change a copy.
How to tell it (STAR)
**Situation:** our product listing became [seven times] slower after a small feature-flag change. **Task:** find the cause and fix the API so it cannot happen again. **Action:** tracing showed an indexer making an HTTP call per product. I replaced it with an async method backed by a per-request snapshot and added a guideline and test against I/O in indexers. **Result:** latency returned to about [120 ms], and the flag service stopped rate-limiting us.
Last updated 30 Oct 2026.