Collection Interfaces
.NET's collection types — List<T>, Dictionary<TKey,TValue>, arrays, HashSet<T>, and many others — all implement a shared hierarchy of interfaces that describe increasing levels of capability. Understanding this hierarchy matters because it tells you what a method parameter actually needs: a method that only iterates its input should accept IEnumerable<T>, not List<T>, so it can work with arrays, LINQ query results, database cursors, or any other sequence without forcing callers to materialize a concrete list first. Programming against the narrowest interface that satisfies your needs is one of the most impactful habits for writing flexible, reusable C# APIs.
Cricket analogy: A coach writing a fielding drill should ask for 'anyone who can catch' (IEnumerable<T>) rather than demanding a specific XI like the Mumbai Indians squad list, so the drill works whether the players come from a training group or a full team roster.
The core hierarchy
IEnumerable<T> sits at the root: it exposes a single method, GetEnumerator(), and represents anything that can be iterated once, forward-only, via foreach. ICollection<T> extends it with Count, Add, Remove, Contains, and Clear — a modifiable collection of known size but no positional/indexed access. IList<T> extends ICollection<T> further with indexed access (this[int index]) and positional Insert/RemoveAt. IDictionary<TKey,TValue> is a parallel branch representing keyed access, extending ICollection<KeyValuePair<TKey,TValue>> with this[TKey], Keys, Values, and TryGetValue.
Cricket analogy: GetEnumerator is like an umpire simply signaling 'next ball' one delivery at a time, while a full scorecard with Add and Remove capability, like adjusting a squad list before a match, needs the richer ICollection powers; IList adds numbered batting positions you can insert into directly.
// Accepting the narrowest interface that satisfies the method's needs.
static double Average(IEnumerable<double> readings)
{
double sum = 0;
int count = 0;
foreach (var reading in readings)
{
sum += reading;
count++;
}
return count == 0 ? 0 : sum / count;
}
static void AddDefaultEntries(ICollection<string> destination)
{
destination.Add("pending");
destination.Add("active");
}
// Works with an array, a List<T>, a HashSet<T> projection — anything enumerable.
var fromArray = Average(new[] { 3.5, 4.0, 2.75 });
var fromList = Average(new List<double> { 10.0, 12.5 });
var tags = new List<string>();
AddDefaultEntries(tags); // List<T> satisfies ICollection<T>
// IReadOnlyList<T> exposes indexed read access without mutation methods.
IReadOnlyList<string> snapshot = tags.AsReadOnly();
Console.WriteLine(snapshot[0]);Read-only interfaces
Alongside the mutable hierarchy, .NET defines IReadOnlyCollection<T>, IReadOnlyList<T>, and IReadOnlyDictionary<TKey,TValue>, which expose only non-mutating members (Count, indexers, enumeration) with no Add/Remove. These are the correct return type for a public API that wants to expose a collection for reading without letting callers mutate the underlying storage — a much safer choice than returning a concrete List<T> or even ICollection<T>, both of which expose mutation methods that callers could invoke against your internal state.
Cricket analogy: Handing a journalist an IReadOnlyList<T> of the scorecard is like giving them a printed sheet of runs and overs to report on without letting them scribble changes into the official team ledger itself.
IEnumerable<T> inherits from the non-generic IEnumerable, and similarly ICollection<T>/IList<T> inherit from their non-generic .NET Framework 1.x predecessors. This dual hierarchy exists purely for backward compatibility from before generics existed in C# 2.0 — in modern code you should essentially always use the generic versions, since they avoid boxing for value types and provide compile-time type checking.
Calling .Count() (the LINQ extension method) on a type that already implements ICollection<T>, such as List<T>, is a subtle inefficiency trap if done carelessly through a variable typed as plain IEnumerable<T> — LINQ's Count() actually checks for ICollection<T>/ICollection at runtime and uses the O(1) Count property when available, so it's usually fine, but it's still clearer and marginally faster to use the .Count property directly whenever the static type already guarantees ICollection<T> or IReadOnlyCollection<T>.
Choosing interfaces for method signatures
A practical rule of thumb: accept the least capable interface that lets your method do its job (often IEnumerable<T> for read-only iteration, or ICollection<T> if you need to know the count or add items), and return the most capable and informative type reasonable for the caller — often a concrete type like List<T> or T[] from an internal implementation, or a read-only interface like IReadOnlyList<T> from a public API that wants to hide mutability. This asymmetry — liberal in what you accept, precise in what you return — keeps APIs both flexible and safe.
Cricket analogy: A net-bowling coach should be happy to bowl to 'any batter handed to him' but should hand back a full, specific report on that batter's technique, not just a vague 'they can bat'; accept broadly, return precisely.
- IEnumerable<T> is the root interface, exposing only forward-only iteration via GetEnumerator().
- ICollection<T> adds Count, Add, Remove, Contains, Clear; IList<T> further adds indexed access and positional Insert/RemoveAt.
- IDictionary<TKey,TValue> parallels this hierarchy for keyed access, extending ICollection<KeyValuePair<TKey,TValue>>.
- IReadOnlyList<T>/IReadOnlyCollection<T>/IReadOnlyDictionary expose read-only views with no mutation members, ideal for public API return types.
- Accept the narrowest interface a method needs as a parameter type; return the most useful/precise type for callers.
- Non-generic collection interfaces (IEnumerable, ICollection, IList) predate generics and should generally be avoided in modern code.
Practice what you learned
1. Which interface sits at the root of the .NET collection interface hierarchy, offering only iteration?
2. What capability does IList<T> add beyond ICollection<T>?
3. Why should a public API method return IReadOnlyList<T> instead of List<T> when exposing internal state for reading?
4. What is the recommended practice for choosing a parameter type for a method that only needs to read through a sequence once?
5. Why do non-generic interfaces like IEnumerable and IList still exist in .NET alongside their generic counterparts?
Was this page helpful?
You May Also Like
Arrays and Lists
Compare C#'s fixed-size arrays with the dynamically resizable List<T>, covering allocation, performance characteristics, and when to reach for each.
Generics Explained
Understand how C# generics let you write type-safe, reusable code that works across many types without sacrificing performance or requiring casts.
Dictionaries and Sets
Learn how Dictionary<TKey,TValue> and HashSet<T> use hashing for near-constant-time lookups, and how to use them correctly with custom key types.
LINQ Basics
Language Integrated Query lets you write expressive, type-safe queries directly in C# over collections, databases, and XML using a unified syntax.
Related Reading
Related Study Notes in Programming
Browse all study notesApache Spark Study Notes
Programming · 30 topics
ProgrammingApache Flink Study Notes
Programming · 30 topics
ProgrammingHadoop Study Notes
Programming · 30 topics
ProgrammingSnowflake Study Notes
Programming · 30 topics
ProgrammingApache Airflow Study Notes
Programming · 30 topics
Programmingdbt (Data Build Tool) Study Notes
Programming · 30 topics