📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What are indexers in C#? Explain how to declare and overload them, how they compile, how they work with Index and Range (^1 and ..), ref-returning indexers, and when an indexer is or is not a good API.

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.

Mid-Level Theory Intermediate api-design csharp index-range indexers oop ref-returns

🎯 Key points

  • An indexer is a parameterised property declared with this[...] and compiled to get_Item/set_Item
  • Indexers can be overloaded by parameter list and can be virtual, abstract or in interfaces, but not static
  • [IndexerName] changes the metadata name from Item
  • A type with Count or Length and an int indexer gets implicit ^ support; adding Slice gives .. support
  • ref-returning indexers (as in `Span<T>`) let callers modify elements in place without copies
  • `List<T>`'s indexer returns a copy, so modifying a struct element through it does not compile
  • Dictionary indexer throws on a missing key and overwrites on set; use TryGetValue and TryAdd
  • Use indexers only for cheap, collection-like access with obvious meaning

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Declaring and overloading

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'];
  • Declared with this[...]; can have get, set or init accessors with different accessibility.
  • Overloads must differ in parameter types/count (return type alone isn't enough).
  • Indexers can't be static (C# has no static indexers), but can be virtual, abstract and part of interfaces (IList<T>, IReadOnlyDictionary<TKey,TValue>).
  • Parameters can have defaults or params (rarely a good idea).

2. How they compile

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).

3. Index and Range support (C# 8)

^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.

4. ref-returning indexers

public 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.

5. When an indexer is a good API

  • The type is genuinely a collection or map where obj[key] reads naturally (custom collections, grids, matrices, buffers, configuration sections like IConfiguration["Logging:LogLevel:Default"]).
  • Access is cheap (O(1) or O(log n)) and has no significant side effects.
  • Semantics are obvious: what happens for a missing key is clear and consistent with BCL conventions.

6. When it isn't

  • Expensive operations — an indexer that queries a database (customers[id]) hides I/O; use FindAsync(id).
  • Unclear semantics — what does report[3] mean? A named method is clearer.
  • Missing-key behaviour that surprises — throwing vs returning default; for maps, also offer TryGetValue.
  • Indexers that create items on read (auto-adding defaults) unless documented, as with some caching APIs.
  • Async isn't possible — indexers can't be async.

7. A production example

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).

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Metadata

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).

2. Pattern-based Index/Range lowering

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.

3. Index and Range types

  • Index 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.

4. Ref returns and safety

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.

5. Indexers in BCL design

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.

6. Performance

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.

Try it yourself

  1. Add Count and this[int] to a custom collection and use ^1 without declaring an Index indexer.
  2. Add a Slice(int, int) method and use [1..^1].
  3. Implement a ref-returning indexer on a struct grid and mutate elements in place.
  4. Decompile and find get_Item and DefaultMemberAttribute.

💻 Code

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)];
}

⚠ Common mistakes

1. Indexers that hide I/O

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.

2. Unclear indexer semantics

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.

3. Inconsistent missing-key behaviour

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.

4. Assuming array ranges are views

What happens: array[1..] used in hot loops. Why it is wrong: array ranges allocate copies. Do instead: use array.AsSpan(1..).

5. Using ^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.

6. Holding refs from ref-returning indexers across resizes

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.

7. Overloading indexers ambiguously

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).

8. Mutating struct elements through value-returning indexers

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says indexers are parameterised properties Why: Checks property mechanics
    How to answer

    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.

  2. When: Candidate mentions dictionary indexer semantics Why: Probes Dictionary API choices (TryGetValue, TryAdd)
    How to answer

    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.

  3. When: Candidate mentions ref-returning indexers on spans Why: Checks struct copying and ref structs
    How to answer

    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.

🛠 Real-world problems: how you handle them

Per-item HTTP calls hidden behind an indexer
Situation
A product listing page used a feature-flag client with an indexer `flags[name]` that called the flag service over HTTP.
Signals
Product page latency rose from [120 ms] to [900 ms] after a new per-product flag check was added; the flag service rate-limited the app.
Investigation
Distributed tracing showed [200] HTTP calls per page, one per product, all from `FeatureFlags.get_Item`.
Resolution
Replaced the indexer with `IsEnabledAsync(name, ct)` backed by a per-request snapshot loaded once, and moved the check out of the loop.
Prevention
API guideline: no I/O behind indexers or property getters; added a trace-based test that fails if a page makes more than [5] outgoing calls.

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.

More in C# OOP Features

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me