C# developers are familiar with indexers. They allow objects to be accessed using square brackets, just like arrays and lists. For example, you can retrieve an element from a collection using customers[0] or access a dictionary value using settings["Theme"].
However, indexers have traditionally been available only on types that explicitly define them. If you are working with a class from an external library, you cannot directly add a new indexer to that class without modifying its source code.
C# 15 introduces extension indexers, allowing developers to add indexed access to existing types through extension blocks. This makes it possible to work with external types using familiar square-bracket syntax without changing their original implementation.
Extension indexers are particularly useful when you want to provide a more natural way to access calculated values, navigate structured data, or expose a different view of an existing collection.
In this article, we will understand how extension indexers work, explore practical examples, and discuss when they make sense in application development.
Understanding Indexers in C#
An indexer allows an instance of a class or structure to be accessed using an index. It looks similar to a property, but it accepts parameters inside square brackets.
Consider a simple customer collection:
public class CustomerCollection
{
private readonly List<string> _customers =
[
"John",
"David",
"Sarah"
];
public string this[int index]
{
get => _customers[index];
}
}
You can access the values using:
var customers = new CustomerCollection();
Console.WriteLine(customers[0]);
Console.WriteLine(customers[1]);
Output:
John
David
The indexer provides a convenient way to access internal data without exposing the underlying collection directly.
However, there is a limitation. The indexer must be declared inside the original class or structure.
If you do not own that type, you cannot add another indexer using traditional C# syntax.
This is where extension indexers become useful.
What Are Extension Indexers in C#?
Extension indexers allow developers to add indexer functionality to an existing type without modifying its original implementation.
They are declared inside an extension block within a static class.
Consider the following example:
public static class SequenceExtensions
{
extension(IEnumerable<int> sequence)
{
public int this[int index] =>
sequence.ElementAt(index);
}
}
Here, we are extending IEnumerable<int> with an indexer.
Instead of creating a separate method such as GetElementAt(2), developers can access an element using square brackets.
IEnumerable<int> numbers = [10, 20, 30, 40];
int thirdNumber = numbers[2];
Console.WriteLine(thirdNumber);
Output:
30
Notice that we did not modify IEnumerable<int> or create a custom collection class.
The extension indexer provides indexed access through an extension block.
Extension indexers were introduced in C# 15. C# 14 introduced extension members, including extension properties and operators, while C# 15 added support for extension indexers.
How Extension Indexers Work
An extension indexer uses the same general syntax as a regular indexer, but its declaration is placed inside an extension block.
There are a few important parts to understand.
1. Extension Block
The extension block identifies the type being extended.
extension(IEnumerable<int> sequence)
{
// Extension members
}
The receiver parameter, sequence, represents the existing object on which the extension member operates.
Unlike static extension members, an extension indexer requires a named receiver because indexers are always instance members.
2. Indexer Declaration
An extension indexer uses the this keyword.
public int this[int index] =>
sequence.ElementAt(index);
The declaration specifies the index parameter and the value returned by the indexer.
3. Indexed Access
Once the extension is available in the consuming code, it can be used like an ordinary indexer.
int value = numbers[2];
The calling code does not need to know how the extension indexer retrieves the value internally.
This separation makes it possible to introduce a more convenient access pattern without changing the original type.
Creating a Read-Only Extension Indexer
Let's build a practical example using a customer collection.
Suppose an external library provides customer information through an IEnumerable<Customer> implementation. You want to retrieve a customer using an email address rather than searching through the collection every time.
You can create an extension indexer that accepts a string.
Step 1. Define the Customer Model
public sealed record Customer(
int Id,
string Name,
string Email
);
Step 2. Create the Extension Indexer
public static class CustomerExtensions
{
extension(IEnumerable<Customer> customers)
{
public Customer? this[string email] =>
customers.FirstOrDefault(
customer => string.Equals(
customer.Email,
email,
StringComparison.OrdinalIgnoreCase
)
);
}
}
The extension indexer accepts an email address and searches the customer collection.
Using StringComparison.OrdinalIgnoreCase makes the comparison case-insensitive.
Step 3. Use the Extension Indexer
IEnumerable<Customer> customers =
[
new(1, "John", "[email protected]"),
new(2, "Sarah", "[email protected]"),
new(3, "David", "[email protected]")
];
Customer? customer = customers["[email protected]"];
Console.WriteLine(customer?.Name);
Output:
Sarah
Without the extension indexer, you would normally use a LINQ query:
var customer = customers.FirstOrDefault(
x => x.Email == "[email protected]"
);
Both approaches work. The extension indexer provides an alternative syntax when indexed access makes the intention clearer.
One important consideration is performance. This implementation searches the collection using FirstOrDefault, so the lookup takes O(n) time. For large customer collections or frequent lookups, a dictionary indexed by email address would generally be a better choice.
Creating Read and Write Extension Indexers
Extension indexers are not limited to retrieving values. They can also support get and set accessors.
This becomes useful when you want to provide a different way to update data stored inside an existing object.
Consider a path that stores relative coordinates rather than absolute positions.
Each coordinate represents a movement from the previous point. An extension indexer can expose the absolute position at a particular index.
Step 1. Define the Path Model
public sealed class Path
{
public List<(int X, int Y)> Offsets { get; } = [];
}
The path stores coordinate offsets instead of absolute points.
Step 2. Create the Extension Indexer
public static class PathExtensions
{
extension(Path path)
{
public (int X, int Y) this[int index]
{
get
{
ValidateIndex(path, index);
int x = 0;
int y = 0;
for (int i = 0; i <= index; i++)
{
x += path.Offsets[i].X;
y += path.Offsets[i].Y;
}
return (x, y);
}
set
{
ValidateIndex(path, index);
var previous = index == 0
? (X: 0, Y: 0)
: path[index - 1];
path.Offsets[index] =
(
value.X - previous.X,
value.Y - previous.Y
);
}
}
private static void ValidateIndex(
Path path,
int index)
{
if (index < 0 || index >= path.Offsets.Count)
{
throw new ArgumentOutOfRangeException(
nameof(index)
);
}
}
}
}
The getter calculates the absolute coordinate by adding all previous offsets.
The setter converts the requested absolute coordinate into a relative offset.
For example, if the previous point is (2, 3) and you want the current point to become (10, 10), the new offset becomes (8, 7).
The important detail is that the original Path class does not need to declare an indexer. The extension provides the additional access behavior.
Step 3. Read and Update Path Coordinates
var path = new Path();
path.Offsets.AddRange(
[
(2, 3),
(1, 1),
(-1, 4)
]);
Console.WriteLine(path[0]);
Console.WriteLine(path[1]);
Console.WriteLine(path[2]);
path[1] = (10, 10);
Console.WriteLine(path[1]);
Console.WriteLine(path[2]);
Output:
(2, 3)
(3, 4)
(2, 8)
(10, 10)
(9, 14)
The second point changes to (10, 10), and the third point changes accordingly because its offset is relative to the updated point.
This example demonstrates how extension indexers can provide a different view of existing data rather than simply exposing an element from a collection.
For large paths with frequent indexed reads, consider caching absolute positions or maintaining a more suitable internal representation. The example recalculates coordinates from the beginning for each read.
Extension Indexers vs Traditional Indexers
Both approaches provide indexed access, but their implementation and use cases are different.
Feature | Traditional indexers | Extension indexers |
|---|
Declaration | Inside a class or structure | Inside an extension block |
Modify original type | Required | Not required |
Read access | Supported | Supported |
Write access | Supported | Supported |
Ref-returning access | Supported | Supported |
External library types | Requires modifying the original type | Can extend supported existing types |
Implementation ownership | Original type owner | Extension author |
Traditional indexers are still the right choice when you own the class and indexed access is part of its core design.
Extension indexers are more useful when you want to provide additional indexed behavior for an existing type without changing its source code.
Advantages and Disadvantages of Extension Indexers
Advantages
Extend existing types: Developers can add indexed access to types from external libraries without modifying their implementation.
Cleaner calling syntax: Square-bracket notation can make data access more natural than repeatedly calling helper methods.
Better separation of concerns: Additional access behavior can remain in separate extension classes.
Flexible access patterns: Extension indexers can expose calculated values, filtered data, or alternative views of existing objects.
Read and write support: Both get and set accessors are supported.
Disadvantages
Language requirements: Extension indexers require C# 15 support.
Discoverability: Developers may not immediately know that an indexer comes from an extension rather than the original type.
Performance considerations: An extension indexer can hide expensive operations behind simple square-bracket syntax.
Potential naming conflicts: Multiple extensions may provide competing indexers for the same receiver and index argument types.
No modification of the original type: Extension indexers do not add actual instance members to the original class or change its underlying implementation.
Best Practices for Using Extension Indexers
Extension indexers are useful, but they should be introduced only when they make the code easier to understand.
Use Them When Indexed Access Improves Readability
Do not introduce an extension indexer simply because the language supports it.
For example, retrieving a calculated coordinate using path[2] is meaningful because the index represents a position in the path.
However, a complex database query hidden behind customers[101] may make the code harder to understand.
Pay Attention to Performance
The syntax may look similar to array access, but the implementation can have completely different performance characteristics.
An indexer that uses ElementAt() on an arbitrary enumerable may require traversing the collection.
For frequently accessed data, consider using an IReadOnlyList<T> or another data structure that supports efficient indexed access.
Validate Index Values
Always consider negative indexes and indexes outside the available collection range.
Use meaningful exceptions such as ArgumentOutOfRangeException rather than allowing unexpected failures to occur inside the implementation.
Avoid Competing Extension Indexers
Keep extension indexers focused on a clear purpose.
If multiple libraries introduce similar indexers for the same type, developers may face ambiguity or confusion when using the API.
Keep Extension Logic Close to Its Purpose
Place related extension indexers in a clearly named static class and namespace.
This makes the additional functionality easier to discover, test, and maintain.
Common Mistakes and Troubleshooting
Problem | Possible reason | Recommended solution |
|---|
Extension indexer does not compile | Project does not support C# 15 | Configure the appropriate SDK and language version |
Receiver cannot be accessed | Extension block does not have a named receiver | Declare a named receiver parameter |
Indexer is not available | Extension namespace is not in scope | Import the namespace containing the extension |
Index is outside the collection | Invalid index was provided | Add explicit index validation |
Unexpected performance issues | Indexer performs a linear search or repeated calculation | Use a suitable data structure or caching |
Extension indexer is ambiguous | Multiple applicable extension indexers exist | Review competing declarations and make the intended access unambiguous |
One important limitation is that extension indexers cannot be captured in expression trees. If your application uses expression-based frameworks such as Entity Framework Core, do not assume an extension indexer will automatically translate into a database query.
Summary
Extension indexers introduce another way to extend existing types in C#. They allow developers to add indexed access through extension blocks without modifying the original class or structure.
Introduced in C# 15, this feature supports get and set accessors and can be used to expose calculated values or alternative views of existing data.
For application developers, the biggest benefit is flexibility. Instead of creating helper methods for every access pattern, you can provide a natural indexing experience when it makes the code easier to understand.
However, extension indexers should be designed carefully. Their implementation can hide expensive operations, and developers should pay attention to validation, performance, and discoverability.
When used appropriately, extension indexers provide a useful way to make existing APIs more expressive while keeping the original implementation unchanged.
Join the conversation! Your thoughts help the community grow.