DEV Community

JKC
JKC

Posted on

Your Type Has No Indexer. C# 15 Lets You Add One.

TL;DR: C# 15 lets you declare an indexer inside an extension block, so thing[i] works on types you don't own. Add a Count and a Slice, and you get ^1, ranges, and list patterns too.

C# 15 ships with .NET 11, which is still in release candidate. Everything below ran on the .NET 11 RC 1 SDK with no LangVersion setting.

The helper everyone writes

You have a type you can't change. Maybe it is a wrapper from a library, maybe it is an interface like IReadOnlyDictionary<TKey, TValue>. You want square brackets. You get a method instead:

int last = readings.GetAt(readings.Values.Length - 1);
int stock = inventory.GetValueOrDefault("kiwi", 0);
int cell = grid.Get(row: 1, column: 2);
Enter fullscreen mode Exit fullscreen mode

Each one works. Each one is also a tiny dialect your team has to learn per type. C# 14 gave us extension properties, but indexers were left out. C# 15 fills that gap.

What it is

Inside an extension block you can now declare this[...], just like on a class. Indexers are always instance members, so the block needs a named receiver:

public static class SequenceIndexer
{
    extension(IEnumerable<int> sequence)
    {
        public int this[int index] => sequence.ElementAt(index);
    }
}
Enter fullscreen mode Exit fullscreen mode

And the call site looks like the indexer was always there:

IEnumerable<int> numbers = Enumerable.Range(1, 10);
Console.WriteLine(numbers[2]); // 3
Enter fullscreen mode Exit fullscreen mode

That is the whole feature. Get and set accessors, multiple parameters, expression bodies, and ref returns all work like they do on normal indexers.

A lookup with a fallback

Here is one you can use today. A second parameter turns any read-only dictionary into a lookup with a default:

public static class DictionaryIndexers
{
    extension<TKey, TValue>(IReadOnlyDictionary<TKey, TValue> map)
    {
        public TValue this[TKey key, TValue fallback]
            => map.TryGetValue(key, out var value) ? value : fallback;
    }
}
Enter fullscreen mode Exit fullscreen mode
var stock = new Dictionary<string, int> { ["apple"] = 12, ["pear"] = 0 };
Console.WriteLine(stock["apple", 0]); // 12
Console.WriteLine(stock["kiwi", 0]);  // 0
Enter fullscreen mode Exit fullscreen mode

No out var, no KeyNotFoundException. Dictionary<string, int> picks it up because it implements IReadOnlyDictionary.

The realistic one: a type you don't own

Say a library hands you this, and you can't edit it:

public sealed class Readings(int[] values)
{
    public int[] Values { get; } = values;
}
Enter fullscreen mode Exit fullscreen mode

Give it a Count, an integer indexer, and a Slice, all as extensions:

public static class ReadingExtensions
{
    extension(Readings readings)
    {
        public int Count => readings.Values.Length;

        public int this[int index] => readings.Values[index];

        public Readings Slice(int start, int length)
            => new(readings.Values.AsSpan(start, length).ToArray());
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the compiler treats it like a real collection:

var temps = new Readings([18, 21, 24, 19]);

Console.WriteLine(temps[0]);                // 18
Console.WriteLine(temps[^1]);               // 19
Console.WriteLine(temps[1..3].Values[0]);   // 21
Console.WriteLine(temps is [18, .., 19]);   // True
Enter fullscreen mode Exit fullscreen mode

Index from the end, ranges, and list patterns all came for free. You wrote three members, and the compiler wired up the rest. That is the same pattern the language already uses for "countable" types, now open to extensions.

Setters work too, with as many parameters as you need:

public sealed class Grid(int width, int height)
{
    public int Width { get; } = width;
    public int[] Cells { get; } = new int[width * height];
}

public static class GridIndexer
{
    extension(Grid grid)
    {
        public int this[int row, int column]
        {
            get => grid.Cells[row * grid.Width + column];
            set => grid.Cells[row * grid.Width + column] = value;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
var grid = new Grid(3, 2);
grid[1, 2] = 7;
Console.WriteLine(grid[1, 2]);                   // 7
Console.WriteLine(string.Join(", ", grid.Cells)); // 0, 0, 0, 0, 0, 7
Enter fullscreen mode Exit fullscreen mode

No more doing row-major math at every call site. It happens in one place, which is the only place it should be wrong.

Can I use it?

What Status
Language version C# 15
SDK .NET 11 SDK (RC at the time of writing)
LangVersion needed No, C# 15 is the default for net11.0
.NET 10 / C# 14 No: error CS8652, "extension indexers" is in Preview
GA Microsoft says C# 15 ships with .NET 11 in November

Gotchas

  1. Real indexers always win, silently. The compiler tries the type's own indexer first, then extensions. In a test, an extension this[int] on List<int> that returns -1 changed nothing: list[1] still returned 20, and the build gave no warning. So you can't use an extension to "fix" an indexer a type already has.

  2. Arrays and strings are off limits. Extension indexers are never used for array or string element access. An extension this[string] on int[] compiled fine, but a["x"] failed with CS0029, because the compiler only looked at the built-in array indexer.

  3. Brackets can hide a loop. The ElementAt indexer on IEnumerable<int> looks like O(1) and isn't. Reading seq[0] through seq[9] from a lazy iterator pulled 55 items to return 10. If the receiver is not already a list, think twice before giving it brackets.

  4. No expression trees. Expression<Func<Box, int>> f = b => b[0]; fails with CS9296: an expression tree may not contain an extension property or indexer access. That matters if you hoped to use one inside an EF Core query.

Wrap-up

Extension indexers are small, but they finish the story C# 14 started: methods, properties, operators, and now brackets. Pick the one GetAt helper in your codebase that annoys you most and give it a this[...] on the .NET 11 RC.

Sources

This article was written with the help of AI and checked against the sources above.

Top comments (0)