AlgoMaster Logo

IEnumerable & ICollection

Last Updated: May 17, 2026

19 min read

Every collection in .NET, from List<T> to Dictionary<TKey, TValue> to HashSet<T>, ultimately implements the same small family of interfaces. This lesson covers that family: what each interface adds, why the hierarchy is shaped the way it is, and how foreach and LINQ use it under the hood. The rest of the Collections section leans on the vocabulary built here, so when later lessons say "any IEnumerable<T>" or "any IList<T>," the meaning will already be in your head.

Why the Hierarchy Exists

A collection is just a thing you can pull elements out of. Some collections also let you count, add, remove, look up by index, look up by key, or check whether they hold a value. The Base Class Library expresses each of those capabilities as a separate interface, and the concrete collection types implement whichever set of interfaces fits what they actually support.

The reason for splitting capabilities across interfaces, instead of putting everything on a single Collection base class, is that not every collection supports every operation. A Queue<T> doesn't let you index by position. A HashSet<T> doesn't have a meaningful "first item." A read-only view doesn't let you add. If every collection had to support every method, half the calls would throw NotSupportedException, and you'd lose the ability to express "I just need to iterate" or "I just need to count" in a type signature.

The hierarchy starts at the most permissive contract (just iteration) and adds capabilities as you move down. A method that only needs to read items in order asks for IEnumerable<T>. A method that also needs to count without iterating asks for IReadOnlyCollection<T>. A method that needs to mutate the collection asks for ICollection<T> or IList<T>. The narrower the parameter type, the more callers can pass into the method, and the fewer assumptions the method is allowed to make.

Here's how the main interfaces connect:

The cyan boxes on the left are the read-only contracts. The orange boxes on the right are the mutating contracts. The green boxes at the bottom are the concrete types you'll use day to day. Every concrete collection sits at the bottom of one or more chains, and every chain starts at IEnumerable<T> at the top. That's the one interface every collection implements, and it's the place to start.

IEnumerable&lt;T&gt;: The Floor

IEnumerable<T> is the smallest possible collection contract. It declares exactly one method:

That's the whole thing. No Count, no Add, no indexer, no Contains. Just GetEnumerator(), which returns an object you can use to walk through the items one at a time. Anything that can be walked from start to finish implements this interface.

Because the contract is so small, almost everything fits. Arrays implement it. List<T>, Dictionary<TKey, TValue>, HashSet<T>, Queue<T>, Stack<T> all implement it. Strings implement IEnumerable<char>. LINQ operators like Where, Select, and OrderBy both consume and return IEnumerable<T>. A method written against IEnumerable<T> works with every one of those.

The same SumPrices method runs against both a decimal[] and a List<decimal>. Neither caller had to convert anything, because both types implement IEnumerable<decimal>. The method doesn't know or care which kind of collection it received. It just walks the items.

That generality is why public APIs often take IEnumerable<T> as a parameter and return IEnumerable<T> from query methods. The narrower the contract, the more callers can use it, and the easier it is to swap the implementation later. If SumPrices had been written as static decimal SumPrices(List<decimal> prices), the array call wouldn't compile, and you'd have to allocate a new list just to satisfy the type.

The non-generic IEnumerable (without the <T>) is the older interface from .NET Framework 1.0. IEnumerable<T> inherits from it, which is why every generic collection also implements the non-generic version. You'll see the non-generic form in old code and in reflection-heavy APIs, but for new code, always reach for IEnumerable<T>.

One more thing worth noticing: IEnumerable<T> is covariant in T (the out T in the declaration). That means IEnumerable<Product> is assignable to IEnumerable<object>, because anything you can read from a sequence of products can also be read as a sequence of objects. This is one of the reasons returning IEnumerable<T> from a method is so flexible. A method that returns IEnumerable<DerivedType> can be assigned to a variable of type IEnumerable<BaseType> without an explicit cast.

IEnumerator&lt;T&gt;: The Cursor

IEnumerator<T> is what GetEnumerator() actually returns. If IEnumerable<T> is "this thing can be walked," IEnumerator<T> is "here is the cursor doing the walking." The contract has three members that matter:

MoveNext() advances the cursor to the next item and returns true if there is one, false if you've run off the end. Current exposes the item the cursor is currently pointing at, but only after MoveNext() has been called at least once and returned true. Reading Current before the first MoveNext(), or after MoveNext() returns false, has undefined behavior depending on the implementation, and most enumerators will either throw or return a default value.

Reset() is supposed to move the cursor back to the start, but it's mostly a historical wart. Many enumerators don't support it and throw NotSupportedException. You shouldn't call it in normal code.

IEnumerator<T> also implements IDisposable, which is why foreach always calls Dispose() on the enumerator when the loop ends. For most collections Dispose() does nothing, but for things like database cursors or file streams it releases the underlying resource.

You can drive an enumerator by hand without foreach, and it's worth doing once to see the shape:

The pattern is: get the cursor, loop while MoveNext() returns true, read Current inside the loop body, and dispose the cursor when you're done. The try/finally is there because the cursor might hold a resource, and you want to release it whether the loop finishes normally or throws.

If that pattern looks like a lot of boilerplate, it is. That's exactly why foreach exists.

foreach Is Just Syntax

foreach is not a magic looping construct. It's syntactic sugar that the compiler lowers (rewrites) into the exact pattern from the previous example. When you write:

The compiler generates code that looks roughly like this:

Everything is there: the call to GetEnumerator(), the while (MoveNext()) loop, the read of Current, and the disposal in a finally. The loop variable name is declared fresh on each iteration. The disposal happens whether the loop completes normally, exits via break, or escapes via an exception.

Here's the lowering as a diagram:

The cyan path is the setup. The orange diamond is the per-iteration check. The teal box is the user-written body. The green path is the cleanup. Every foreach in your codebase is some variant of this shape.

A useful consequence of the lowering: the compiler doesn't actually require the target to implement IEnumerable<T>. It just requires that the target has a GetEnumerator() method (any visibility, any return type) that returns something with a MoveNext() method and a Current property. This is the "duck-typed" form of foreach. It's why custom collection types can implement foreach support without formally implementing the interface, and it's what makes foreach over a Span<T> or a ref struct enumerator possible (interfaces wouldn't work there).

In practice, almost every type you'll iterate does implement IEnumerable<T>, so the distinction rarely shows up.

Dictionary<TKey, TValue>.GetEnumerator() returns an enumerator over KeyValuePair<TKey, TValue>. The foreach lowers into the same pattern: get cursor, MoveNext until false, dispose. The dictionary's enumeration order isn't guaranteed across .NET versions, but for any single run, you'll see each key-value pair exactly once.

Deferred Execution and IEnumerable

Returning IEnumerable<T> from a method has a property that returning List<T> doesn't: it can defer the work. The method can hand back an object that hasn't computed anything yet, and the items only get produced when the caller iterates. This is how LINQ achieves its lazy evaluation, and it's worth understanding because the same trick shows up in your own code whenever you use yield return.

Here's the same query written two ways:

expensiveNow was materialized into a List<decimal> the moment ToList() ran. At that point the source had two items above $50, so the list has two. Adding 500m to prices later doesn't change the list.

expensiveLater is still an IEnumerable<decimal> and the Where filter hasn't actually been applied yet. When Count() calls GetEnumerator() on it, the enumerator walks the current state of prices, which now has three items above $50. The deferred query sees the addition because the iteration happens after the mutation.

This is the single biggest thing to internalize about IEnumerable<T>: it represents a query plan, not a snapshot. If you want a snapshot, call .ToList() or .ToArray() to materialize it. If you want freshness, keep it as IEnumerable<T> and re-enumerate when you need current data.

The same pattern shows up when you write iterator methods with yield return. The method body doesn't actually run when the caller invokes the method, it runs incrementally as the caller pulls items via MoveNext(). The _Iterator Pattern (yield)_ lesson covers the mechanics of yield. For now, the takeaway is that IEnumerable<T> is the surface area that makes deferred execution possible: a query, an iterator, a database cursor, an infinite sequence, anything that produces items on demand can hide behind it.

ICollection&lt;T&gt;: Add, Remove, Count, Contains

IEnumerable<T> is enough for reading. The moment you want to mutate the collection or ask "how many items?" without iterating, you need ICollection<T>. It extends IEnumerable<T> and adds a small set of operations every mutable collection supports:

Count returns the number of items in O(1) (every BCL collection that implements ICollection<T> tracks this). Contains checks membership, but the cost varies wildly: O(n) on List<T>, O(1) average on HashSet<T>. Add and Remove modify the collection. Clear empties it. CopyTo copies the items into a destination array.

IsReadOnly is a runtime hint that says "this collection rejects mutations." If you call Add on a collection whose IsReadOnly is true, it throws NotSupportedException. This is the pattern arrays use when they're exposed as ICollection<T>: an array's length is fixed, so Add and Remove aren't actually supported, and the array's IsReadOnly returns true. The shape of the interface is "everyone implements all the methods, and some of them are allowed to throw at runtime if the underlying type can't honor them." That design predates the read-only interfaces (which we'll see shortly) and is one reason those exist.

Two things to notice. First, the same Report method works on both List<string> and string[] because both implement ICollection<string>. The method has no idea what concrete type was passed. Second, the array reports IsReadOnly=True. If Report had tried to call catalog.Add("something") through the ICollection<string> reference, the call would have compiled (the method is on the interface) but thrown NotSupportedException at runtime, because arrays can't grow.

The Count property on ICollection<T> is what lets LINQ optimize. When you call .Count() (the LINQ method) on an IEnumerable<T>, it checks at runtime whether the source actually implements ICollection<T>, and if so, it reads the property directly instead of iterating. That's the difference between O(1) and O(n) for a count check. The same trick happens in .ToList() and .ToArray(), which use Count to pre-size the destination.

IList&lt;T&gt;: Indexing, Insert, RemoveAt

IList<T> extends ICollection<T> with position-based operations:

The indexer this[int index] reads and writes elements by position. IndexOf returns the first position of an item or -1 if it's not there. Insert puts an item at a specific position, shifting everything after it one slot to the right. RemoveAt removes the item at a specific position, shifting everything after it one slot to the left.

Both List<T> and arrays implement IList<T>, which is why you can pass either one to a method that takes IList<T> and use the indexer:

Same method, two different concrete types, both working through the IList<T> view. The indexer call items[0] works identically on the list and the array because both implement the same interface contract.

Insert and RemoveAt are where things get more interesting, because they're position-based mutations. List<T> supports them; arrays implement them but throw NotSupportedException since their size is fixed. That's IsReadOnly at work again: arrays report IsReadOnly=true, and the mutation methods all throw.

Insert(1, "Mouse Pad") pushes Keyboard from index 1 to index 2, and Monitor from 2 to 3, then writes the new value into the freed slot. RemoveAt(0) shifts every remaining element one position to the left.

The choice between IList<T> and ICollection<T> as a parameter type comes down to one question: does your method care about positions? If you only need to count, add, remove by value, or iterate, ask for ICollection<T>. If you need to read or write by index, ask for IList<T>. Asking for more than you need just narrows the set of callers who can pass into the method, with no benefit.

IDictionary&lt;TKey, TValue&gt;: Keys, Values, Lookup

IDictionary<TKey, TValue> is the contract for collections that map keys to values. It extends ICollection<KeyValuePair<TKey, TValue>> (so the element type is KeyValuePair<TKey, TValue> for iteration purposes) and adds key-aware operations:

The indexer is the headline operation: dict[key] reads or writes by key, in O(1) average time for hash-based dictionaries. ContainsKey checks key existence. TryGetValue does a lookup that doesn't throw if the key's missing, which is the right shape when "not found" is a normal outcome.

The ReportPrice method takes an IDictionary<string, decimal>, so it could accept any dictionary implementation, not just Dictionary<TKey, TValue>. TryGetValue returns false for missing keys without throwing, which is the recommended way to check-and-fetch in one step. The Keys property returns a live view of the keys, not a snapshot: if you add an entry to the dictionary, Keys.Count reflects it.

The Add overload that takes a key and a value is more explicit than the indexer. dict.Add("Mouse", 19.99m) throws if the key already exists. dict["Mouse"] = 19.99m overwrites silently. Pick the one that matches your intent: Add for "this should be a new entry, fail loudly otherwise," indexer for "set this value regardless."

The Keys and Values properties return ICollection<TKey> and ICollection<TValue> respectively. They support iteration and Count, but mutating them (e.g., calling Add on the keys collection) throws NotSupportedException. They're meant for reading, not for changing the dictionary through a side door.

ISet&lt;T&gt;: Set Algebra

ISet<T> is the contract for unordered collections with no duplicates. It extends ICollection<T> with set-theoretic operations:

Notice the new on Add. ICollection<T>.Add returns void; ISet<T>.Add returns bool, because a set add can be a no-op (the item was already there). The two methods have different signatures, and the new keyword tells the compiler that ISet<T>.Add hides the inherited one.

Add returned false because alice@example.com was already in the set. The count stayed at 2. IntersectWith mutated repeatBuyers in place, keeping only the emails that also appear in pastBuyers. The set operations are all destructive in this sense: they modify the receiver rather than returning a new set. If you want a fresh set, copy first (new HashSet<T>(source)) and then operate.

The full power of ISet<T> shows up when you need to express things like "products in this category but not in the user's wishlist" or "categories shared between two stores." Those are set operations, and writing them as loops with Contains is both slower (because each Contains is an O(1) hash lookup, but you do it n times) and harder to read.

The non-mutating side of ISet<T> is just as useful. IsSubsetOf, IsSupersetOf, Overlaps, and SetEquals answer relational questions about two sets without modifying either one. IsSubsetOf returns true when every element of the receiver is also in the argument. Overlaps returns true when there's at least one shared element, which is the right tool when you want to check "does this user's wishlist contain anything from today's sale?" without computing the full intersection. SetEquals returns true when both sets have exactly the same elements, ignoring order. Each of these runs in time proportional to the size of the smaller set, which is faster than building the intersection by hand.

One subtlety: HashSet<T> uses the element type's Equals and GetHashCode to decide membership. If the element is a custom class, the default implementations compare references, not contents, which usually isn't what you want. Override both methods on the type, or pass an IEqualityComparer<T> to the HashSet<T> constructor. The same applies to Dictionary<TKey, TValue> for its keys. Comparers get their own lesson in _IComparer & IEqualityComparer_.

IReadOnlyCollection&lt;T&gt;, IReadOnlyList&lt;T&gt;, IReadOnlyDictionary&lt;TKey, TValue&gt;

The read-only interfaces, added in .NET 4.5, are a cleaner answer to "I want to expose data without letting callers mutate it." They don't have Add, Remove, or Clear methods at all. There's nothing to throw, because the methods aren't on the interface in the first place.

List<T> implements IReadOnlyList<T>. Dictionary<TKey, TValue> implements IReadOnlyDictionary<TKey, TValue>. T[] implements IReadOnlyList<T>. So every common collection has a read-only view available, and you don't have to wrap it in a ReadOnlyCollection<T> to expose it that way. You just declare your method or property as the read-only interface type.

The Items property exposes the internal list as IReadOnlyList<string>. Callers can read by index, iterate, and check the count. They can't add or remove, because the methods aren't on the interface. The compiler catches the misuse, no runtime check needed.

The catch: it's a view, not a copy. The caller can't mutate through the read-only interface, but if the caller knows the underlying type and casts back to List<T>, they can mutate. The interface protects against accidents and honest mistakes, not against an adversary downcasting on purpose. For genuinely immutable data, see the _Immutable Collections_ lesson (ImmutableList<T> and friends).

The read-only interfaces are also covariant in their element type (the out T in the declaration). That means IReadOnlyList<Product> is assignable to IReadOnlyList<object>. The mutable IList<T> is not covariant, because you could call Add on the upcast view and break type safety. This covariance is occasionally useful when mixing related types in a common signature.

Worth flagging the gap in IReadOnlyCollection<T>: it doesn't include Contains. If you want to check membership through a read-only contract, you'll either use the LINQ Contains extension (which falls back to iteration if the underlying type isn't an ICollection<T>), or you'll expose the underlying collection through IReadOnlyDictionary<TKey, TValue> for key-based lookup or ISet<T> for set-based lookup. The read-only interface family was designed around the common cases of "iterate, count, read by index, read by key," and other lookups didn't make the cut.

Picking the Right Interface for a Parameter

The rule of thumb for parameter types is "the smallest interface that supports what your method actually does." Asking for more capability than you need cuts off callers and makes the signature lie about what the method will do.

Method needs to...Use
Iterate items onlyIEnumerable<T>
Iterate and countIReadOnlyCollection<T>
Read by indexIReadOnlyList<T>
Look up by keyIReadOnlyDictionary<TKey, TValue>
Add or remove items by valueICollection<T>
Read/write by index, insert, remove atIList<T>
Read/write by key, add/remove keysIDictionary<TKey, TValue>
Compute unions, intersections, set membershipISet<T>

Reverse logic for return types. A method that builds a list internally and returns it should advertise as much capability as is genuinely safe for the caller to have. Returning IEnumerable<T> when the result is a fully materialized List<T> is a tax on the caller (they may now have to call .Count() or .ToList() to do basic things). Returning the concrete List<T> exposes implementation details and forgoes deferred execution if you ever want to switch.

The pragmatic middle ground for return types: IReadOnlyList<T> for materialized snapshots and IEnumerable<T> for deferred queries (or anything that can hide an iterator behind yield return). That's not a hard rule, but it covers most of the choices you'll make in production code.

A Quick Tour Through the Implementations

To pin all of this together, here's a small table mapping the concrete collection types to the interfaces they implement. The Collections section covers each of these in detail, so this is just the map.

Concrete typeIEnumerable<T>ICollection<T>IList<T>IDictionary<K,V>ISet<T>IReadOnlyList<T>IReadOnlyDictionary<K,V>
T[]YesYes (read-only)Yes (no add/remove)NoNoYesNo
List<T>YesYesYesNoNoYesNo
LinkedList<T>YesYesNoNoNoNoNo
Dictionary<TKey,TValue>YesYesNoYesNoNoYes
HashSet<T>YesYesNoNoYesNoNo
SortedSet<T>YesYesNoNoYesNoNo
Queue<T>YesYes (read-only Count)NoNoNoNoNo
Stack<T>YesYes (read-only Count)NoNoNoNoNo
ImmutableList<T>YesYes (read-only)Yes (returns new)NoNoYesNo

A few notes on the cells. Arrays implement IList<T> but report IsReadOnly=true and throw on Add and Remove; they do support indexer reads and writes within the fixed size. LinkedList<T> doesn't implement IList<T> because random access by index isn't a natural operation on a linked list. Queue<T> and Stack<T> implement ICollection (the non-generic) and IEnumerable<T>, but not ICollection<T>, because Add doesn't map cleanly onto enqueue or push. Immutable collections implement the read-only interfaces and the mutable ones, but the mutable methods return new collections rather than modifying in place.

This is the lens for the rest of the Collections section. When you read "List<T> implements IList<T>," that now tells you everything List<T> supports as a parameter type. When you read "Queue<T> implements only IEnumerable<T> and ICollection," you know it's iteration-and-count, nothing else.

Iterator Methods: A Teaser

Anywhere you can write a method that returns IEnumerable<T>, you can also write one that uses yield return to produce items lazily without manually building an enumerator. The compiler generates the state machine for you.

The method body doesn't run when you call CountDown(3). It runs incrementally as foreach calls MoveNext() on the enumerator, pausing at each yield return and resuming on the next MoveNext(). The mechanics of how the compiler rewrites this into a state machine, what happens to local variables across yields, and the rules around yield break, are all the subject of the _Iterator Pattern (yield)_ lesson. For now, the takeaway is that yield return is one of the easiest ways to produce an IEnumerable<T>, and it's the mechanism behind many LINQ operators.

Summary

  • IEnumerable<T> declares GetEnumerator() and is the floor every collection implements. A method that takes IEnumerable<T> accepts every standard collection plus arrays and LINQ queries.
  • IEnumerator<T> is the cursor: MoveNext advances, Current reads, Dispose cleans up. foreach lowers into the exact pattern of "get enumerator, loop on MoveNext, dispose in finally."
  • Returning IEnumerable<T> enables deferred execution. The query plan runs each time it's enumerated, so subsequent changes to the source can show up if you iterate again.
  • ICollection<T> adds Count, Add, Remove, Clear, Contains. IList<T> adds positional operations (indexer, Insert, RemoveAt). IDictionary<TKey, TValue> adds key-based lookup. ISet<T> adds set algebra.
  • The read-only interfaces (IReadOnlyCollection<T>, IReadOnlyList<T>, IReadOnlyDictionary<TKey, TValue>) expose only reads, so misuse is a compile error rather than a runtime NotSupportedException. They're the right return type for materialized snapshots you don't want callers mutating.
  • Choose parameter types from the smallest interface that supports what the method actually does. This widens the set of callers and makes the contract honest.
  • Most concrete collections implement multiple interfaces. List<T> is also IList<T>, IReadOnlyList<T>, ICollection<T>, and IEnumerable<T>. The interface you choose for a variable or parameter decides what operations are available at compile time.

Summary

  • IEnumerable<T> declares GetEnumerator() and is the floor every collection implements. Methods that only need to iterate should take IEnumerable<T> as their parameter type.
  • IEnumerator<T> is the cursor returned by GetEnumerator(). MoveNext advances, Current reads, and Dispose cleans up when iteration ends.
  • foreach is syntactic sugar that lowers into a try/finally around a while (MoveNext()) loop, with Dispose in the finally. The compiler doesn't require formal IEnumerable<T> implementation, just the right shape.
  • Returning IEnumerable<T> enables deferred execution. The pipeline runs fresh on each enumeration, which is both LINQ's superpower and its sharpest performance edge.
  • ICollection<T> adds Count, Add, Remove, Clear, Contains. IList<T> adds indexed access, Insert, and RemoveAt. IDictionary<TKey, TValue> adds key-based lookup. ISet<T> adds union, intersection, and other set operations.
  • The read-only interfaces (IReadOnlyCollection<T>, IReadOnlyList<T>, IReadOnlyDictionary<TKey, TValue>) expose only reads, so accidental mutation becomes a compile error instead of a runtime NotSupportedException.
  • The rule for parameter types is "the smallest interface that supports what your method actually does." Wider types narrow your caller set without any benefit.
  • Concrete collections implement multiple interfaces at once. List<T> is IList<T>, IReadOnlyList<T>, ICollection<T>, and IEnumerable<T>. The interface you choose for a reference picks the operations visible at compile time.

The _List&lt;T&gt;_ lesson covers the workhorse implementation behind IList<T>: how its internal array grows, what Capacity and Count mean independently, and the cost of Add, Insert, and RemoveAt in concrete numbers. Most of the interfaces in this lesson become a lot less abstract once you've seen what List<T> actually does under the hood.