You've got an IReadOnlyList<T> coming back from a repository and you want orders[^1] to grab the most recent one. Or you're wrapping a third-party sequence type and want dictionary-style lookup without writing a whole adapter class. Both are the same problem: you want this[...] syntax on a type you don't control, and until now C# extension members simply didn't support it. You could add extension methods called ElementAt or At, but the moment you want bracket syntax, you were stuck writing a wrapper type.
C# 14 rebuilt extension methods into extension blocks — a static class containing extension(ReceiverType receiver) { ... } sections that can hold instance-shaped methods, properties, operators, and static members. It was a big upgrade, but indexers were conspicuously absent from that first pass. C# 15, shipping in .NET 11 Preview 6, closes the gap: you can now declare an indexer inside an extension block, and it behaves like a real this[] member on the receiver type.
This is a preview-only feature at the moment — it needs <LangVersion>preview</LangVersion> — but the design is stable enough to plan around, and it changes how you'll want to wrap sequence and collection types you don't own.
The basic shape
An extension indexer looks exactly like an instance indexer, just nested inside an extension block with a named receiver:
public static class SequenceIndexer
{
extension(IEnumerable<int> sequence)
{
public int this[int index] => sequence.ElementAt(index);
}
}
IEnumerable<int> numbers = Enumerable.Range(1, 10);
int third = numbers[2]; // 3
Because indexers are always instance members, the extension block must name its receiver parameter — you can't get away with the parameterless extension(IEnumerable<int>) form that's valid for static-only extension blocks. The compiler needs sequence in scope so the indexer body has something to index into.
// NOTE
This is the exact motivating example from the .NET 11 Preview 6 release notes — before this feature, getting the nth element of an IEnumerable<T> meant .ElementAt(n), which reads awkwardly next to code that's otherwise doing array-style access.
From-the-end indexing with Index
The more compelling case is adding Index-based access — the ^1 syntax — to a type that doesn't support it natively. IReadOnlyList<T> has this[int] but no Index overload, so list[^1] doesn't compile today. An extension indexer fixes that without touching the interface:
public static class ReadOnlyListExtensions
{
extension<T>(IReadOnlyList<T> list)
{
public T this[Index index] => list[index.GetOffset(list.Count)];
}
}
IReadOnlyList<Order> recentOrders = await orderStore.GetRecentAsync(customerId);
Order latest = recentOrders[^1];
Notice the type parameter sits on the extension<T> declaration, not on the indexer itself, because T is part of the receiver's shape (IReadOnlyList<T>). This follows the same rule as generic extension methods and properties: put the type parameter on the block when it's needed to describe the receiver, and on the individual member only when that member introduces an additional type parameter the receiver doesn't have.
Settable indexers
Extension indexers support the full set of accessor features you'd expect from an ordinary indexer — get, set, expression-bodied accessors, and ref-returning accessors. Here's a settable indexer over a simple key-tracking wrapper:
public static class TagBagExtensions
{
extension(Dictionary<string, string> tags)
{
public string? this[string key, string fallback]
{
get => tags.TryGetValue(key, out var value) ? value : fallback;
set => tags[key] = value ?? fallback;
}
}
}
var tags = new Dictionary<string, string>();
tags["environment", "unknown"] = "staging";
Console.WriteLine(tags["environment", "unknown"]); // staging
Console.WriteLine(tags["region", "unspecified"]); // unspecified
That's a two-parameter indexer with a fallback baked into the lookup key — something you can't retrofit onto Dictionary<TKey, TValue> itself without wrapping it in a new type. init accessors are explicitly disallowed on extension indexers, so if you need immutable-after-construction semantics you still need a real type.
// WARNING
If the receiver is a struct and your indexer has a set accessor, the receiver parameter must be declared ref — e.g. extension(ref MyStruct receiver) — or the setter mutates a throwaway copy and the caller never sees the change. This is the same struct-copy trap that applies to ordinary extension methods; the compiler won't warn you, because a copy-and-discard is a perfectly legal (if useless) thing to do.
How resolution is prioritised
The obvious question is what happens when a type already has its own indexer and you also define an extension indexer for it. The compiler resolves indexer access using a fixed priority order, checked in this sequence:
- Instance indexers declared directly on the receiver type
- Instance-based implicit indexers (the compiler-recognised patterns behind list patterns and
Index/Rangesupport) - Extension indexers with an explicit signature match
- Extension indexers reached through implicit conversion
In practice this means your extension indexer is a fallback, never an override. If you write an extension indexer for a type that already exposes this[int], your extension is simply invisible at that call site — the compiler picks the real member and yours never runs. There's no ambiguity error, no warning, nothing; it just silently doesn't apply. This mirrors how extension methods have always worked (a real instance method always wins over an extension method with a compatible signature), but it's easy to forget when you're adding an indexer specifically because you assumed the type didn't have one.
What you can't do yet
A handful of restrictions are worth knowing before you reach for this in real code:
- No modifiers that don't make sense on extensions.
abstract,virtual,override,static, andprotectedare all rejected on an extension indexer, same as for extension methods and properties. - No indexing through
baseor a type receiver. Extension indexers only apply to instance receivers reached the normal way — you can't call one viabase[...]or on a bare type name. - No
dynamicarguments. If any indexer argument is typeddynamic, overload resolution can't statically pick your extension indexer, so it's excluded from candidacy entirely. - Not usable in expression trees. If you're building a
Func<T, bool>for EF Core or anotherIQueryableprovider, an extension indexer inside that lambda won't compile — expression tree construction doesn't support them. Stick to real indexers or.ElementAt()/.Skip().Take()inside anything that gets translated to SQL. initaccessors aren't allowed, as noted above.
None of these are surprising once you know the feature is filling a specific gap — ad hoc bracket-syntax access on types you don't own — rather than trying to be a general-purpose operator overloading mechanism.
Common pitfalls
Assuming LangVersion is set project-wide. Extension indexers, like the rest of C# 15's preview surface, require <LangVersion>preview</LangVersion> in the .csproj. If you copy an example into a solution still targeting latest or 13.0, you'll get a flat "feature not available" compiler error rather than anything indexer-specific — check the language version first when debugging.
Expecting the extension indexer to shadow a real one. As covered above, if the receiver type already has a compatible indexer, your extension is dead code from the compiler's point of view. If you're adding an extension indexer to smooth over a missing capability, double-check the receiver type doesn't already have an overload that satisfies the call — generic constraints and inherited interfaces can introduce indexers you didn't expect.
Forgetting the named receiver requirement. Static-only extension blocks can omit the receiver parameter name (extension(IEnumerable<int>)), which is fine for static methods and properties. The moment you add an indexer — an inherently instance member — that same block needs a named parameter, or the compiler rejects the indexer declaration.
Mutating a struct receiver without ref. Covered above, but worth repeating: a settable extension indexer over a struct silently no-ops unless the receiver parameter is ref. This is exactly the kind of bug that passes code review because the code compiles and looks correct.
Reaching for this inside LINQ-to-entities queries. If you're querying through EF Core or another provider that translates expression trees, an extension indexer used inside the query lambda won't compile. Materialise the sequence first (.ToList() or .AsEnumerable()), then use the extension indexer on the in-memory collection.
Summary
- C# 15 (shipping with .NET 11) adds indexers to extension blocks, completing the set of extension member kinds alongside methods, properties, and operators from C# 14.
- Declare them like ordinary indexers inside
extension(ReceiverType receiver) { ... }— the receiver parameter must be named, even in an otherwise static-only extension block. - They support
get,set, expression bodies, ref returns, and multiple parameters, but notinitaccessors. - Resolution always prefers a real instance indexer over an extension one — your extension indexer is a fallback that silently doesn't apply if a genuine match exists.
- They can't be used with
dynamicarguments,base/type receivers, or inside expression trees. - Settable indexers over struct receivers need
refon the receiver parameter, or the setter mutates a discarded copy. - The feature needs
<LangVersion>preview</LangVersion>today; treat it as something to prototype against, not ship to production, until .NET 11 goes GA in November.