C# Collection Interfaces: IEnumerable, ICollection, IList
Every collection you've learned — array, List, Dictionary, HashSet, Stack, Queue — secretly agrees to a shared set of contracts. This lesson draws the map.
Over the last six lessons, you've met arrays, List<T>, Dictionary<TKey, TValue>, HashSet<T>, Stack<T>, and Queue<T>. They look and behave quite differently — some are indexed, some aren't; some allow duplicates, some don't. And yet, you've been able to write foreach over every single one of them, with identical syntax, every time. That's not a coincidence. It's because every one of these types implements a small set of shared interfaces that describe "what it means to be a collection" in .NET.
In this lesson, you'll zoom out from individual collection types and see the interfaces that connect them all — why they exist, how they relate to each other, and how understanding this map lets you write code that works with "any collection," not just one specific type.
What Is It?
The Simple Explanation
Recall from the "Interfaces — First Look" lesson: an interface is a contract that says what a type can do, without saying how. The collection interfaces are a small family of contracts — IEnumerable<T>, ICollection<T>, IList<T>, and a few more — that describe increasingly specific capabilities a collection type might offer: "can be looped over," "has a count and can be modified," "can be indexed into," and so on.
The Technical Definition
The collection interfaces live in System.Collections.Generic and form a hierarchy — each one builds on the last, adding more capability (and more requirements) as you go. Every concrete collection type you've learned implements one or more of these interfaces, which is what lets generic, reusable code be written against the interface rather than any one specific type.
Why Does It Exist?
The Problem
Suppose you write a method that prints every item in a customer's order:
void PrintOrder(List<string> items)
{
foreach (string item in items)
Console.WriteLine(item);
}This works — until someone needs to call it with a string[] array instead of a List<string>, or a HashSet<string> of already-deduplicated items. None of those are a List<string>, so none of them can be passed in, even though PrintOrder doesn't actually need anything List-specific — it only ever loops over the items. The method is more restrictive than it needs to be, simply because it names one concrete type instead of describing the capability it actually depends on.
The Solution
Write the method against the interface that describes exactly the capability it needs — "can be looped over" — instead of a specific type:
void PrintOrder(IEnumerable<string> items)
{
foreach (string item in items)
Console.WriteLine(item);
}
PrintOrder(["Book", "Pen"]); // works with a List<string>
PrintOrder(new[] { "Book", "Pen" }); // works with a string[]
PrintOrder(new HashSet<string> { "Book" }); // works with a HashSet<string>Now the method accepts any collection that can be enumerated — array, list, set, or a type nobody's even written yet — because it depends on the smallest contract that satisfies its actual needs. This is the same "depend on the abstraction, not the concretion" principle you saw in the Interfaces lesson, applied specifically to collections.
Big Picture
IEnumerable<T> — "can be looped over"
ICollection<T> — + Count, Add, Remove, Contains
IList<T> — + this[int index]
IDictionary<TKey,TValue> — + this[TKey key]
IList<T> is also automatically an ICollection<T> and an IEnumerable<T>.
How It Works
- The most basic collection contract — it promises exactly one thing: the ability to walk through its elements, one at a time, in order.
- This is precisely what powers
foreach— you'll see exactly how in the next lesson, "IEnumerable<T> — First Look." - It doesn't promise a
Count, indexing, or the ability to add/remove anything — just "you can iterate me."
- Extends
IEnumerable<T>with aCountproperty and methods toAdd,Remove, and checkContains— the basic "manage a group of things" operations. - Still no positional indexing —
ICollection<T>doesn't promisethis[0]works.
- Extends
ICollection<T>with an indexer (this[int index]) plusInsertandRemoveAt— everything you've been using on arrays andList<T>. List<T>implementsIList<T>; arrays implement it too.
- A parallel branch that extends
ICollection<KeyValuePair<TKey, TValue>>with a key-based indexer (this[TKey key]), plusKeys,Values, andTryGetValue— everything you used withDictionary<TKey, TValue>.
IReadOnlyCollection<T>andIReadOnlyList<T>mirrorICollection<T>andIList<T>, but strip out every mutating member — noAdd, noRemove, noInsert. OnlyCountand read-only indexing remain.- These are what you reached for back in the
List<T>lesson's real-world example, exposing a shopping cart's items asIReadOnlyList<CartItem>— the caller can look, but not modify.
Simple Example
// Every one of these types satisfies IEnumerable<string>:
IEnumerable<string> fromArray = new[] { "a", "b", "c" };
IEnumerable<string> fromList = new List<string> { "a", "b", "c" };
IEnumerable<string> fromSet = new HashSet<string> { "a", "b", "c" };
void PrintAll(IEnumerable<string> items)
{
foreach (string item in items)
Console.WriteLine(item);
}
PrintAll(fromArray); // works
PrintAll(fromList); // works
PrintAll(fromSet); // works — same method, three completely different underlying typesCode → Meaning → Result:
- One method,
PrintAll, works with three structurally very different collections — because it only asked for the one capability (enumeration) it actually needed. - This is the payoff of the interface hierarchy: code written against a small interface automatically works with every collection that implements it, present and future.
Real-World Example
A reporting module needs to calculate the total value of "whatever collection of orders it's handed" — it shouldn't care whether that's a List<Order> fetched from a database, an array built in a unit test, or some other IEnumerable<Order> entirely.
public record Order(string CustomerName, decimal Total);
public class OrderReport
{
// Depends only on the capability it needs: enumeration.
public decimal CalculateGrandTotal(IEnumerable<Order> orders)
{
decimal grandTotal = 0;
foreach (Order order in orders)
grandTotal += order.Total;
return grandTotal;
}
}
var report = new OrderReport();
List<Order> ordersFromDatabase =
[
new Order("Amy", 42.50m),
new Order("Ben", 18.00m),
];
Console.WriteLine(report.CalculateGrandTotal(ordersFromDatabase)); // 60.50
Order[] ordersFromTest =
[
new Order("Test Customer", 100.00m),
];
Console.WriteLine(report.CalculateGrandTotal(ordersFromTest)); // 100.00 — same method, no changes neededBecause CalculateGrandTotal asks for IEnumerable<Order> instead of a concrete List<Order>, it's automatically reusable in a unit test with a simple array — no need to convert anything, and no risk of the method quietly depending on List-only features it doesn't actually need.
Analogy
A basic driver's license lets you drive a car — that's the base capability. A commercial license builds on top of that same foundation, adding the ability to drive trucks. A motorcycle endorsement adds a different, additional capability. Each license "is a" driver's license (they all share the base rules — obey traffic signals, carry insurance) while adding more specific permissions on top.
The collection interfaces work the same way: IEnumerable<T> is the "base license" every collection holds — the right to be looped over. ICollection<T> and IList<T> are more specific "endorsements" layered on top, each adding capabilities without ever taking away the base one.
Under the Hood
- Array — implements
IList<T>(and thereforeICollection<T>andIEnumerable<T>), though itsAdd/Removethrow at runtime since arrays can't resize — a quirk worth knowing, but not something you'll run into if you stick to.Lengthand indexing. List<T>— implementsIList<T>,ICollection<T>,IEnumerable<T>, and the read-only variants too.Dictionary<TKey, TValue>— implementsIDictionary<TKey, TValue>,ICollection<KeyValuePair<TKey, TValue>>, andIEnumerable<KeyValuePair<TKey, TValue>>.HashSet<T>— implementsICollection<T>andIEnumerable<T>, but deliberately notIList<T>— there's no indexer, consistent with everything you learned in the HashSet lesson.Stack<T>andQueue<T>— implementIEnumerable<T>(so you canforeachover them to inspect, without disturbing them), but deliberately notICollection<T>orIList<T>— no genericAdd/Remove/indexer, only their specificPush/PoporEnqueue/Dequeue.
- This is a direct consequence of what you learned in the previous lesson:
ICollection<T>would require a genericAdd/Remove, which would let arbitrary code violate the LIFO/FIFO ordering guarantee those types exist to protect. - The interface a type chooses to implement is itself a design decision — it's a precise declaration of "here's exactly what you're allowed to assume about me."
Common Confusion
1. IEnumerable<T> vs ICollection<T> vs IList<T> as method parameters
A common beginner habit is always writing List<T> as a parameter type "just because that's what I usually pass." The better habit: ask "what does this method actually need to do?" If it only loops, accept IEnumerable<T>. If it needs Count or Add, accept ICollection<T>. If it needs indexing, accept IList<T>. Accepting the smallest interface that satisfies your needs makes your method usable with the widest range of collections.
2. IReadOnlyList<T> doesn't mean the underlying collection can never change
It means that specific reference can't be used to change it. If a class exposes public IReadOnlyList<CartItem> Items => _items;, external code can't call .Add() through Items — but the class itself can still mutate _items internally, and those changes will show up the next time Items is read.
3. Interface names starting with a capital "I" is a .NET convention, not a rule of the language
C# doesn't require interface names to start with "I" — it's purely a widely followed naming convention across the entire .NET ecosystem, which is why every interface in this lesson follows the same pattern.
Common Mistakes
Mistake 1 — Requiring a more specific interface than the method actually needs
Too restrictive — this method never indexes or adds anything, yet it demands IList<T>:
decimal Sum(IList<decimal> values) // unnecessarily narrow
{
decimal total = 0;
foreach (decimal v in values)
total += v;
return total;
}
// Sum(someHashSet) // won't compile — HashSet isn't an IListCorrect — accept only what's actually used:
decimal Sum(IEnumerable<decimal> values) // works with anything enumerable
{
decimal total = 0;
foreach (decimal v in values)
total += v;
return total;
}Mistake 2 — Exposing a mutable interface when you meant to protect the data
Wrong — IList<T> still lets outside code modify the collection:
public IList<CartItem> Items => _items; // callers can still Add/Remove through thisCorrect — use the read-only interface when the intent is "look, don't touch":
public IReadOnlyList<CartItem> Items => _items; // callers can only readMistake 3 — Assuming every IEnumerable<T> has a Count or supports indexing
This won't compile — IEnumerable<T> deliberately doesn't promise either capability, even though many collections you've used do happen to have them. If you need Count or indexing, say so explicitly by requiring ICollection<T> or IList<T> instead.
When Should I Use It?
| You need to... | Accept this interface |
|---|---|
| Just loop over the items once | IEnumerable<T> |
| Know the count, add/remove, check membership | ICollection<T> |
| Access items by numeric position | IList<T> |
| Look up values by a key | IDictionary<TKey, TValue> |
| Expose data for reading only, protecting it from external changes | IReadOnlyList<T> / IReadOnlyCollection<T> |
Mental Model
ICollection<T> = IEnumerable<T> + Count, Add, Remove, Contains
IList<T> = ICollection<T> + indexed access
IDictionary<TKey,TValue> = ICollection<...> + key-based access
IReadOnly... variants = the same shapes, minus every mutating member
Remember:
· Every collection you've learned implements
IEnumerable<T> — that's why foreach always just works.· Accept the narrowest interface a method genuinely needs; it works with the widest range of collections.
· Not implementing a higher interface is often a deliberate design choice (see Stack/Queue), not a limitation.
Key Takeaway
- Every collection type in .NET implements a shared hierarchy of interfaces —
IEnumerable<T>at the base, withICollection<T>,IList<T>, andIDictionary<TKey, TValue>adding capability on top. - This shared foundation is why
foreachworks identically across arrays, lists, dictionaries, sets, stacks, and queues. - Which interfaces a type implements is a deliberate design choice —
HashSet<T>skipsIList<T>because it has no meaningful index;Stack<T>/Queue<T>skipICollection<T>to protect their ordering guarantees. - Write method parameters against the narrowest interface that satisfies the method's actual needs — it's more flexible for callers, and more honest about what your code depends on.
- Use the
IReadOnly...variants to expose a collection for reading without letting outside code mutate it.
Check Your Understanding
You've seen how the collection interfaces connect every type you've learned in this module. Let's check your understanding.
1. Why can the same foreach syntax be used on an array, a List<T>, and a HashSet<T> without any special-casing?
Show answer
Correct: B
Why B is correct: Every collection type covered in this module implements IEnumerable<T>, the base interface that guarantees "I can be iterated." foreach is built to work against exactly that contract, which is why it works identically everywhere.
Why A is incorrect: No conversion to an array happens — each type provides its own way of walking through its elements.
Why C is incorrect: This behavior is guaranteed, not coincidental — it's a deliberate consequence of the shared interface hierarchy.
Why D is incorrect: Arrays, List<T>, and HashSet<T> are genuinely different types with very different internal storage — they just happen to share this one common contract.
Reinforcement: IEnumerable<T> is the shared foundation underneath every collection you've learned.
2. Why does HashSet<T> not implement IList<T>?
Show answer
Correct: B
Why B is correct: As you learned in the HashSet lesson, elements are placed according to their hash code, not a meaningful sequence. Implementing IList<T> would require a working indexer, which would be misleading for a structure with no genuine positional order.
Why A is incorrect: This is a deliberate, considered design decision that reflects what a hash set fundamentally is — not a bug or a gap.
Why C is incorrect: HashSet<T> absolutely can be looped over — it implements IEnumerable<T>, just not the more specific IList<T>.
Why D is incorrect: IList<T> works with any type, value or reference — that's not the relevant distinction here.
Reinforcement: Which interfaces a type implements reflects genuine capability, not an arbitrary limitation.
3. A method needs to count how many items are in a collection and check whether a specific item is present, but never needs to access an item by position. Which parameter type best expresses that?
Show answer
Correct: B
Why B is correct: ICollection<T> is exactly the narrowest interface offering both Count and Contains — matching what the method actually needs, without demanding indexing it will never use.
Why A is incorrect: IEnumerable<T> doesn't include Count or Contains as guaranteed members — using it here would force the method to enumerate manually to get information it could ask for directly.
Why C is incorrect: Requiring IList<T> would exclude perfectly valid callers like HashSet<T>, which the method never needed to exclude in the first place.
Why D is incorrect: Naming one concrete type is far more restrictive than necessary, and locks out arrays, lists, and sets entirely.
Reinforcement: Match the interface to the actual capabilities the method uses — no more, no less.
4. A class exposes a property as public IReadOnlyList<string> Tags => _tags;. What does this actually prevent?
Show answer
Correct: B
Why B is correct: IReadOnlyList<T> simply omits mutating members from the exposed contract. Code outside the class, working only through Tags, has no way to call Add or Remove — but the class's own internal code can still freely modify _tags, and those changes are visible the next time Tags is read.
Why A is incorrect: The restriction only applies to code using the public Tags property — the class's own internal methods can still mutate _tags directly.
Why C is incorrect: This is an interface-level restriction on what operations are exposed, not a true immutability guarantee at the memory or object level.
Why D is incorrect: Reading is exactly what IReadOnlyList<T> is for — it fully supports enumeration, Count, and indexed reads.
Reinforcement: Read-only interfaces restrict what external callers can do through that specific reference — they don't make the underlying data immutable.
You've now seen the map that ties every collection type together. Next, you'll look closely at the interface that started it all — IEnumerable<T> — and see exactly how foreach uses it under the hood.