Back to Csharplang

Extension indexers

proposals/extension-indexers.md

latest16.9 KB
Original Source

Extension indexers

[!INCLUDESpecletdisclaimer]

Champion issue: https://github.com/dotnet/csharplang/issues/9856

Declaration

Grammar

Extension indexers are added to the set of permitted members inside an extension declaration by extending the grammar as follows (relative to proposals/csharp-14.0/extensions.md):

antlr
extension_member_declaration
        : method_declaration
        | property_declaration
        | indexer_declaration // new
        | operator_declaration
        ;

Like ordinary indexers, extension indexers have no identifier and are identified by their parameter list. Extension indexers may use the full set of features that ordinary indexers support today (accessor bodies, expression-bodied members, ref-returning accessors, scoped parameters, attributes, etc.).

Because indexers are always instance members, an extension block that declares an indexer must provide a named receiver parameter.

The existing restrictions on extension members continue to apply: indexers inside an extension declaration cannot specify abstract, virtual, override, new, sealed, partial, protected (or any of the related accessibility modifiers), or init accessors.

csharp
public static class BitExtensions
{
    extension(int i)
    {
        public bool this[int index]
        {
            get => ...;
        }
    }
}

All rules from the C# standard that apply to ordinary indexers apply to extension indexers, but extension members do not have an implicit or explicit this.

The existing extension member inferrability rule still applies: For each non-method extension member, all the type parameters of its extension block must be used in the combined set of parameters from the extension and the member.

IndexerName attribute

IndexerNameAttribute may be applied to an extension indexer. The attribute is not emitted in metadata, but its value affects conflicts between members, it determines the name of the property and accessors in metadata, and is used when emitting [DefaultMemberAttribute] (see Metadata).

Consumption

Indexer access

The rules in Indexer access are updated: if the normal processing of the indexer access finds no applicable indexer, an attempt is made to process the construct as an extension indexer access.

  1. Attempt to bind using only the instance indexers declared (or inherited) on the receiver type. If an applicable candidate is found, overload resolution selects among those instance members as today and stops.
  2. If the set of applicable indexers is empty, an attempt is made to process the element_access as an implicit System.Index/System.Range indexer access (which relies on Length/Count plus this[int]/Slice(int, int)).
  3. If both steps fail to identify any applicable indexers, an attempt is made to process the element_access as an extension indexer access.

Note: the element access section handles the case where an argument has type dynamic, so it never gets processed as an indexer access.

Extension indexer access

Extension members, including extension indexers, are never considered when the receiver is a base_access expression.

Note: we only process an element_access as an indexer access if the receiver is a variable or value, so extension indexers are never considered when the receiver is a type.

Given an element_access E[A], the objective is to identify an extension indexer.

A candidate extension indexer is applicable with respect to receiver E and argument list A if an expanded signature, comprised of the type parameters of the extension block and a parameter list combining the extension parameter with the indexer's parameters, is applicable with respect to an argument list combining the receiver E with the argument list A.

We reuse the extension method scope-walk: we traverse the same scopes consulted for extension method invocation, including the current and enclosing lexical scopes and using namespace or using static imports.

Considering each scope in turn:

  • Extension blocks in non-generic static class declarations in the current scope are considered.
  • The indexers in those extension blocks comprise the candidate set.
  • Candidates that are not accessible are removed from the set.
  • Candidates that are not applicable (as defined above) are removed from the set.
  • If the resulting set is not empty, overload resolution is applied to the candidate set.
    • If a single best indexer can be identified, then we have successfully processed the indexer access.
    • Otherwise, the extension indexer access is ambiguous and a compile-time error occurs.
  • Otherwise, an attempt is made to process the element_access as an implicit System.Index/System.Range indexer access (which relies on Length/Count plus this[int]/Slice(int, int)) using extension members in the current scope.
    • If there is no applicable candidate for one or both parts, then we proceed to the next scope.
    • If an applicable candidate is found for both parts (the Length/Count part and the this[int]/Slice(int, int) part), then we consider there was an applicable extension implicit indexer and this is the last scope we will consider.
      • If a single best member can be identified for each part, then we have successfully processed the implicit indexer access.
      • Otherwise, a compile-time error occurs.

Using this single best indexer identified at the previous step, the indexer access is then processed as a static method invocation.

Depending on the context in which it is used, an indexer access causes invocation of either the get_accessor or the set_accessor of the indexer.
If the indexer access is the target of an assignment, the set_accessor static implementation method is invoked to assign a new value.
In all other cases, the get_accessor static implementation method is invoked to obtain the current value.
Either way, the invocation will use generic arguments inferred during the applicability check and the receiver as the first argument.

Other element-access forms

Any construct that defers to element-access binding (null-conditional element access or assignments, index assignments in object initializers, or list and spread patterns) automatically participates in the extension indexer resolution described above.

  • So a type with a suitable Length or Count extension properties is considered countable for the purpose of those patterns.
  • The implicit System.Index/System.Range fallback indexers can also be extensions, but the two parts (Length/Count and this[int]/Slice(int, int)) must be found in the same scope.

Note: Since a list-pattern with a spread-pattern needs to bind a Length/Count, a real or implicit this[Index] and a real or implicit this[Range], it may use the Length from one scope, the this[int] and corresponding Length from another scope and the Slice(int, int) and Length from yet another scope. The compiler assumes that the Length/Count properties are well-behaved and give the same result.

Expression trees

Extension indexers cannot be captured in expression trees.

XML docs

CREF syntax allows referring to an extension indexer and its accessors, as well as its implementation methods.

Example:

csharp
/// <see cref="E.extension(int).this[string]"/>
/// <see cref="E.extension(int).get_Item(string)"/>
/// <see cref="E.extension(int).get_Item"/>
/// <see cref="E.extension(int).set_Item(string, int)"/>
/// <see cref="E.extension(int).set_Item"/>
/// <see cref="E.get_Item(int, string)"/>
/// <see cref="E.get_Item"/>
/// <see cref="E.set_Item(int, string, int)"/>
/// <see cref="E.set_Item"/>
public static class E
{
    extension(int i)
    {
        /// <summary></summary>
        public int this[string s]
        {
            get => throw null;
            set => throw null;
        }
    }
}

Metadata

Extension indexers follow the same lowering model as extension properties. For each CLR-level extension grouping type that contains at least one indexer, the compiler emits:

  • An extension property named Item (or the value supplied by IndexerNameAttribute) with accessor bodies that throw NotImplementedException() and an [ExtensionMarkerName] attribute referencing the appropriate extension marker type.
  • Implementation methods named get_Item/set_Item in the enclosing static class. These methods prepend the receiver parameter to the parameter list and contain the user-defined bodies. They are static and participate in overload resolution in the same way as implementation methods for extension properties.

To mirror the behavior of ordinary indexers, the compiler also emits [DefaultMemberAttribute] on any extension grouping type that contains one or more extension indexers. The attribute’s MemberName equals the metadata name of the indexer (Item by default, or the value from IndexerNameAttribute).

Example

Source code:

csharp
static class BitExtensions
{
    extension<T>(T t)
    {
        public bool this[int index]
        {
            get => ...;
            set => ...;
        }
    }
}

Emitted metadata (simplified to C#-like syntax):

csharp
[Extension]
static class BitExtensions
{
    [Extension, SpecialName, DefaultMember("Item")]
    public sealed class <G>$T0 // grouping type
    {
        [SpecialName]
        public static class <M>$T_t // marker type
        {
            [SpecialName]
            public static void <Extension>$(T t) { } // marker method
        }

        [ExtensionMarkerName("<M>$T_t")]
        public bool this[int index] // extension indexer
        {
            get => throw new NotImplementedException();
            set => throw new NotImplementedException();
        }
    }

    // accessor implementation methods
    public static bool get_Item<T>(T t, int index) => ...;
    public static void set_Item<T>(T t, int index, bool value) => ...;
}

Open issues

Dealing with params

If you have an extension indexer with params, such as int this[int i, params string[] s] { get; set; }, there are three ways you could use it:

  • extension indexing: receiver[i: 0, "Alice", "Bob"]
  • getter implementation invocation: E.get_Item(receiver, i: 0, "Alice", "Bob")
  • setter implementation invocation: E.set_Item(...)

But what is the signature of the setter implementation method?
It only makes sense for the last parameter of a user-invocable method signature to have params, so it serves no purpose in E.set_Item(... extension parameter ..., this i, params string[] s, int value).

Some options:

  1. disallow params for extension indexers that have a setter
  2. omit the [ParamArray] attribute on the setter implementation method
  3. do nothing special (emit the [ParamArray])

I would propose option 2, as it maximizes params usefulness. The cost is only a small difference between extension indexing and disambiguation syntax.

Decision (LDM 2026-02-02): emit the [ParamArray] and verify no negative impact on tooling

Impact of assigned value to type inference

csharp
int i = 0;
i[42, null] = new object(); // fails inference
E.set_Item(i, 42, null, new object()); // infer `E.set_Item<object>`

public static class E
{
    extension<T>(int i)
    {
        public T this[int j, T t] { set { } }
    }
}
csharp
#nullable enable

int i = 0;
i[new object()] = null; // infer `E.extension<object!>` and warn on conversion of null literal to `object!`
E.set_Item(i, new object(), null); // infer `E.set_Item<object?>`

public static class E
{
    extension<T>(int i)
    {
        public T this[T t] { set { } }
    }
}

Decision (LDM 2026-02-02): the indexer is inferred only given the receiver and arguments in the argument list (ie. the assigned value doesn't contribute).

Should extension Length/Count properties make a type countable?

As a reminder, extensions do not come into play when binding implicit Index or Range indexers:

csharp
C c = new C();
_ = c[..]; // Cannot apply indexing with [] to an expression of type 'C'

class C
{
    public int Length => 0;
}

static class E
{
    public static C Slice(this C c, int i, int j) => null!;
}

So our position so far has been that extensions properties should not count as "countable properties" in list-patterns, collection expressions and implicit indexers.

If we expose this[Index] or this[Range] extension indexers in element access scenarios, it is natural to expect the target type to work in list patterns.
List patterns, however, require a Length or Count property.

Should extension properties satisfy that requirement? (that would seem natural)

csharp
C c = new C();
var x1 = c[^1];
var x2 = c[1..];

if (c is [.., var y1]) { }
if (c is [_, .. var y2]) { }

class C { }

static class E
{
  extension(C c)
  {
    object this[System.Index] => ...;
    C this[System.Range] => ...;
    int Length => ...;
  }
}

But then, should those properties also contribute to the implicit indexer fallback (Length/Count + Slice) that is used when an explicit Index/Range indexer is missing?

csharp
C c = new C();
if (c is [var y1, .. var y2]) { }

class C
{
  C Slice(int i, int j) => ...;
}

static class E
{
  extension(C c)
  {
    object this[System.Index] => ...;
    int Length => ...;
  }
}

Decision (LDM 2026-02-02): extensions should contribute everywhere, including countable properties and implicit indexer fallback.

Confirm whether extension indexer access comes before or after implicit indexers

csharp
C c = new C();
_ = c[^1];

class C
{
  public int Length => ...;
  public int this[int i] => ...;
}

static class E
{
  extension(C c)
  {
    public int this[System.Index i] => ...;
  }
}

I've spec'ed and implemented extension indexer access as having priority over implicit indexers, but now think they should come after to avoid unnecessary compat breaks.

Update (LDM 2026-02-02): this needs further investigation. Yes, extensions should come after non-extension members, but beyond that we need some concrete proposals in light of above decision to allow extensions to contribute to implicit indexer fallback.

Count/Length: Is the name prioritized first, or non-extension vs extension?

We also have an existing fallback: Length is prioritized over Count property. Should an extension Length come before or after a non-extension Count property?

Answer: the proposal is to look up scope by scope. Instance scope comes before extension scopes.
Within each scope, we prefer Length over Count.

Confirm proposed design for implicit indexers

We look scope-by-scope, starting from instance scope and then proceeding to extension scopes.
Within a scope:

  1. we look for a real indexer,
  2. otherwise, if we have a single argument of the right type, then we look for an implicit indexer.

Decision (LDM 2026-03-09): no, once we move into extension scopes, we're going to use all-the-way-through extension resolution.

Confirm proposed design for list-patterns

For a list-pattern with a spread, we will look for:

  1. a Length/Count
  2. a real or implicit this[Index]
  3. a real or implicit this[Range]

We will look for each independently. But when we look for an implicit this[Index] or this[Range], the two parts must come from the same scope:

  1. Length/Count
  2. this[int]/Slice(int, int)

Note: the non-negative handling for Length patterns kicks in when a type can be used in a list-pattern (ie. it is countable and indexable).

Decision (LDM 2026-03-09): no, we'll follow this lookup order instead:

  1. List patterns are resolved as if we look for Length/Count, Index indexer and Range indexer individually
  2. For Index and Range indexers, proceed as follows: a. With instance lookup only, find the "real" index if possible b. With instance lookup only, find the parts of the implicit indexer if possible c. With full lookup (instance+extension), find the "real" index if possible d. With full lookup (instance+extension), find the parts of the implicit indexer if possible (each in individual lookups)

Should extension Slice method also contribute?

cs
_ = c[1..^1];

static class E
{
  extension(C c)
  {
    public int Length => 3;
  }
  public static C Slice(this C c, int i, int j) => ...;
}

Decision (LDM 2026-03-09): yes, we're treating classic and new extension methods exactly the same.

Should extension Length contribute to spread optimization?

cs
C c = new C();
int[] i = [0, .. c]; // Uses Length, if available, to allocate the right size

Decision (LDM 2026-03-09): no, it's unlikely that an extension would be able to implement this in a performant way, so it would not help for optimization.