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 -> Customer

The indexer syntax makes that relationship visible.

Extension Indexers vs Extension Methods

The two approaches serve different purposes.

Feature

Extension Method

Extension Indexer

Syntax

store.Get(10)

store[10]

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:

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 type

For 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 exist

This 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:

  1. Does the type already expose appropriate indexing?

  2. Does the extension provide meaningful domain behavior?

  3. Does it improve readability?

  4. Does it hide an important operation?

  5. 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 rules

An 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
ApiCustomerLookup

an 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 semantics

The 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:

  1. Does the type naturally behave like an indexed collection?

  2. Is the access operation lightweight?

  3. Is the index key obvious?

  4. Is missing-key behavior clear?

  5. Would object[key] be easier to understand than object.Get(key)?

  6. Does the feature avoid hiding I/O?

  7. Does it improve the domain model?

  8. Will developers discover and understand the extension member?

  9. Could a wrapper or interface provide a clearer abstraction?

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