← Open in the full interactive course (progress tracking, search & more)

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

THE COLLECTION INTERFACE HIERARCHY
IEnumerable<T> — "can be looped over"
ICollection<T> — + Count, Add, Remove, Contains
IList<T> — + this[int index]
IDictionary<TKey,TValue> — + this[TKey key]
Each level adds capability — and each level requires everything below it too. An IList<T> is also automatically an ICollection<T> and an IEnumerable<T>.

How It Works

WALKING UP THE HIERARCHY, LEVEL BY LEVEL
LEVEL 1 — IEnumerable<T>: "I CAN BE LOOPED OVER"
LEVEL 2 — ICollection<T>: "+ COUNT, ADD, REMOVE, CONTAINS"
LEVEL 3A — IList<T>: "+ INDEXED ACCESS"
LEVEL 3B — IDictionary<TKey, TValue>: "+ KEY-BASED ACCESS"
LEVEL 4 — THE "READ-ONLY" SIBLINGS

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 types

Code → Meaning → Result:

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 needed

Because 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

Driver's License Categories

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

WHICH CONCRETE TYPES IMPLEMENT WHICH INTERFACES
A QUICK REFERENCE MAP
WHY STACK AND QUEUE STOP AT IEnumerable<T>

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 IList

Correct — 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

WrongIList<T> still lets outside code modify the collection:

public IList<CartItem> Items => _items; // callers can still Add/Remove through this

Correct — use the read-only interface when the intent is "look, don't touch":

public IReadOnlyList<CartItem> Items => _items; // callers can only read

Mistake 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 onceIEnumerable<T>
Know the count, add/remove, check membershipICollection<T>
Access items by numeric positionIList<T>
Look up values by a keyIDictionary<TKey, TValue>
Expose data for reading only, protecting it from external changesIReadOnlyList<T> / IReadOnlyCollection<T>
Rule of thumb: For a parameter, accept the narrowest interface that gets the job done — it maximizes what callers can pass in. For a return type you want to protect from outside mutation, return the narrowest read-only interface that gives callers what they need to read.

Mental Model

IEnumerable<T> = "can be looped over" — the base of everything
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


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.


dotnetmadeeasy.com — Learn C# and .NET, the right way.