AlgoMaster Logo

ReadOnly Collections

Last Updated: May 17, 2026

16 min read

A read-only collection is a view of an existing collection that doesn't expose any mutating operations. The interfaces (IReadOnlyCollection<T>, IReadOnlyList<T>, IReadOnlyDictionary<TKey, TValue>, IReadOnlySet<T>) and the wrapper classes (ReadOnlyCollection<T>, ReadOnlyDictionary<TKey, TValue>, ReadOnlyObservableCollection<T>) let one class hand out a reference to its internal data without giving callers the ability to change it. The key thing to keep in mind is that "read-only" describes the view, not the underlying data, and if you mix that up you'll write classes that look encapsulated but aren't.

Why a Read-Only View Is Useful

The encapsulation section made the case for keeping internal state private. The problem is that List<T>, Dictionary<TKey, TValue>, and the other concrete collections from the _List_ and _Dictionary_ lessons expose Add, Remove, Clear, indexer setters, and everything else. The moment you return a List<T> from a property, every caller can mutate it.

The Order class went through the trouble of marking _items as private readonly, then handed out the live list anyway. The caller called Add and Clear directly on it. The AddItem method is now a polite suggestion rather than a rule.

The fix isn't to copy the list every time someone reads it. Copying is wasteful, and it breaks the obvious thing readers expect, which is that they're looking at the order's current items. The fix is to hand out a reference whose static type doesn't have mutating methods. That's what IReadOnlyList<T> and ReadOnlyCollection<T> are for.

The property still returns the same _items list, but the static type the caller sees is IReadOnlyList<string>. The compiler rejects order.Items.Add(...) because the interface doesn't declare Add. The caller can iterate, index, and check Count, which is all most callers actually need.

This is the everyday shape of using read-only collections: a class keeps a mutable collection privately and exposes a read-only-typed reference to it. The rest of this lesson is about what that buys you, what it doesn't buy you, and which type to pick when.

The Read-Only Interfaces

The BCL has four read-only collection interfaces in System.Collections.Generic. They form a parallel hierarchy to the mutable ones: where ICollection<T> adds Add and Remove on top of IEnumerable<T>, IReadOnlyCollection<T> adds only Count. Same for IList<T> versus IReadOnlyList<T>, and so on.

InterfaceAdds Over BaseFirst Available
IReadOnlyCollection<T>Count (over IEnumerable<T>).NET Framework 4.5
IReadOnlyList<T>indexer this[int] (get-only).NET Framework 4.5
IReadOnlyDictionary<TKey, TValue>Keys, Values, ContainsKey, TryGetValue, indexer (get-only).NET Framework 4.5
IReadOnlySet<T>Contains, IsSubsetOf, set-theoretic queries.NET 5

IReadOnlySet<T> came later than the other three, so you'll see older codebases use IEnumerable<T> or a custom interface where IReadOnlySet<T> would be cleaner today. Outside of that asymmetry, all four are designed the same way: they declare only the methods that don't change the collection.

The thing to keep in mind is that these are interfaces, not classes. They describe a contract. List<T> implements IReadOnlyList<T> directly, so a List<int> instance is already a valid IReadOnlyList<int>. There's no conversion, no wrapping, no copy. The cast is free:

The variable view and the variable stockCounts reference the same list object. The only difference is what the compiler lets you do through each reference. Through view, you can read and iterate. You cannot call Add, Remove, Clear, or use the indexer's setter, because the IReadOnlyList<T> interface doesn't declare any of them.

Same idea with IReadOnlyDictionary<TKey, TValue>:

The indexer on IReadOnlyDictionary<TKey, TValue> is get-only, so priceView["Mouse"] = 9.99m is a compile error. You also can't call Add, Remove, or Clear. What's exposed is the part of Dictionary<TKey, TValue> that doesn't modify the dictionary.

IReadOnlySet<T> works the same way, but it's available only on .NET 5 and later. HashSet<T> and SortedSet<T> both implement it on those runtimes:

The interface exposes Contains, IsSubsetOf, IsProperSubsetOf, IsSupersetOf, Overlaps, and SetEquals. The mutating set methods (Add, Remove, UnionWith, IntersectWith, and so on) are not declared on IReadOnlySet<T>, so the compiler blocks them when you go through the read-only reference.

The Wrapper Classes

The interfaces are one half of the story. The other half is the wrapper classes in System.Collections.ObjectModel: ReadOnlyCollection<T>, ReadOnlyDictionary<TKey, TValue>, and ReadOnlyObservableCollection<T>. These are concrete classes that wrap an existing mutable collection and forward read operations to it while refusing write operations.

The shape is the same for all three. The wrapper holds a reference to the underlying collection. Its read members (indexer, Count, Contains, GetEnumerator) call through to the underlying collection. Its ICollection<T>.Add, ICollection<T>.Remove, and ICollection<T>.Clear implementations throw NotSupportedException if anyone gets at them through the older non-generic IList or ICollection interfaces.

ReadOnlyCollection<T> implements IReadOnlyList<T> (and IList<T>, but it throws on write attempts). It's a class, not an interface, so you get a concrete type you can name in method signatures and return types.

List<T> has a helper for building one: List<T>.AsReadOnly().

AsReadOnly() allocates a ReadOnlyCollection<T> that references the underlying List<T>. The cost is one small allocation for the wrapper. No element copying happens, the wrapper stores a reference to the same list.

Dictionary<TKey, TValue>.AsReadOnly() is the same idea, available on .NET 8 and later. On older runtimes you construct ReadOnlyDictionary<TKey, TValue> directly:

ReadOnlyObservableCollection<T> is the third wrapper. It wraps an ObservableCollection<T> (a List<T>-like type that raises change notifications, used heavily in WPF and other XAML UI frameworks) and exposes the same notifications while blocking mutation through the wrapper. You'll see this in MVVM code where the view model holds an ObservableCollection<T> and exposes a ReadOnlyObservableCollection<T> for the UI to bind to.

The notification fires because the underlying collection changed and the wrapper passes the event through. The caller using the wrapper sees the event but cannot call Add on the wrapper directly. If you're not writing UI code, you probably won't reach for ReadOnlyObservableCollection<T>. The non-observable ReadOnlyCollection<T> is the common one.

The Critical Caveat: A Wrapper Is Not Immutability

This is the part that trips people up. A read-only collection is a view over another collection. The view doesn't expose mutating methods, but the underlying collection is still mutable. If anyone still holds a reference of the underlying type, they can mutate it, and the wrapper will reflect the change because both point to the same data.

wrapper.Count changed from 2 to 0 without anyone calling a single method on the wrapper. The mutation happened through items, which is still a normal List<string>. The wrapper has no way to stop that. It's holding a reference to the same list and just reports whatever the list currently contains.

The same effect through IReadOnlyList<T>:

The view reference saw the underlying list grow and saw the first element change, because there's only one list. view and stockCounts are two names for the same object, with two different static types. The static type of view doesn't let you mutate the list through that name, but it doesn't stop anyone else from mutating it through a different name.

A picture helps. The caller's IReadOnlyList<string> reference, the wrapper, and the underlying List<string> are three nodes, but they all share the same data in the end:

The caller goes through the wrapper. The wrapper goes to the list. The holder (the class that built the wrapper) keeps a private reference to the list and can mutate it whenever it wants. That's by design when the holder is the one orchestrating changes. It's a bug when the holder forgot they were still exposing the underlying list elsewhere.

The leak shows up most often when a class returns the same underlying field from two different properties, one typed as List<T> and one typed as IReadOnlyList<T>:

The Items property is typed as IReadOnlyList<string>, so callers can't mutate the order through that property. But EditableItems returns the same list as a List<string>, and that property exposes everything the read-only view tried to hide. From a callers' perspective, the Items property is a security blanket, not a security boundary.

A more subtle version of the same bug: passing the underlying list to another class that "borrows" it.

The IReadOnlyList<T> exposed by Items looks safe, but the Order class itself handed the underlying list to CartIndex in the constructor. Anything CartIndex does to its private _items field is visible to anyone reading Order.Items. The Items property hasn't lost its protective shape, what's been lost is the assurance that only Order is changing the list.

If you want to defend against this, two strategies are common.

The first is to never let the underlying list leave the class. Keep it strictly private, expose only the IReadOnlyList<T> or a ReadOnlyCollection<T>, and never pass the list itself to a constructor or method of another type.

The second is to copy when crossing an external boundary. If CartIndex genuinely needs the elements but isn't supposed to mutate the order, give it a copy:

Or, better, change CartIndex's constructor to take IEnumerable<string> and copy what it needs internally. The point is to think of the underlying list as part of the class's private state, not as a value that gets passed around.

ReadOnly vs Immutable

The _Immutable Collections_ lesson covered immutable collections (ImmutableArray<T>, ImmutableList<T>, ImmutableDictionary<TKey, TValue>, and friends in System.Collections.Immutable). The names sound similar, but the guarantees are different.

A read-only collection is a view that you can't mutate through this reference. Someone else, with a reference of the underlying type, still can. The data backing the view can change over the lifetime of the view.

An immutable collection is data that nobody can mutate, ever. Operations like Add exist, but they return a new immutable collection instead of changing the old one. Once you have an ImmutableList<T>, its contents are frozen.

AspectIReadOnlyList<T> (interface)ReadOnlyCollection<T> (wrapper)ImmutableList<T> (immutable)
Can the holder of this reference mutate it?NoNoNo
Can anyone else mutate the underlying data?Yes, if they hold the mutable referenceYes, if they hold the mutable referenceNo
Does the data change over time?PossiblyPossiblyNo, never
Add available?NoNoYes, returns a new instance
Allocation costNone (cast)One wrapper objectOne new instance per change
Thread-safe for reads while another thread writes?NoNoYes
Best forHiding mutability from callersHiding mutability and naming a concrete typeSharing data across threads, snapshots, undo stacks

A practical example. Two classes that look almost identical at the property line, but behave differently:

Both snapshots were captured after the first Add and before the second. In the read-only version, snapshotA points at the same List<T> as a._items, so when the second Add happens, the snapshot grows from 1 to 2 without anyone touching the snapshot reference. In the immutable version, snapshotB is a separate immutable instance, and the second Add produces a new instance that a._items is reassigned to. The snapshot still has 1 element.

This is the practical distinction. Read-only protects callers from accidentally modifying the data. Immutable also protects callers from being surprised when the data changes underneath them.

When to reach for which:

  • `IReadOnlyList<T>` / `IReadOnlyDictionary<TKey, TValue>`: the default for exposing internal collection state. Free at the call site (no allocation), clearly signals intent.
  • `ReadOnlyCollection<T>` / `ReadOnlyDictionary<TKey, TValue>`: when you want a concrete type name for documentation or when an API needs a class rather than an interface. Slightly more defensive than the interface, since downcasting to List<T> will fail.
  • `ImmutableList<T>` / `ImmutableDictionary<TKey, TValue>`: when callers will hold the reference for a long time, when the data is shared across threads, when "the value I read yesterday is still the same value today" matters, or when you want functional-style updates with structural sharing.

For most everyday object-oriented code (web request handlers, view models, domain services on a single thread), IReadOnlyList<T> and IReadOnlyDictionary<TKey, TValue> are enough. Immutable collections are worth the cost when you genuinely need a snapshot guarantee or thread safety, not as a default.

One more piece of vocabulary that helps keep the distinction straight: sometimes people use the word "frozen" for immutable and "shielded" for read-only. The shield in front of a window keeps you from poking the glass, but the window itself can be rearranged from the other side. A frozen window can't be rearranged at all, by anyone. Read-only collections are shields. Immutable collections are frozen.

A third option worth knowing about is FrozenDictionary<TKey, TValue> and FrozenSet<T>, added in .NET 8. These are immutable lookup structures optimized for the read-mostly case: they're built once, more expensively than a regular dictionary, but every read after that is faster than Dictionary<TKey, TValue>. They are not just "read-only views". They are genuinely immutable and they're a different trade-off curve from the Immutable* types: faster reads, much slower builds, no efficient way to "add an item and get a new one back". Use them for static lookups loaded at startup, configuration tables, that kind of thing. Don't use them for collections that change.

Returning Read-Only from Methods and Properties

The everyday use of these types is in the public surface of a class. A property returning IReadOnlyList<T> tells the caller "you can iterate and index, but you can't modify." A method parameter typed IReadOnlyCollection<T> tells the caller "I promise I won't change what you pass in."

The Order example, fully assembled:

The LineItems property is typed IReadOnlyList<LineItem>. Callers can iterate, index by position, ask for Count. They cannot call Add or Remove. The way to add a line item is through the AddLine method, which validates the inputs. The class's invariants (no zero-quantity lines, no negative quantities) stay enforced because nothing outside the class can put a LineItem into the list.

If you want to be more defensive, wrap the field once and store the wrapper:

The wrapper is built once in the constructor and reused for every read. Callers can't downcast it to List<LineItem> because it isn't one. They also can't call any mutating method on it through the older IList<T> interface, because ReadOnlyCollection<T> throws NotSupportedException for those. This is one notch more defensive than just IReadOnlyList<T>, at the cost of one extra object per Order.

Method parameters work the same way. A method that's supposed to only inspect a list should declare its parameter as IReadOnlyList<T>:

The caller passes a regular List<decimal>. The method sees it as IReadOnlyList<decimal> and can't accidentally call Add. The compiler enforces the contract at the type level, you don't need a code review comment that says "please don't modify this parameter."

This pattern also helps callers who are passing arrays. T[] implements IReadOnlyList<T>, so the method works for decimal[] as well as List<decimal>. If the parameter had been List<decimal>, the caller would have had to convert the array first. The read-only interface is the more flexible parameter type.

There's one subtle thing about returning IReadOnlyList<T> from a property. A determined caller can cast it back:

The pattern match order.Items is List<string> mutable succeeds because the underlying object really is a List<string>. The caller now has a typed reference and can mutate freely. If you want to block that, return ReadOnlyCollection<T> (or build a wrapper around an IReadOnlyList<T>-only type) so that the downcast fails.

For most teams, this hole isn't worth plugging at the type level. A code reviewer would catch the cast immediately, and the convention "we typed it as IReadOnlyList<T> for a reason" carries the rule. Reach for ReadOnlyCollection<T> only when you're publishing a public API and want stronger guarantees against accidental misuse.

ReadOnly Around Other Collection Types

ReadOnlyCollection<T> wraps anything that implements IList<T>. That includes List<T>, T[], and most array-like custom collections. So you can wrap an array directly:

The wrapper around the array works just like the wrapper around a List<T>. Reading is fine. Writing through the wrapper is blocked at compile time. Writing through the underlying array (tags[0] = "x") is still allowed because the caller kept that reference, and the wrapper will reflect the change. The leaky-encapsulation rule applies just as much to arrays as it does to lists.

For sets, the situation is slightly different. There's no ReadOnlySet<T> wrapper class in the BCL prior to .NET 9. From .NET 5 through .NET 8, the right pattern is to expose IReadOnlySet<T> and back it with a HashSet<T> or SortedSet<T>. On .NET 9, the BCL adds a ReadOnlySet<T> wrapper class and a HashSet<T>.AsReadOnly() helper, mirroring the list and dictionary story. The interface route works on every modern version, so it's the safer default.

The Cart class exposes its coupons as IReadOnlySet<string>. Outside callers can Contains, Count, and iterate. Inside, the class uses a regular HashSet<T> so it can Add and Remove with normal set semantics.

IReadOnlyCollection<T> (just Count and iteration) is occasionally the right return type when you don't want callers to depend on index access. Use it when the underlying type might be a set, a queue, or anything where indexing doesn't naturally apply. IReadOnlyList<T> is the right return type when the underlying data is genuinely ordered and indexable, and that's most of the time when you started with a List<T> or an array.

A Common Failure Mode: "Public Setter, Read-Only Getter"

One last pattern worth flagging. Beginners sometimes try this:

This compiles. It even seems to work. But the public setter is a step in the wrong direction. A caller can now replace the entire list with anything they want, including their own list that they keep mutating from outside. The read-only-typed property is doing nothing for encapsulation because the setter accepts whatever the caller hands in.

The fix is to remove the public setter and add an explicit method for the operation the class actually wants to support:

Now the only way to change the order's items is through AddItem and ClearItems. Those methods can validate, log, raise events, or do anything else the class needs. The Items property just exposes the current state for reading.

This is the OOP-style way to use read-only collections: keep mutation methods explicit on the class, keep the read access on the property, and never let the underlying mutable collection leave the class as a typed mutable reference.

Init-Only Properties and Collection Initializers

One last shape that comes up often in modern C# (C# 9 and later): the init accessor. An init-only property can be set during object initialization (in a constructor or an object initializer expression) and then never again. Pairing init with a read-only collection type gives a nice, terse way to express "set the items once when the order is created, then expose them read-only forever":

After construction, the property is set in stone from the caller's perspective. The compiler rejects assignments outside an object initializer. The catch is the same one this lesson keeps emphasizing: the caller still has the List<string> they passed in. Anything they do to that list afterward is visible through order.Items, because the Order stored the same reference.

If you want the order to truly own its items after construction, copy or wrap inside the constructor:

The constructor copies the source items into a new list, then wraps that list. The order's Items no longer share state with the caller's list, so mutating the source has no effect on the order. The cost is one extra copy at construction time, which is usually fine if the order isn't built in a tight loop.

The same pattern works with immutable collections: take an IEnumerable<T> and call ToImmutableList() inside the constructor. The choice between ReadOnlyCollection<T> and ImmutableList<T> here is the same trade-off we covered earlier: do callers need a snapshot that won't change if the class adds more items later, or is "you can't add through this reference" enough?

Summary

  • A read-only collection is a view that doesn't expose mutating methods. It is not the same as an immutable collection. The underlying data can still change if anyone holds a reference of the mutable type.
  • The four read-only interfaces are IReadOnlyCollection<T> (count + iteration), IReadOnlyList<T> (adds indexer), IReadOnlyDictionary<TKey, TValue> (adds key lookup), and IReadOnlySet<T> (adds set queries, .NET 5+). List<T>, Dictionary<TKey, TValue>, HashSet<T>, and arrays implement the relevant ones directly, so the cast is free.
  • The wrapper classes ReadOnlyCollection<T>, ReadOnlyDictionary<TKey, TValue>, and ReadOnlyObservableCollection<T> live in System.Collections.ObjectModel. They allocate one small wrapper object each but don't copy elements.
  • List<T>.AsReadOnly() and Dictionary<TKey, TValue>.AsReadOnly() (the latter on .NET 8+) return wrappers that reference the underlying collection. Mutations to the original are visible through the wrapper.
  • The leaky-encapsulation trap: if a class holds the underlying mutable reference (or shares it with another object), the read-only view will still reflect those changes. Keep the underlying collection strictly private and never let it leave the class as a mutable type.
  • Use IReadOnlyList<T> and IReadOnlyDictionary<TKey, TValue> as the default return type when exposing internal collection state. Use ReadOnlyCollection<T> when you want a stronger barrier against downcasting back to List<T>. Use the immutable collections from the _Immutable Collections_ lesson when callers need a snapshot or thread-safety guarantee.
  • Method parameters typed as IReadOnlyList<T> document "I won't change this" at the type level and accept both List<T> and T[] without conversion.