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.
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 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.
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.
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.
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>.
foreach — you'll see exactly how in the next lesson, "IEnumerable<T> — First Look."Count, indexing, or the ability to add/remove anything — just "you can iterate me."IEnumerable<T> with a Count property and methods to Add, Remove, and check Contains — the basic "manage a group of things" operations.ICollection<T> doesn't promise this[0] works.ICollection<T> with an indexer (this[int index]) plus Insert and RemoveAt — everything you've been using on arrays and List<T>.List<T> implements IList<T>; arrays implement it too.ICollection<KeyValuePair<TKey, TValue>> with a key-based indexer (this[TKey key]), plus Keys, Values, and TryGetValue — everything you used with Dictionary<TKey, TValue>.IReadOnlyCollection<T> and IReadOnlyList<T> mirror ICollection<T> and IList<T>, but strip out every mutating member — no Add, no Remove, no Insert. Only Count and read-only indexing remain.List<T> lesson's real-world example, exposing a shopping cart's items as IReadOnlyList<CartItem> — the caller can look, but not modify.// 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:
PrintAll, works with three structurally very different collections — because it only asked for the one capability (enumeration) it actually needed.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.
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.
IList<T> (and therefore ICollection<T> and IEnumerable<T>), though its Add/Remove throw at runtime since arrays can't resize — a quirk worth knowing, but not something you'll run into if you stick to .Length and indexing.List<T> — implements IList<T>, ICollection<T>, IEnumerable<T>, and the read-only variants too.Dictionary<TKey, TValue> — implements IDictionary<TKey, TValue>, ICollection<KeyValuePair<TKey, TValue>>, and IEnumerable<KeyValuePair<TKey, TValue>>.HashSet<T> — implements ICollection<T> and IEnumerable<T>, but deliberately not IList<T> — there's no indexer, consistent with everything you learned in the HashSet lesson.Stack<T> and Queue<T> — implement IEnumerable<T> (so you can foreach over them to inspect, without disturbing them), but deliberately not ICollection<T> or IList<T> — no generic Add/Remove/indexer, only their specific Push/Pop or Enqueue/Dequeue.ICollection<T> would require a generic Add/Remove, which would let arbitrary code violate the LIFO/FIFO ordering guarantee those types exist to protect.IEnumerable<T> vs ICollection<T> vs IList<T> as method parametersA 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.
IReadOnlyList<T> doesn't mean the underlying collection can never changeIt 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.
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.
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;
} 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 readIEnumerable<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.
| 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> |
IEnumerable<T> — that's why foreach always just works.IEnumerable<T> at the base, with ICollection<T>, IList<T>, and IDictionary<TKey, TValue> adding capability on top.foreach works identically across arrays, lists, dictionaries, sets, stacks, and queues.HashSet<T> skips IList<T> because it has no meaningful index; Stack<T>/Queue<T> skip ICollection<T> to protect their ordering guarantees.IReadOnly... variants to expose a collection for reading without letting outside code mutate it.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?
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>?
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?
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?
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.
dotnetmadeeasy.com — Learn C# and .NET, the right way.