IReadOnlyList and IReadOnlyDictionary in Modern C

This article explores the purpose of IReadOnlyList<T> and IReadOnlyDictionary<TKey, TValue> in modern C# API design. It covers how these interfaces establish clear contracts for data consumption, distinguish read-only views from immutability, provide index-based access without mutation risks, and improve API maintainability compared to older collection interfaces.

Communicating Intent and Encapsulation

Before .NET 4.5, developers often relied on IEnumerable<T> to expose collections safely or IList<T> when index-based access was required. IEnumerable<T> protects data from modification, but it conceals basic metrics such as Count and forces sequential enumeration or LINQ allocations (.ToList(), .Count()) for index-based operations. Conversely, IList<T> and IDictionary<TKey, TValue> expose mutating methods like Add, Remove, and Clear, forcing developers either to implement runtime exceptions (NotSupportedException) on read-only wrappers or expose mutable internals.

IReadOnlyList<T> and IReadOnlyDictionary<TKey, TValue> solve this problem by defining an explicit contract: the consumer can read and index the data, but cannot modify it through the interface. This provides strong encapsulation without sacrificing structural visibility.

Key Capabilities

These interfaces provide the efficiency of direct access while omitting mutation APIs:

  • Direct Lookup: IReadOnlyList<T> exposes an indexer (this[int index]) and a Count property, enabling \(O(1)\) random access.
  • Key-Based Access: IReadOnlyDictionary<TKey, TValue> provides ContainsKey, TryGetValue, Keys, Values, and a key indexer (this[TKey key]), mirroring the lookup mechanics of standard dictionaries.
  • Covariance: IReadOnlyList<T> is covariant (out T), meaning an IReadOnlyList<string> can be implicitly treated as an IReadOnlyList<object>. Standard mutable interfaces like IList<T> cannot support covariance because allowing writes would violate type safety.

Read-Only View vs. True Immutability

A common misconception is that these interfaces guarantee immutable data. In reality, they represent a read-only view, not an immutable collection.

Standard mutable types like List<T>, T[], and Dictionary<TKey, TValue> implement these read-only interfaces directly. If a class exposes a private List<T> as an IReadOnlyList<T>, the caller cannot invoke .Add() directly, but internal methods within the owning class can still alter the underlying list. Consumers observing the interface will see those updates. If an API requires guaranteed thread safety or absolute immutability that cannot change under any circumstances, types from the System.Collections.Immutable namespace should be used instead.

Best Practices for Modern API Design

When designing modern C# APIs, collection choices should follow these guidelines:

  1. Return Types: Use IReadOnlyList<T> or IReadOnlyDictionary<TKey, TValue> when returning in-memory collections that consumers need to index, count, or query by key, but should not modify.
  2. Avoid Unnecessary Defensive Copies: Because standard collections implement these interfaces, you can return existing collections directly typed as read-only interfaces without allocating new arrays or wrapper classes.
  3. Parameter Types: Accept the least specific interface needed. If a method only enumerates, accept IEnumerable<T>. If it requires index access or count checks without altering data, accept IReadOnlyList<T>. This allows callers to pass arrays, lists, or custom collections interchangeably.