C# developers are familiar with extension methods. They allow us to add functionality to an existing type without modifying its original source code or creating a derived type.
Extension indexers take that idea further by allowing an indexer-like operation to be added to an existing type through an extension member.
This can be useful when an application repeatedly accesses data through a key, position, or lookup expression and the original type cannot be changed.
However, extension indexers are not something that should be added everywhere. Their value becomes clearer when they solve a specific readability or abstraction problem.
This article explains how C# 15 extension indexers work, where they can help in real applications, where they may create problems, and how to decide whether they are appropriate for production code.
What Is an Extension Indexer?
An indexer allows an object to be accessed using square-bracket syntax.
For example:
var customer = customers[10];A normal indexer is declared inside the type:
public class CustomerCollection
{
private readonly List<Customer> _customers = new();
public Customer this[int index]
{
get => _customers[index];
set => _customers[index] = value;
}
}The calling code can then use:
var customer = collection[0];The limitation is that you normally need to modify the original type to add the indexer.
C# 15 introduces extension indexers, allowing an indexer to be defined externally as an extension member. This makes it possible to provide indexer-style access without changing the original type.
Why Extension Indexers Are Useful
Consider an existing type that you do not own:
Dictionary<int, Customer>You cannot add an indexer directly to Dictionary<TKey,TValue>.
Of course, dictionaries already support indexing:
var customer = customers[101];But imagine a type that exposes a lookup method instead:
var customer = repository.GetCustomer(101);If the application repeatedly treats that repository as a read-only keyed collection, an extension indexer could potentially provide a more natural access pattern.
The difference is primarily about the API developers see:
repository.GetCustomer(101);versus:
repository[101];The second form can be cleaner when the object genuinely behaves like an indexed collection.
Extension Members in C# 15
Extension indexers are part of the broader extension-member work in C# 15.
The goal is to provide more ways to extend existing types without modifying their original declarations.
Traditional extension methods look like this:
public static class CustomerExtensions
{
public static bool IsActive(this Customer customer)
{
return customer.Status == CustomerStatus.Active;
}
}The method can then be called as:
if (customer.IsActive())
{
// ...
}An extension indexer follows a different usage pattern because the caller uses indexing syntax rather than a method call.
This can be particularly useful when the operation represents lookup semantics rather than an action.
A Practical Example
Suppose an application contains this third-party type:
public class CustomerStore
{
private readonly Dictionary<int, Customer> _customers;
public CustomerStore(IEnumerable<Customer> customers)
{
_customers = customers.ToDictionary(x => x.Id);
}
public Customer? GetById(int id)
{
return _customers.TryGetValue(id, out var customer)
? customer
: null;
}
}The original type cannot be changed.
Currently, callers have to write:
var customer = store.GetById(100);An extension indexer can provide indexer-style access when the design calls for it.
Conceptually, the extension member allows the caller to express:
var customer = store[100];The important point is not that fewer characters are required.
The value comes from making the API communicate the intended relationship between the object and the key.
Indexer Syntax Communicates Lookup
Compare these two calls:
store.GetById(100);and:
store[100];The first says:
Execute an operation that retrieves a customer.
The second says:
Treat this object as a collection-like structure indexed by a customer ID.
Neither is universally better.
The second becomes useful when the abstraction genuinely represents keyed access.
For example:
CustomerStore
|
+-- 101 -> Customer
+-- 102 -> Customer
+-- 103 -> CustomerThe indexer syntax makes that relationship visible.
Extension Indexers vs Extension Methods
The two approaches serve different purposes.
Feature | Extension Method | Extension Indexer |
|---|---|---|
Syntax |
|
|
Best for | Operations | Lookup/access |
Can add behavior | Yes | Yes |
Changes original type | No | No |
Reads naturally as collection access | Sometimes | Yes |
Supports parameters | Method parameters | Index arguments |
Useful for keyed access | Yes | Often clearer |
Use an extension method when the operation is conceptually an action.
Use an extension indexer when the object behaves conceptually like an indexed value provider.
Extension Indexers with Read-Only Data
One useful scenario is exposing read-only lookup behavior.
Suppose the application receives configuration data:
public sealed class FeatureSettings
{
public IReadOnlyDictionary<string, bool> Features { get; }
public FeatureSettings(
IReadOnlyDictionary<string, bool> features)
{
Features = features;
}
}An extension member could provide a convenient lookup abstraction.
The caller could then express:
var enabled = settings["NewDashboard"];The important design question is whether settings[key] clearly represents a lookup.
If the expression requires documentation to understand what it does, an extension indexer may be less appropriate than an explicit method.
Working with Multiple Index Arguments
Indexers are not restricted to a single argument.
A normal C# indexer can use multiple parameters:
public string this[int row, int column]
{
get => _data[row, column];
}This can be useful for matrix-like or coordinate-based data.
An extension indexer can provide similar syntax when the underlying type should expose that access pattern but cannot be modified.
For example, a grid-like abstraction might conceptually support:
var value = grid[2, 5];This is more expressive than:
var value = grid.GetValue(2, 5);when the object is genuinely being treated as a two-dimensional data structure.
Extension Indexers for Domain Models
Domain models are another area where careful use can improve readability.
Suppose an application maintains localized values:
Product
├── en-US -> "Laptop"
├── fr-FR -> "Ordinateur"
└── de-DE -> "Laptop"A method-based API might be:
product.GetName("en-US");An indexer-based API could read:
product["en-US"];This makes sense because the locale behaves like a lookup key.
The same principle applies to:
Localization
Configuration
Metadata
Keyed caches
Feature flags
Routing tables
Coordinate-based data
Domain-specific collections
Extension Indexers and ASP.NET Core
Extension indexers can also be useful around ASP.NET Core infrastructure when an object represents keyed application data.
Consider a simple request-context abstraction:
public interface IRequestData
{
string? Get(string key);
}A method call might look like:
var tenantId = requestData.Get("TenantId");If the abstraction is explicitly intended to behave like a keyed collection, an indexer can make the usage clearer:
var tenantId = requestData["TenantId"];This is especially readable when accessing multiple values:
var tenantId = requestData["TenantId"];
var region = requestData["Region"];
var correlationId = requestData["CorrelationId"];However, do not use indexer syntax merely because it looks shorter.
The abstraction should communicate a genuine keyed relationship.
Handling Missing Values
One important design decision is what happens when the requested key does not exist.
Possible behaviors include:
Return null
Throw an exception
Return a default value
Return an optional/result typeFor example:
var customer = store[100];What should happen if customer 100 does not exist?
If missing values are expected, returning null may be appropriate.
If the indexer follows dictionary semantics, throwing an exception may be consistent with the underlying abstraction.
This behavior must be documented clearly.
Safe Lookup vs Strict Lookup
It is often useful to distinguish between safe and strict access.
For example:
var customer = store[100];could represent strict lookup.
A separate method could provide safe lookup:
var found = store.TryGet(100, out var customer);This makes the API semantics clear.
A common pattern is:
store[100]
|
+-- Expected to exist
store.TryGet(100, out customer)
|
+-- May not existThis approach is easier to reason about than an indexer whose missing-value behavior is unclear.
Extension Indexers and Existing Collections
Extension indexers are not automatically useful for types that already provide indexers.
For example:
Dictionary<int, Customer>already supports:
customers[100];Adding another abstraction around the same behavior may simply increase complexity.
Before creating an extension indexer, ask:
Does the type already expose appropriate indexing?
Does the extension provide meaningful domain behavior?
Does it improve readability?
Does it hide an important operation?
Will developers understand the syntax immediately?
If the answer to these questions is mostly no, an extension method may be better.
Extension Indexers vs Wrapper Types
Sometimes a wrapper type is a better design.
Suppose you need specialized access to a dictionary:
Dictionary<int, Customer>You could create:
public class CustomerDirectory
{
private readonly Dictionary<int, Customer> _customers;
public CustomerDirectory(
Dictionary<int, Customer> customers)
{
_customers = customers;
}
public Customer? this[int id]
{
get => _customers.TryGetValue(id, out var customer)
? customer
: null;
}
}Now the behavior is part of a dedicated domain abstraction.
This can be preferable when the lookup has important business rules.
For example:
CustomerDirectory
|
+-- Authorization
+-- Validation
+-- Caching
+-- Lookup
+-- Audit rulesAn extension indexer should not become a way to hide substantial domain logic.
Extension Indexers vs Interfaces
Interfaces remain useful when multiple implementations need to provide the same behavior.
For example:
public interface ICustomerLookup
{
Customer? GetCustomer(int id);
}If the application has several implementations:
SqlCustomerLookup
CachedCustomerLookup
MockCustomerLookup
ApiCustomerLookupan interface may provide a clearer architectural boundary than an extension indexer.
Extension members are best suited to adding syntax or behavior to an existing type.
They do not replace dependency inversion.
Performance Considerations
An extension indexer does not automatically make lookup operations faster.
The underlying implementation still determines performance.
For example, if an indexer eventually calls:
dictionary.TryGetValue(key, out var value);the performance characteristics are largely those of the dictionary lookup.
If it performs a database query:
store[customerId];the syntax may look inexpensive while actually triggering I/O.
That can be dangerous.
Developers should not assume indexer syntax means in-memory access.
Consider this:
var customer = repository[100];If the implementation executes a database query, the code hides an expensive operation behind a familiar-looking expression.
This is one of the most important design considerations.
Avoid Hiding Expensive Operations
An indexer generally communicates lightweight access.
For example:
array[10];
dictionary["name"];Developers expect these operations to be relatively direct.
If this:
repository[100];opens a database connection and performs a network request, the syntax can be misleading.
For expensive operations, an explicit method is often clearer:
await repository.GetCustomerAsync(100);The method name and asynchronous API communicate that work is happening.
This is a strong reason not to overuse extension indexers.
Async Operations and Indexers
Indexers cannot be declared as asynchronous methods.
That makes them unsuitable for operations that naturally require await.
For example, avoid designing an abstraction where developers expect this:
var customer = await repository[100];Even if the underlying concept could theoretically return a task-like value, the API becomes confusing.
Prefer:
var customer =
await repository.GetCustomerAsync(100);This communicates the asynchronous operation directly.
Extension Indexers and Caching
A good use case can be an in-memory cache.
Suppose an existing cache type provides methods:
cache.Get(key);
cache.Set(key, value);If the domain abstraction is explicitly collection-like, indexer syntax may make read access more natural.
For example:
var configuration = cache["database"];However, be careful about whether the operation can miss.
A clear API might distinguish:
cache["database"];from:
cache.TryGet("database", out var configuration);The semantics should be obvious to anyone reading the code.
Common Mistakes
Using Indexers Everywhere
Not every lookup needs indexer syntax.
An explicit method can sometimes communicate intent better.
Hiding Database Queries
Avoid making an expensive database operation look like a simple collection access.
Hiding Network Calls
The same principle applies to HTTP calls and remote services.
Ignoring Missing Keys
Define what happens when a key does not exist.
Creating Ambiguous APIs
If developers cannot tell what the index represents, the indexer may reduce readability instead of improving it.
Replacing Existing Abstractions
Do not introduce an extension indexer simply because a type already has a well-designed API.
Troubleshooting Extension Indexers
The Compiler Does Not Recognize the Extension Member
Check that the project is using a C# 15-capable compiler and the appropriate preview configuration.
For example:
<PropertyGroup>
<LangVersion>preview</LangVersion>
</PropertyGroup>Also verify that the correct .NET SDK is being used by the project.
The Indexer Is Not Found
Check whether the namespace containing the extension member is imported.
Extension members still depend on normal C# namespace and accessibility rules.
Multiple Indexers Cause Ambiguity
If several applicable extension members accept similar argument types, the compiler may not be able to select the intended member.
Keep extension APIs focused and avoid competing overloads that are difficult to distinguish.
The Syntax Looks Convenient but Behavior Is Confusing
Review what the indexer actually does.
If it performs expensive or asynchronous work, consider replacing it with an explicit method.
Best Practices
Use Indexers for Genuine Indexed Access
The object should conceptually behave like a collection, map, grid, or keyed value provider.
Keep Access Lightweight
Indexers work best for operations developers expect to be relatively direct.
Make Missing-Value Behavior Obvious
Document whether missing keys return null, a default value, or an exception.
Prefer Explicit Methods for I/O
Use methods for database, network, and asynchronous operations.
Keep Extension Logic Small
An extension indexer should add convenient access, not become an entire business layer.
Use Domain-Specific Keys
A strongly typed key can sometimes be clearer than a generic string:
public readonly record struct CustomerId(int Value);Then the conceptual access becomes:
store[customerId];This reduces accidental mixing of unrelated identifiers.
Advantages
Cleaner Syntax
Indexing can make repeated keyed access easier to read.
No Changes to the Original Type
Extension members can add behavior without modifying a third-party or external type.
Better Domain Expression
A collection-like abstraction can communicate its purpose naturally.
Reduced Wrapper Code
For simple access patterns, an extension indexer can avoid unnecessary adapter methods.
Familiar C# Syntax
Developers already understand indexer notation.
Disadvantages
Can Hide Expensive Work
An indexer may look like simple memory access even when it performs I/O.
Limited Suitability for Async Operations
Asynchronous access is generally better represented by explicit methods.
Can Reduce Discoverability
A method such as GetCustomer describes its purpose directly. An indexer relies more heavily on the surrounding type to communicate meaning.
Preview Feature Considerations
C# 15 extension-member capabilities are part of the evolving language feature set, so teams should evaluate compiler and SDK support before adopting them broadly.
Not a Replacement for Good Domain Design
An indexer does not automatically make an abstraction better.
When Extension Indexers Actually Help
Extension indexers are most useful when the underlying object has a clear indexed or keyed identity.
Good examples include:
Configuration["Database"]
Localization["en-US"]
Metadata["Version"]
Grid[row, column]
Cache[key]
DomainCollection[id]Less suitable examples include:
Repository[id] // database operation
ApiClient[url] // network request
FileStore[path] // disk I/O
Service[key] // unclear semanticsThe distinction is important.
The syntax should reflect the conceptual operation, not simply make an existing method shorter.
A Practical Decision Checklist
Before introducing an extension indexer, ask:
Does the type naturally behave like an indexed collection?
Is the access operation lightweight?
Is the index key obvious?
Is missing-key behavior clear?
Would
object[key]be easier to understand thanobject.Get(key)?Does the feature avoid hiding I/O?
Does it improve the domain model?
Will developers discover and understand the extension member?
Could a wrapper or interface provide a clearer abstraction?
Is the team comfortable adopting the required C# language version?
If most answers are yes, an extension indexer may be a reasonable fit.
Summary
C# 15 extension indexers provide a way to add indexer-style access to types without modifying their original definitions.
Their biggest benefit is not simply shorter syntax. The real value comes when an existing type genuinely behaves like a keyed or indexed collection and the indexer makes that relationship clearer.
They can work well for lightweight access patterns involving configuration, metadata, localization, caches, and domain-specific collections.
They should be used more carefully for repositories, remote services, databases, or other operations where the underlying work is expensive or asynchronous. In those cases, explicit methods usually communicate the cost and behavior more clearly.
The key principle is simple: use an extension indexer when the syntax accurately describes the abstraction. If object[key] looks like simple indexed access but actually performs significant business logic or I/O, an explicit method is usually easier to understand and maintain.

Join the conversation! Your thoughts help the community grow.