C# Indexers — NullReference Ambush in String-Indexed Stores
Unexpected NullReferenceException from a string-indexed store? Ensure your indexer throws KeyNotFoundException, not null.
20+ years shipping production .NET services in enterprise systems. Everything here is grounded in real deployments.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Indexers let custom C# types support bracket notation (obj[key]) for element access, just like arrays or dictionaries.
- Declared with 'this[parameterType param]' and get/set accessors — a property with a parameter, not a standalone feature.
- Index keys can be int, string, enum, or multiple parameters — overload by signature same as methods.
- Performance: indexers are JIT-inlineable, often zero overhead vs a manual method; validation logic adds runtime cost.
- Production insight: missing null checks or bounds validation in indexers cause confusing crashes deep in internal arrays, not at the call site.
Imagine a library where instead of asking the librarian 'can you get me the book stored in slot number 5?', you just walk up and grab shelf[5] yourself. An indexer is what lets YOUR custom object behave like that shelf — you define the rules for what happens when someone uses square brackets on it. It's not magic, it's just a special property with a parameter.
Most C# developers discover indexers the moment they start digging into how List<T> or Dictionary<TKey, TValue> actually work under the hood. You use myList[0] every day without a second thought — but that bracket notation isn't some built-in language primitive reserved for arrays. It's a feature you can implement yourself on any class you write. That's what makes indexers genuinely powerful.
Before indexers existed, if you wanted your custom collection or data-wrapper class to support element access, you'd be stuck writing verbose GetItem(int index) and SetItem(int index, T value) methods. Users of your class would have to learn a bespoke API instead of reaching for the intuitive bracket syntax they already know. Indexers solve this by letting you define exactly what obj[key] means for your type — whether the key is an integer, a string, an enum, or anything else.
By the end of this article you'll understand not just how to declare an indexer, but when it's the right design choice, how to build multi-parameter and overloaded indexers, and the subtle bugs that catch even experienced developers off guard. You'll walk away ready to write cleaner APIs and answer indexer questions confidently in a technical interview.
What an Indexer Actually Is (and Why It Beats a Plain Method)
An indexer is a special kind of property that accepts one or more parameters — the index values inside the square brackets. Like a regular property it has a get accessor and an optional set accessor, but instead of using the property name as the access point, callers use bracket notation on the object itself.
The key design motivation is expressiveness. Compare these two lines:
string city = addressBook.GetEntry("Alice"); string city = addressBook["Alice"];
The second line is immediately obvious to anyone who has used a dictionary or array. The first forces the caller to learn your method name. When your class semantically represents a collection or a keyed store of data, an indexer makes your API feel native to the language.
Indexers are declared using the this keyword followed by the parameter list in square brackets — think of this as saying 'when someone indexes into me, here is what to do'. They live on the class just like properties, and they fully support access modifiers, readonly patterns (get-only), and even expression-bodied syntax for simple cases.
String-Keyed Indexers — Modeling a Real Configuration Store
Indexers don't have to use integers. Any type can be an index — and string-keyed indexers are especially common in the real world. ASP.NET's IConfiguration, HttpContext.Items, and ViewData all use string indexers. If you've ever written config["ConnectionStrings:Default"] you've used one.
A string indexer is ideal when your class wraps a keyed data store and you want callers to access values by name rather than position. It communicates intent clearly: this isn't a list, it's a lookup.
The example below models a lightweight application settings store. Notice how we return null for missing keys in the getter rather than throwing — this mirrors the design of Dictionary
Overloaded and Multi-Parameter Indexers — When One Index Isn't Enough
C# lets you overload indexers just like methods — the same class can have multiple indexers as long as their parameter signatures differ. This is genuinely useful when your type can be meaningfully accessed by more than one kind of key.
You can also define indexers that take multiple parameters, separated by commas inside the brackets. This fits naturally for two-dimensional or grid-based data structures where obj[row, col] is far more readable than obj.GetCell(row, col).
The example below models a spreadsheet grid. It exposes both a two-integer indexer for positional access and a string indexer that parses Excel-style cell addresses like "B3". Both indexers share the same underlying storage. This gives callers the flexibility to use whichever style fits their context — and the class looks and feels like a first-class citizen of the language.
Read-Only Indexers and Interface Contracts — The Pattern You'll See in Real Codebases
Not every indexer should allow writes. A read-only indexer (get accessor only) is the right choice when your class exposes data that callers should query but never mutate directly — think of a compiled lookup table, a cached result set, or an immutable view over some underlying data.
Indexers can also be declared in interfaces, which is the key to writing testable, mockable code. When ASP.NET Core defines IHeaderDictionary with a string indexer, any class that implements that interface must provide that indexer. Your own interfaces can do exactly the same thing.
The example below shows a read-only country code lookup paired with an interface. Notice that the interface declares only a getter — implementing classes can add a setter internally if they need it, but callers holding a reference to the interface can't write through it. That's the encapsulation story indexers tell best.
Indexer Performance and Thread Safety — What Senior Engineers Watch For
Indexers are methods under the hood. The JIT can inline simple getters and setters, so for trivial cases they have zero overhead compared to a manually written method. But when you add validation, exception handling, or delegation to another method, the inlining budget shrinks.
More importantly, indexers are NOT inherently thread-safe. If two threads write and read through the same indexer concurrently, you get torn reads, corrupted state, or stale data. A common mistake: assuming that because you're using a collection as backing store, the indexer is safe. Dictionary
This section shows a thread-safe indexer using a ReaderWriterLockSlim for a scenarios where reads are frequent and writes are rare. For high-throughput scenarios, consider using ConcurrentDictionary
Indexers with Access Modifiers — Locking Down Your Virtual Array
You already know indexers behave like virtual arrays. But here's the part most tutorials skip: you can slap access modifiers on the get and set accessors individually. That means you can expose a read-only indexer to the outside world while keeping a private setter for internal mutation.
Why does this matter? Because real production code doesn't trust consumers. Your configuration store might let anyone read a value, but only the initialization routine should write one. Slap private set; on that indexer and move on. No extra methods. No defensive copies. The compiler enforces it.
This isn't just about security. It's about signalling intent. When a junior sees a public get and a private set, they immediately understand: "This is read-mostly data, don't mess with it after construction." That clarity saves hours of debugging later. Don't hide your constraints — encode them in the type system.
protected on the whole indexer) with separate modifiers on get/set. One locks the entire indexer to a visibility scope, the other controls read vs write permissions independently. Mixing them up silently widens your attack surface.Overloaded Indexers — Because One Dimension Isn't Enough
Your competitor pages show you can have multiple indexers on the same class. They don't tell you why you'd want to. Here's the real reason: mapping different key types to the same logical store feels natural to callers. A dictionary-style string indexer for user lookups, an int indexer for positional access — same class, two operations, zero ambiguity.
The compiler disambiguates by signature. Same as overloaded methods. So you can have this[string key] and this[int index] living side by side. The trick is keeping implementations consistent. If both indexers access the same backing store, you'd better make sure this["foo"] and this[0] don't contradict each other. That's a design smell — usually your indexers should map to different logical domains, not the same data with different keys.
Overloaded indexers are rare in the wild because they're easy to abuse. But when you need them (e.g., a matrix class with row/column access vs. flat index), they save you from writing two separate getter methods that do the same thing. Just don't make your team guess which one to call.
Multi-Dimensional Maps — Modeling Lookup Tables Like a Pro
Real-world data rarely fits a single key. You need a row and a column, a product and a region, a user and a timestamp. That's where multi-dimensional indexers come in — they turn your class into a proper matrix or lookup table.
The pattern is dead simple: accept two or more parameters and return a value. Under the hood you can use a nested Dictionary, a 2D array, or a real database connection. The caller never sees that mess — they just write store["EU", "Q3"] and get the number they need. This isn't just syntactic sugar; it's a contract that screams "this thing is a map, not a bag of methods."
Senior engineers reach for this when building configuration grids, permission tables, or any resource that has a natural two-axis structure. The WHY is obvious: it matches how humans think about tabular data. Don't make them call GetValue("EU", "Q3") when ["EU", "Q3"] says everything.
obj[a, b] — intuitive, testable, and production-ready.Summing Up — When to Reach for Indexers (and When to Walk Away)
Indexers are a sharp tool. Use them when your class feels like a container — a list, a map, a matrix, a configuration store. The caller's mental model is "I have a key, give me the value." That's the sweet spot.
Walk away when the operation involves side effects, async work, or complex validation. If getting a value kicks off a database call or a calculation over thousands of records, use a named method like FetchAsync() or ComputeTotal(). An indexer that throws exceptions or blocks the thread is a design smell that junior devs leave for you to debug at 2 AM.
One more rule: keep the getter fast and idempotent. If you can't guarantee O(1) or at most O(log n), you're lying to the caller about the cost of your syntax. Respect the contract — indexers are for lookup, not for heavy lifting. That's the difference between a senior engineer and someone who just learned about this[].
Task<T> GetAsync(key) method. The brackets lie about cost.Index and Range Types with Indexers
C# 8.0 introduced the Index and Range types, which provide a concise syntax for indexing and slicing collections. While commonly used with arrays and spans, these types can also be integrated into custom indexers to offer modern, expressive access patterns. To support Index, your indexer can accept an Index parameter, which represents a position from the start or end (using the ^ operator). For example, ^1 refers to the last element. Similarly, Range can be used to define a slice of the collection. Implementing indexers with these types makes your custom collection feel like a first-class citizen in modern C#.
Consider a StringStore class that holds a list of strings. By adding an indexer that takes an Index, you allow callers to access elements using the hat notation. You must convert the Index to an integer index by checking its IsFromEnd property and computing the offset from the end. For Range, you can return a new collection or a span representing the slice. This approach is particularly useful for collections that are not arrays but still benefit from range-based access.
Here's an example implementation:
```csharp public class StringStore { private readonly Listnew();
public string this[Index index] { get => _items[index.IsFromEnd ? _items.Count - index.Value : index.Value]; set => _items[index.IsFromEnd ? _items.Count - index.Value : index.Value] = value; }
public IEnumerable
public void Add(string item) => _items.Add(item); } ```
Usage: ``csharp var store = new ``StringStore(); store.Add("A"); store.Add("B"); store.Add("C"); Console.WriteLine(store[^1]); // Output: C var slice = store[0..2]; // Returns ["A", "B"]
By adopting Index and Range, you align your custom collections with modern C# idioms, reducing cognitive load for users and enabling concise, readable code.
Index and Range indexers, as they may involve multiple reads. Consider using ConcurrentDictionary or locking mechanisms if the collection is shared across threads.Index and Range types into custom indexers enables concise, modern access patterns like ^1 for last element and 0..2 for slicing.Slicing with Range in Custom Collections
Beyond the Range indexer shown earlier, you can implement slicing that returns a new collection of the same type, enabling fluent chaining and LINQ-like operations. This is especially useful for immutable collections or when you want to avoid exposing internal storage. The key is to define a Slice method or an indexer that returns a new instance containing the specified range.
For example, an ImmutableStringStore could have a Range indexer that returns a new ImmutableStringStore with the sliced elements. This preserves immutability and allows further operations like store[1..3][0]. To implement this, you need to copy the relevant elements into a new internal list.
Here's a complete example:
```csharp public class ImmutableStringStore { private readonly IReadOnlyList
public ImmutableStringStore(IEnumerableToList().AsReadOnly(); }
public string this[Index index] => _items[index.IsFromEnd ? _items.Count - index.Value : index.Value];
public ImmutableStringStore this[Range range] { get { var (start, length) = range.GetOffsetAndLength(_items.Count); return new ImmutableStringStore(_items.Skip(start).Take(length)); } }
public int Count => _items.Count; } ```
Usage: ``csharp var store = new ImmutableStringStore(new[] { "A", "B", "C", "D" }); var slice = store[1..3]; // Contains "B", "C" Console.WriteLine(slice[0]); // Output: B ``
This pattern is common in functional programming and can be extended to support negative ranges (via ^). It's important to handle edge cases like empty ranges or out-of-bounds indices gracefully. You might also consider implementing IEnumerable to allow LINQ integration.
Slicing with Range makes your custom collection more versatile and aligns with the behavior of arrays, List, and Span. It's a powerful addition that enhances usability without sacrificing performance if implemented efficiently.
Memory<T> or ReadOnlyMemory<T> to share data without copying, or implement lazy slicing that defers copying until necessary.Range indexer that returns a new collection of the same type enables fluent slicing and maintains immutability, similar to array slicing in languages like Python.ReadOnlySpan with Indexers for Performance
When performance is critical, ReadOnlySpan<T> provides a memory-safe, allocation-free view over contiguous memory. You can expose a custom indexer that returns a ReadOnlySpan<T> to allow high-performance access without copying data. This is particularly useful for collections backed by arrays or unmanaged memory.
To implement, your indexer can return a ReadOnlySpan<T> that points to the underlying data. However, be cautious: Span<T> and ReadOnlySpan<T> are stack-only types and cannot be used as return types in async methods or stored in fields. They are best used for synchronous, short-lived operations.
Consider a Buffer class that wraps an array of bytes. By providing an indexer that returns a ReadOnlySpan<byte>, you enable callers to slice and process data efficiently:
public class Buffer
{
private readonly byte[] _data;
public Buffer(byte[] data) => _data = data;
public ReadOnlySpan<byte> this[Range range]
{
get
{
var (start, length) = range.GetOffsetAndLength(_data.Length);
return _data.AsSpan(start, length);
}
}
public byte this[Index index]
{
get => _data[index.IsFromEnd ? _data.Length - index.Value : index.Value];
}
}
Usage: ``csharp var buffer = new Buffer(new byte[] { 0x01, 0x02, 0x03, 0x04 }); ReadOnlySpan<byte> slice = buffer[1..3]; // Contains 0x02, 0x03 Console.WriteLine(slice[0]); // Output: 2 ``
This approach avoids allocating new arrays for slices, reducing GC pressure. It's ideal for high-throughput scenarios like network packet parsing or image processing. However, ensure that the underlying memory remains valid for the span's lifetime; if the collection can be modified, use ReadOnlySpan<T> to prevent mutations.
Remember that ReadOnlySpan<T> cannot be used as a return type in async methods or as a field. For such cases, consider Memory<T> or ReadOnlyMemory<T>, which are heap-allocatable and can be used in async contexts. You can then call .Span to get a span for synchronous operations.
MemoryManager<T> to create a Memory<T> that wraps the pointer safely.ReadOnlySpan<T> in indexers provides zero-allocation slicing and high-performance access to contiguous memory, ideal for performance-critical applications.Null Reference Ambush in String-Indexed Config Store
ToUpper() on it.- Always document the return contract of your indexer clearly: null vs throw. Different callers need different guarantees.
- Prefer throwing KeyNotFoundException when a missing key indicates a programming error — silent null propagates bugs.
- Consider providing a TryGet method alongside your indexer for callers that want to avoid exceptions.
Check the backing array length vs the index value: inspect via debugger or log both sizes.Use a conditional breakpoint on the indexer getter to catch the exact offending index.| File | Command / Code | Purpose |
|---|---|---|
| TemperatureLog.cs | using System; | What an Indexer Actually Is (and Why It Beats a Plain Method |
| AppSettings.cs | using System; | String-Keyed Indexers |
| SpreadsheetGrid.cs | using System; | Overloaded and Multi-Parameter Indexers |
| CountryCodeLookup.cs | using System; | Read-Only Indexers and Interface Contracts |
| ThreadSafeConfig.cs | using System; | Indexer Performance and Thread Safety |
| ConfigWithAccessModifiers.cs | public class ConfigStore | Indexers with Access Modifiers |
| MultiIndexerMatrix.cs | public class SparseMatrix | Overloaded Indexers |
| RegionalSalesMap.cs | using System.Collections.Generic; | Multi-Dimensional Maps |
| StringStoreWithIndexRange.cs | public class StringStore | Index and Range Types with Indexers |
| ImmutableStringStoreSlicing.cs | public class ImmutableStringStore | Slicing with Range in Custom Collections |
| BufferWithReadOnlySpan.cs | public class Buffer | ReadOnlySpan |
Key takeaways
Interview Questions on This Topic
Can you define an indexer in a C# interface? If so, what's the difference between declaring only a getter versus both a getter and setter in the interface?
Frequently Asked Questions
20+ years shipping production .NET services in enterprise systems. Everything here is grounded in real deployments.
That's OOP in C#. Mark it forged?
9 min read · try the examples if you haven't