1. Introduction
Imagine we have our favorite class DogShelter, which stores a collection of dogs. In previous lectures, we already learned how to add an indexer to get a dog by its number in the shelter: Dog firstDog = myShelter[0];. Pretty cool!
But what if our user wants to get a dog not by number, but, say, by name? Or by breed? Or maybe even by a combo of properties? Sure, we could add methods like GetDogByName("Buddy") or GetDogByBreedAndAge("Labrador", 5). And that's a totally normal approach.
But sometimes you just want access to be more "array-like" and intuitive. So you could write: Dog buddy = myShelter["Buddy"]; or Dog oldLab = myShelter["Labrador", 8];.
If DogShelter is our own class, we can just add new indexers inside it. But what if DogShelter is from a third-party library we can't change? Or maybe we want to add a super specific way to access it that shouldn't "pollute" the main class?
That's exactly where extension indexers (Extension Indexers) come in!
2. "Square Brackets" from the Outside
Remember how in the last lecture we added an extension property DisplayName to Dog? Indexers work pretty much the same way!
An extension indexer is a static indexer defined in a static class that lets you use the obj[index] syntax for objects of existing types, even if those types didn't have an indexer before, or if you want to add an indexer with a different parameter type.
It's like you bought a fridge, and then figured out how to make it give you a Coke if you knock on a certain spot. The fridge is still the same, but you added new functionality "from the outside"!
Extension Indexer Syntax
public static class MyExtensionClass
{
extension(ObjectType instance)
{
public static ReturnType this[IndexType index ]
{
get
{
// Read logic using instance and index
return ...;
}
set
{
// Using instance, index and the 'value' keyword
// 'value' is the new value
}
}
}
}
Notice the this ObjectType instance. This syntax is exactly like what we saw with extension methods and properties. instance is how we'll refer to the object we're extending inside our get and set accessors.
3. How to Declare an Extension Indexer (Without Blowing Your Mind)?
The syntax is similar to Extension Properties, which we talked about last time, but with index parameters. Here's a minimal example:
public static class DogShelterExtensions
{
extension(DogShelter shelter)
{
public static Dog this[string name]
{
get
{
foreach (var dog in shelter)
{
if (dog.Name == name)
return dog;
}
return null;
}
}
}
}
Familiar stuff:
- this before the first parameter—this is required for Extension Members (the object being extended).
- After the class name comes the list of parameters that will be used inside the square brackets.
This works almost like regular indexers, except you don't change the original class!
Practice: Extending DogShelter with an Indexer by Name
Let's tweak our learning project. Imagine we have a dog shelter, and each dog is unique by name:
DogShelter Class (library/foreign code)
public class Dog
{
public string Name { get; set; }
public int Age { get; set; }
}
public class DogShelter : IEnumerable<Dog>
{
private List<Dog> dogs = new List<Dog>();
public void AddDog(Dog dog) => dogs.Add(dog);
// Old indexer by number
public Dog this[int index]
{
get => dogs[index];
set => dogs[index] = value;
}
public IEnumerator<Dog> GetEnumerator() => dogs.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
We want: shelter["Busya"]
Before — only through a method:
// Before C# 14:
public static Dog? FindByName(this DogShelter shelter, string name) { ... }
Now — with an Extension Indexer:
public static class DogShelterExtensions
{
extension(DogShelter shelter)
{
public static Dog? this[string name]
{
get
{
foreach (var dog in shelter)
if (dog.Name == name)
return dog;
return null;
}
set
{
for (int i = 0; i < shelter.Count; i++)
{
if (shelter[i].Name == name)
{
shelter[i] = value!;
return;
}
}
throw new ArgumentException("Dog not found");
}
}
}
}
Now our main code looks way nicer:
var shelter = new DogShelter();
shelter.AddDog(new Dog { Name = "Busya", Age = 3 });
shelter.AddDog(new Dog { Name = "Tuzik", Age = 5 });
// Using the extension indexer!
Dog busya = shelter["Busya"]!;
Console.WriteLine(busya.Age);
shelter["Busya"] = new Dog { Name = "Busya", Age = 4 };
Visualization: What's Happening?
| Operation | How it worked before | With Extension Indexer |
|---|---|---|
| Search by name | shelter.FindByName("X") | shelter["X"] |
| Change dog by name | shelter.UpdateName("X", ..) | shelter["X"] = ... |
4. Nuances and Features of Extension Indexers
Compiler and Scope
- The Extension Indexer must be declared in a static public class (just like regular extension methods).
- Don't forget to add the right using. If you forget—the compiler is silent, but your code won't compile.
- If the base class already has such an indexer—you can't extend it (the signatures must be different).
Implementing the set Accessor
You can declare only get (then the indexer is read-only). Or add set too (like in the example above)—then you can both read and write through your indexer.
Passing by Value and Reference
The Extension Indexer works with the instance of the object you're extending (this before the first parameter). If the object is a reference type, you're changing its state.
Multiple Indexers in One Class
No problem—you can declare several extension indexers with different parameter sets! For example, search by age: shelter[5] (old), shelter["Busya"] (new), shelter[age: 3] (another one, if you want).
Example: Adding Two Indexers to DogShelter
public static class DogShelterExtensions
{
extension(DogShelter shelter)
{
// By name
public static Dog? this[string name]
{
get => shelter.FirstOrDefault(d => d.Name == name);
set
{
for (int i = 0; i < shelter.Count; i++)
if (shelter[i].Name == name)
shelter[i] = value!;
}
}
// By age—returns the first dog with that age
public static Dog? this[int age]
{
get => shelter.FirstOrDefault(d => d.Age == age);
}
}
}
Now you can write:
var youngDog = shelter[1]; // By age
var tony = shelter["Tony"]; // By name
shelter["Tuzik"] = new Dog { Name = "Tuzik", Age = 9 };
Real-World Scenarios
- External Libraries: You want to add extra ways to index a third-party class without touching the source. For example, work with a collection of orders, finding them by number, date, status, etc., without duplicating wrapper methods.
- "Adapter Pattern": You turn an old collection with a "dumb" API into a modern, concise, more "C#-ish" one, without breaking backward compatibility.
- Legacy Code Migration: Add new features to already written types without touching existing code and tests.
- Testing Convenience: You can hang temporary indexers for your needs (like searching by some unique test property) without cluttering the main class.
5. Typical Mistakes and Traps When Working with Extension Indexers
If the base class already has an indexer with exactly the same signature, the extension indexer won't be called—the base indexer takes priority.
The extension indexer is still just an extension member, and without the right using (namespace import) the extension won't be visible.
Another common mistake—returning null without warning the user. If someone accidentally tries to access a non-existent element and the extension indexer returns null, it can lead to a NullReferenceException somewhere else. It's good practice to think about what your implementation should do: throw an exception, return a special stub object, or just return null.
If you have several extension indexers, make sure they're unique by type and number of parameters. For example, you can't create two indexers with the same signature—the compiler will throw an error.
Extension indexers only work with object instances, not with static types.
GO TO FULL VERSION