Creating Custom Indexers for Collection-Like Behavior in C#

Why Indexers Make Your Classes More Intuitive

Indexers let you access objects using the same square bracket syntax you use with arrays and dictionaries. When you create a class that logically represents a collection or container, indexers make the interface feel natural and familiar to anyone who uses your code.

You've already used indexers countless times. When you write myArray[0] or dictionary["key"], you're using indexers. The ability to create your own means you can provide this same convenient syntax for custom types that manage data internally.

This guide shows you how to implement indexers in classes and interfaces, handle multiple parameter types, validate inputs, and follow patterns that make your code clear and maintainable.

Understanding Indexer Syntax

An indexer uses the this keyword followed by parameters in square brackets. It looks like a property but accepts arguments that determine which element to access.

Simple integer-based indexer
public class SimpleCollection
{
    private string[] items = new string[10];

    // Indexer definition
    public string this[int index]
    {
        get
        {
            if (index < 0 || index >= items.Length)
                throw new IndexOutOfRangeException();

            return items[index];
        }
        set
        {
            if (index < 0 || index >= items.Length)
                throw new IndexOutOfRangeException();

            items[index] = value;
        }
    }
}

// Usage
var collection = new SimpleCollection();
collection[0] = "First item";    // Calls set accessor
collection[5] = "Sixth item";
string item = collection[0];     // Calls get accessor
Console.WriteLine(item);         // Output: First item

The indexer looks like an array but you control what happens when someone reads or writes through the index. This lets you add validation, logging, or lazy loading behind a simple access pattern.

Creating String-Based Indexers

String indexers work like dictionaries, letting users access values by name or key. This pattern feels natural for configuration classes, data stores, or any type that maps names to values.

String-based indexer implementation
public class Configuration
{
    private Dictionary<string, string> settings = new();

    public string this[string key]
    {
        get
        {
            if (settings.TryGetValue(key, out string value))
                return value;

            return null; // Or throw exception based on your needs
        }
        set
        {
            if (string.IsNullOrWhiteSpace(key))
                throw new ArgumentException("Key cannot be empty");

            settings[key] = value;
        }
    }

    public bool ContainsKey(string key) => settings.ContainsKey(key);
}

// Usage
var config = new Configuration();
config["ApiUrl"] = "https://api.example.com";
config["Timeout"] = "30";
config["RetryCount"] = "3";

Console.WriteLine(config["ApiUrl"]);  // Output: https://api.example.com
Console.WriteLine(config["Missing"]); // Output: (null)

This configuration class behaves like a dictionary but you could add logging, validation, or type conversion inside the indexer. Users get simple syntax while you maintain control over the data.

Overloading Indexers with Multiple Types

You can define multiple indexers in the same class as long as they have different parameter types. This gives users flexibility in how they access your data.

Class with both int and string indexers
public class Employee
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Department { get; set; }
    public string Email { get; set; }
    public decimal Salary { get; set; }
}

public class EmployeeCollection
{
    private List<Employee> employees = new();
    private Dictionary<string, Employee> employeesByName = new();

    public void Add(Employee employee)
    {
        employees.Add(employee);
        employeesByName[employee.Name] = employee;
    }

    // Integer indexer for positional access
    public Employee this[int index]
    {
        get
        {
            if (index < 0 || index >= employees.Count)
                throw new IndexOutOfRangeException();

            return employees[index];
        }
    }

    // String indexer for name-based access
    public Employee this[string name]
    {
        get
        {
            if (employeesByName.TryGetValue(name, out Employee employee))
                return employee;

            return null;
        }
    }

    public int Count => employees.Count;
}

// Usage
var collection = new EmployeeCollection();
collection.Add(new Employee { Id = 1, Name = "Alice", Department = "IT" });
collection.Add(new Employee { Id = 2, Name = "Bob", Department = "Sales" });

// Access by position
Employee first = collection[0];
Console.WriteLine(first.Name);  // Output: Alice

// Access by name
Employee bob = collection["Bob"];
Console.WriteLine(bob.Department);  // Output: Sales

Users can choose the access method that makes sense for their situation. Numeric indices work well for iteration while string lookups provide semantic access.

Multi-Parameter Indexers

Indexers can accept multiple parameters, which is useful for multi-dimensional structures like matrices, grids, or coordinate-based systems.

Two-dimensional grid with indexer
public class Grid<T>
{
    private T[,] cells;
    private int rows;
    private int columns;

    public Grid(int rows, int columns)
    {
        this.rows = rows;
        this.columns = columns;
        cells = new T[rows, columns];
    }

    // Two-parameter indexer
    public T this[int row, int col]
    {
        get
        {
            ValidateCoordinates(row, col);
            return cells[row, col];
        }
        set
        {
            ValidateCoordinates(row, col);
            cells[row, col] = value;
        }
    }

    private void ValidateCoordinates(int row, int col)
    {
        if (row < 0 || row >= rows)
            throw new ArgumentOutOfRangeException(nameof(row));

        if (col < 0 || col >= columns)
            throw new ArgumentOutOfRangeException(nameof(col));
    }

    public int Rows => rows;
    public int Columns => columns;
}

// Usage
var grid = new Grid<string>(3, 3);

// Set values using coordinates
grid[0, 0] = "X";
grid[1, 1] = "O";
grid[2, 2] = "X";

// Read values
Console.WriteLine(grid[1, 1]);  // Output: O

// Can be used in loops
for (int row = 0; row < grid.Rows; row++)
{
    for (int col = 0; col < grid.Columns; col++)
    {
        var cell = grid[row, col] ?? "-";
        Console.Write($"{cell} ");
    }
    Console.WriteLine();
}

Multi-parameter indexers feel natural for grid-based or coordinate systems. The syntax matches how you'd access multi-dimensional arrays.

Defining Indexers in Interfaces

Interfaces can declare indexers to establish contracts for collection-like types. Implementing classes provide the actual storage and access logic.

Interface with indexer
public interface IDataStore<TKey, TValue>
{
    TValue this[TKey key] { get; set; }
    bool ContainsKey(TKey key);
    void Remove(TKey key);
}

public class MemoryDataStore<TKey, TValue> : IDataStore<TKey, TValue>
{
    private Dictionary<TKey, TValue> storage = new();

    public TValue this[TKey key]
    {
        get => storage.TryGetValue(key, out var value) ? value : default;
        set => storage[key] = value;
    }

    public bool ContainsKey(TKey key) => storage.ContainsKey(key);

    public void Remove(TKey key) => storage.Remove(key);
}

public class CachedDataStore<TKey, TValue> : IDataStore<TKey, TValue>
{
    private Dictionary<TKey, TValue> cache = new();
    private Func<TKey, TValue> loadFunc;

    public CachedDataStore(Func<TKey, TValue> loadFunc)
    {
        this.loadFunc = loadFunc;
    }

    public TValue this[TKey key]
    {
        get
        {
            if (!cache.ContainsKey(key))
            {
                cache[key] = loadFunc(key);
            }
            return cache[key];
        }
        set => cache[key] = value;
    }

    public bool ContainsKey(TKey key) => cache.ContainsKey(key);

    public void Remove(TKey key) => cache.Remove(key);
}

// Usage
IDataStore<string, int> memory = new MemoryDataStore<string, int>();
memory["count"] = 42;
Console.WriteLine(memory["count"]);  // Output: 42

IDataStore<int, string> cached = new CachedDataStore<int, string>(
    id => $"User_{id}"
);
Console.WriteLine(cached[100]);  // Loads and caches: User_100

Different implementations can use different storage mechanisms while presenting the same indexer-based interface to calling code.

Read-Only Indexers

Sometimes you want to expose data through an indexer without allowing modifications. Omitting the set accessor creates a read-only indexer.

Read-only indexer for immutable data
public class DayOfWeek
{
    private static readonly string[] dayNames =
    {
        "Sunday", "Monday", "Tuesday", "Wednesday",
        "Thursday", "Friday", "Saturday"
    };

    // Read-only indexer
    public string this[int dayNumber]
    {
        get
        {
            if (dayNumber < 0 || dayNumber > 6)
                throw new ArgumentOutOfRangeException(nameof(dayNumber));

            return dayNames[dayNumber];
        }
    }

    public int Count => dayNames.Length;
}

// Usage
var days = new DayOfWeek();
Console.WriteLine(days[0]);  // Output: Sunday
Console.WriteLine(days[5]);  // Output: Friday

// This won't compile because indexer is read-only
// days[0] = "NotSunday";  // Error: Property or indexer cannot be assigned to

Read-only indexers protect your data while still providing convenient access. They're perfect for lookup tables, constants, or computed values.

Expression-Bodied Indexers

For simple indexers, you can use expression-bodied syntax to make your code more concise. This works well when the get or set operation is a single expression.

Concise indexer with expression bodies
public class StringWrapper
{
    private List<string> items = new();

    // Expression-bodied get
    public string this[int index] => items[index];

    public void Add(string item) => items.Add(item);
    public int Count => items.Count;
}

public class StringDictionary
{
    private Dictionary<string, string> data = new();

    // Expression-bodied get and set
    public string this[string key]
    {
        get => data.TryGetValue(key, out var value) ? value : null;
        set => data[key] = value;
    }
}

// Usage
var wrapper = new StringWrapper();
wrapper.Add("First");
wrapper.Add("Second");
Console.WriteLine(wrapper[0]);  // Output: First

var dict = new StringDictionary();
dict["name"] = "John";
Console.WriteLine(dict["name"]);  // Output: John

Expression-bodied members reduce syntax noise when your logic is straightforward. Use them for simple get/set operations that don't need multiple statements.

Real-World Example: Custom Cache

Here's a practical example showing how indexers make a custom caching class intuitive to use.

Cache with expiration and indexer access
public class TimedCache<TKey, TValue>
{
    private class CacheItem
    {
        public TValue Value { get; set; }
        public DateTime ExpiresAt { get; set; }
        public bool IsExpired => DateTime.UtcNow > ExpiresAt;
    }

    private Dictionary<TKey, CacheItem> cache = new();
    private TimeSpan defaultExpiration;

    public TimedCache(TimeSpan defaultExpiration)
    {
        this.defaultExpiration = defaultExpiration;
    }

    public TValue this[TKey key]
    {
        get
        {
            if (cache.TryGetValue(key, out var item))
            {
                if (!item.IsExpired)
                {
                    return item.Value;
                }

                // Remove expired item
                cache.Remove(key);
            }

            return default;
        }
        set
        {
            cache[key] = new CacheItem
            {
                Value = value,
                ExpiresAt = DateTime.UtcNow.Add(defaultExpiration)
            };
        }
    }

    public void SetWithExpiration(TKey key, TValue value, TimeSpan expiration)
    {
        cache[key] = new CacheItem
        {
            Value = value,
            ExpiresAt = DateTime.UtcNow.Add(expiration)
        };
    }

    public bool TryGetValue(TKey key, out TValue value)
    {
        if (cache.TryGetValue(key, out var item) && !item.IsExpired)
        {
            value = item.Value;
            return true;
        }

        value = default;
        return false;
    }

    public void Clear() => cache.Clear();
}

// Usage
var cache = new TimedCache<string, string>(TimeSpan.FromMinutes(5));

// Simple indexer access
cache["user_session"] = "abc123";
cache["api_token"] = "xyz789";

// Retrieve values
string session = cache["user_session"];
Console.WriteLine(session);  // Output: abc123

// Check if value exists and hasn't expired
if (cache.TryGetValue("api_token", out string token))
{
    Console.WriteLine($"Token: {token}");
}

// Custom expiration
cache.SetWithExpiration("temp_data", "value", TimeSpan.FromSeconds(30));

The indexer makes the cache feel like a dictionary, hiding the complexity of expiration checking and cleanup. Users get simple syntax while your implementation handles the timing logic.

Best Practices for Indexers

Following these guidelines will make your indexers clear, consistent, and easy to use.

Validate inputs: Always check parameters before accessing internal storage. Throw appropriate exceptions for invalid indices or keys rather than returning null or default values silently.

Be consistent with standard collections: If your indexer behaves like an array, throw IndexOutOfRangeException for invalid indices. If it behaves like a dictionary, throw KeyNotFoundException for missing keys or return null based on what users expect.

Consider thread safety: If multiple threads might access your indexer simultaneously, add proper locking or use concurrent collections internally. Indexers don't automatically provide thread safety.

Document behavior clearly: Explain what happens when users access non-existent keys or invalid indices. Document whether the indexer creates new entries on write, throws exceptions, or returns defaults.

Keep get and set simple: Indexers should feel like direct access to data. Avoid expensive operations in get accessors since users expect fast property-like access. If you need complex logic, consider providing explicit methods instead.

Use appropriate parameter types: Choose parameter types that make sense for how users think about your data. Integers work for ordered sequences, strings for named access, and custom types for domain-specific indexing.

Don't overuse indexers: Just because you can add an indexer doesn't mean you should. Use them when your type truly represents a collection or container. For classes with unrelated data, regular properties and methods are clearer.

Frequently Asked Questions (FAQ)

Can a class have multiple indexers with different parameter types?

Yes, you can overload indexers by using different parameter types. One indexer might accept an int for numeric indexing while another accepts a string for key-based access. The compiler chooses the appropriate indexer based on the argument type you provide when accessing elements.

How do indexers differ from regular properties?

Indexers use the this keyword instead of a property name and require at least one parameter. They enable array-like syntax with square brackets rather than property access with dot notation. While properties retrieve a single value, indexers access values based on keys or indices provided as parameters.

Can interfaces define indexers?

Yes, interfaces can declare indexers without implementation details. Implementing classes must provide the actual get and set logic. This allows you to define contracts for collection-like types where different implementations provide their own storage and retrieval mechanisms while maintaining consistent access patterns.

Back to Articles