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