AlgoMaster Logo

Non-Generic Collections

Last Updated: May 17, 2026

17 min read

The System.Collections namespace is the original collections library that shipped with .NET 1.0 in 2002. Generics didn't arrive until .NET 2.0 in 2005, so for the first few years of the platform, every list, dictionary, queue, and stack stored its items as object. Those types still exist in modern .NET, and this lesson covers what they are, why they were built that way, what's wrong with them today, and the small set of cases where you'd still reach for one.

The Era Before Generics

When the original Base Class Library was designed, C# didn't have a way to write List<T>. The <T> syntax simply wasn't part of the language. To build a list that could hold strings in one program and integers in another, the BCL team had only one tool: store everything as object and let callers cast back to the type they wanted.

That decision shaped every collection that shipped in System.Collections. ArrayList holds object items. Hashtable holds object keys and object values. The non-generic Queue and Stack hold object items. Whatever you put in, you got back as object, and it was your job to remember what was actually in there.

One ArrayList is happily holding a string, an integer, and a decimal at the same time. That's possible because every item is stored as object, and every C# type derives from object. The compiler doesn't stop you from mixing types, and at runtime, the ArrayList doesn't know or care what's inside.

This sounds flexible, and it was sold that way at the time. The problem is that "the collection doesn't care what's inside" is not the same as "your code doesn't care." Almost every real program wants a list of products, or a list of order IDs, or a dictionary keyed by customer name. The collection being typeless just pushes the type tracking back to every line of code that reads from it.

The diagram traces the path from the original BCL through the generics release to the present. The non-generic types didn't disappear when generics arrived. They stayed in the framework so existing code could keep working, and they're still in .NET 8 today. They're just not what you reach for first anymore.

For the rest of this lesson, "non-generic collections" means the types in System.Collections: ArrayList, Hashtable, Queue, Stack, SortedList, BitArray, and a handful of supporting interfaces (IEnumerable, IEnumerator, ICollection, IList, IDictionary). The generic equivalents live in System.Collections.Generic.

The Three Problems With object-Based Collections

Storing everything as object causes three concrete problems. They sound abstract until you see them in code, so each one gets an example.

The first problem is no type safety at the call site. The collection accepts anything, so a bug that puts a string where an int belongs doesn't fail until much later, far from where the wrong value was introduced.

The Add("free") line compiles cleanly. ArrayList.Add takes an object parameter, and a string is an object. There's no warning, no error, no hint that the third element doesn't belong with the others. The crash shows up two screens away in the loop that processes the list, where the cast fails. With List<decimal> the bad call is rejected by the compiler at the line where it happens, with error CS1503: cannot convert from 'string' to 'decimal'.

The second problem is boxing and unboxing for value types. When you put an int into an ArrayList, the runtime has to wrap it in an object because the storage slot is object. That wrapping is called boxing, and it allocates a new object on the heap. When you read it back and cast to int, the runtime unboxes the value, copying it out of the heap object into a local. Both operations cost time and the boxing allocation costs memory.

Numbers vary by machine, but the relationship is consistent. The ArrayList version is several times slower and allocates roughly 10 million object boxes on the heap. The generic List<int> stores ints directly in a contiguous array of ints. Same logical work, very different cost.

The third problem is runtime casts on every read. Because the slot is object, retrieving an item gives you back object, and you need a cast to get to the real type. The cast happens at runtime, the compiler can't check it, and it throws InvalidCastException when the runtime type doesn't match what you asked for.

The two loops do the same thing, but the first one carries a cast on every iteration. The cost of a single cast is small, the compiler-level type safety it gives up is what hurts. With the generic list, the loop variable is string because the list is List<string>. The compiler knows that, and IntelliSense, refactoring tools, and your debugger all know that too.

The summary is simple. The object slot was the only option in 2002, and it works, but it loses type information that the compiler and the runtime could otherwise use to catch mistakes and skip overhead. Generics added that information back.

ArrayList vs List<T>

ArrayList is the non-generic equivalent of List<T>. Same data structure underneath: a contiguous array that grows when full. The difference is the slot type. ArrayList slots are object, so anything fits and everything boxes. List<T> slots are T, so only the right type fits and value types stay unboxed.

The two side-by-side, on the same shopping-cart task:

Same result, different cost profile and different compile-time guarantees. The ArrayList version has a cast on every iteration and would compile even if someone added a non-string item by accident. The List<string> version rejects non-strings at the Add call.

The values-typed version makes the difference louder because of boxing:

The two programs return the same answer, but the first one boxed four ints on the way in and unboxed four ints on the way out. The second one stored them as ints in a int[] underneath, no heap traffic.

Here's the side-by-side reference:

AspectArrayListList<T>
NamespaceSystem.CollectionsSystem.Collections.Generic
Slot typeobjectT
Compile-time type safetyNoneYes, type-checked
Boxing for value typesYes, on every addNo
Cast required on readYesNo
Introduced in.NET 1.0 (2002).NET 2.0 (2005)
Index accessO(1)O(1)
Add at endO(1) amortizedO(1) amortized
Insert/remove at middleO(n)O(n)

The two columns are nearly identical for the underlying mechanics, because the data structure is the same. The differences are all in what the type system sees and what the runtime has to do with value types.

Even when you only store reference types (so boxing isn't a factor), List<T> is still the better choice because the compile-time check eliminates a category of bugs. There's no scenario in new code where ArrayList is the right answer over List<T>.

Hashtable vs Dictionary<TKey, TValue>

Hashtable is the non-generic equivalent of Dictionary<TKey, TValue>. Same idea: hash the key, find a slot, store the value. The difference, again, is that both keys and values are object, which means casting on lookup, boxing for value-typed keys or values, and no compile-time guarantee that all keys are the same type or all values are the same type.

The shopping-cart-style example, with product names as keys and stock counts as values:

The Hashtable line stock1["Headphones"] = 12 does two things you might not see: it boxes the 12 into a heap-allocated object, and it stores the string key as an object too. The lookup (int)stock1["Headphones"] returns the boxed object and unboxes it back to int. The Dictionary version skips both steps because the value slot is already int.

The Hashtable indexer has one quirk worth knowing: missing keys return null instead of throwing. That's the opposite of Dictionary<TKey, TValue>, which throws KeyNotFoundException. The difference traces back to the object slot. A null object is a valid return value, so the indexer just returns it for missing keys.

The Hashtable returning null for missing keys looks convenient, but it's a source of bugs. A missing key and a key with a real null value look the same. With Dictionary<TKey, TValue>, you can tell them apart: a missing key throws, and the explicit TryGetValue method tells you both whether the key was present and what the value was if it was.

AspectHashtableDictionary<TKey, TValue>
NamespaceSystem.CollectionsSystem.Collections.Generic
Key slotobjectTKey
Value slotobjectTValue
Missing key behaviorReturns nullThrows KeyNotFoundException
Boxing for value-typed key or valueYesNo
Cast on readYesNo
Thread safety for single readersYes, multi-readers safe with single writerNo (use ConcurrentDictionary)
Introduced in.NET 1.0 (2002).NET 2.0 (2005)

The last row is the one thing Hashtable has that Dictionary doesn't: a documented thread-safety guarantee for "multiple readers and a single writer." Hashtable is internally synchronized for that pattern. Dictionary<TKey, TValue> is not, and concurrent reads alongside any write can corrupt the structure. In modern code, you'd reach for ConcurrentDictionary<TKey, TValue> from System.Collections.Concurrent instead of using Hashtable for its threading guarantee. The _Concurrent Collections_ lesson covers concurrent collections in detail.

Like ArrayList, there's no scenario in new code where Hashtable is the right answer over Dictionary<TKey, TValue> or ConcurrentDictionary<TKey, TValue>. If you see it in a codebase, it's almost always inherited from old code.

The Boxing Pipeline

Boxing comes up so often with non-generic collections that it's worth walking through the steps. The flow shows what the runtime is doing when an int lands in an ArrayList.

The starting point is an int on the stack. Stacks are fast, ints are cheap, no allocation is involved. To put that int into an ArrayList, the runtime allocates a new object on the heap, copies the int's value into the heap object's payload, and stores a reference to that heap object in the array's slot. That's the boxing step.

On the way out, you read the slot (which gives you a reference), cast back to int, and the runtime copies the value out of the heap object into a new local on the stack. That's the unboxing step.

Two important consequences fall out of this picture. First, the boxed object and the original int are independent. Changing one doesn't change the other, because boxing copies the value. Second, each Add of an int allocates a new heap object, and large fills can put real pressure on the garbage collector.

After the Add, the stack int and the boxed int are decoupled. Reassigning stock to 999 is just a stack write. The heap object still holds 12, and unboxing gives back 12.

List<int> skips this entire pipeline. The underlying storage is int[], not object[], so an Add(12) writes directly into an int slot. No heap object, no copy, no GC pressure. That's what the earlier benchmark was measuring.

The Non-Generic Queue and Stack

Queue and Stack (the non-generic versions) live in System.Collections and predate the generic versions in System.Collections.Generic. They behave like their generic siblings (FIFO for Queue, LIFO for Stack), but they store items as object, which means the same boxing and casting story applies.

Two queues, two completely different developer experiences. The generic version gives you string back. The non-generic version gives you object and demands you remember it's really a string.

The non-generic Stack works the same way for LIFO order:

The mechanics are exactly what the _Queue&lt;T&gt;_ and _Stack&lt;T&gt;_ lessons covered. There's nothing the non-generic versions can do that the generic ones can't. The generic versions remove the casts, prevent the boxing for value types, and give you compile-time type safety. Use them.

SortedList (Non-Generic)

SortedList is the awkward one in the family. It's a key-value collection like Hashtable, but it keeps entries sorted by key. Internally, it's two parallel arrays (one for keys, one for values), kept in sorted order so binary search can find a key in O(log n). Inserting and removing in the middle is O(n) because the arrays have to shift.

There are actually two SortedList types in the BCL. System.Collections.SortedList is the non-generic .NET 1.0 version. System.Collections.Generic.SortedList<TKey, TValue> is the .NET 2.0 generic version. The _SortedDictionary & SortedList_ lesson covers the generic one. This section is about the older non-generic one.

Inserted in 300, 100, 200 order, the entries come out sorted: 100, 200, 300. The collection sorts on insertion, not on read, so reads are cheap and inserts are not.

The iteration variable DictionaryEntry is a value type from System.Collections that represents a key-value pair as two object fields. It's the non-generic counterpart to KeyValuePair<TKey, TValue>. Same role, less type information.

The same caveats apply as with Hashtable. Both keys and values are object, so value-typed keys or values box, reads need casts, and there's no compile-time check on what types you put in. For new code, the answer is SortedDictionary<TKey, TValue> or the generic SortedList<TKey, TValue>.

BitArray

BitArray is the one non-generic collection that doesn't have a direct generic replacement and is still worth knowing. It's a compact, mutable array of bool values, but it stores them as individual bits rather than as full booleans, so 1024 bits fit in 128 bytes instead of 1024 bytes.

The class lives in System.Collections and ships with bitwise operations (And, Or, Xor, Not) that operate on whole bit arrays at once. That makes it useful for any task where you need a fixed-size set of flags or a bitmap.

You build a BitArray with a known length, then read or write individual bits by index. The bool slots behave like a bool[], but the storage is packed: every 32 bools take one int of memory.

The bitwise operations are what make BitArray worth using over a bool[]. Suppose two warehouses each report their in-stock slots, and you want to know which slots are in stock at either location:

Or and And mutate the receiver in place, so you Clone first when you need to keep the original. The operations run on whole 32-bit words underneath, so combining two 10,000-bit arrays runs in roughly 313 CPU operations, not 10,000. That's the actual reason BitArray exists: bulk bit math is faster as word-at-a-time math than as bit-at-a-time math, and the class handles the word packing for you.

Not flips every bit, and Xor flags bits that differ. You can also access the underlying storage indirectly through CopyTo if you need to interoperate with byte[] or int[] representations of the same data, which comes up for serialization or for talking to a native API.

The original bits were 1001000100000000. After Not, every bit is inverted, giving 0110111011111111. Useful for "which slots are out of stock" given a "which slots are in stock" array.

BitArray is the one non-generic collection without a direct generic replacement. There's no BitArray<T>, because the type parameter would always be bool. For bit-packed flag sets, this is the BCL's answer. The alternatives in modern .NET are BitVector32 (a struct that holds 32 bits) and System.Numerics.BitOperations for primitive integer bit math, but neither covers the variable-length, mutable, bulk-operation case the way BitArray does.

Storage optionBits per boolBulk operationsFixed size
bool[]8 (one byte per bool)No, you'd loop manuallyNo, can resize via Array.Resize
BitArray1 (packed into ints)Yes, And, Or, Xor, NotYes, length is fixed after construction
BitVector321 (packed into a single int)Yes, bitwise on the underlying intYes, exactly 32 bits
HashSet<int>Varies, much higherNo bit-level opsNo, dynamic size

The right choice depends on the size and the operations you need. BitArray shines when you want hundreds to millions of bits with bulk operations. BitVector32 is the choice when you have at most 32 flags and want zero allocation. A bool[] is fine when memory doesn't matter and you only need indexed reads and writes.

Non-Generic Interfaces

The interfaces in System.Collections are the abstractions the older collections implement. They're worth a brief look because they show up in legacy code and because IEnumerable is part of the foreach story.

IEnumerable is the simplest. A type that implements it can be iterated with foreach. The interface has one method, GetEnumerator(), which returns an IEnumerator. The non-generic version is the parent of the generic IEnumerable<T>, so any IEnumerable<T> is also an IEnumerable.

IEnumerator has three members worth naming: MoveNext() advances to the next item and returns false when it runs out, Current returns the current item, and Reset() is supposed to rewind to the start but is almost never implemented usefully. The generic IEnumerator<T> returns T from Current. The non-generic one returns object, so you cast on every read.

ICollection extends IEnumerable with Count, a SyncRoot for old-style locking, and CopyTo for bulk copies into an array. It does not have Add or Remove, those live on IList (for index-based collections) and on IDictionary (for key-based ones). The generic ICollection<T> adds Add, Remove, Contains, and Clear, which is closer to what you'd expect from a "collection" interface.

IDictionary is what Hashtable and the non-generic SortedList implement. Its enumerator returns DictionaryEntry values (key plus value, both as object). The generic IDictionary<TKey, TValue> returns KeyValuePair<TKey, TValue> with full type information.

IDictionaryEnumerator is the specialized enumerator for IDictionary. Same shape as IEnumerator, but with extra properties Key and Value to read each entry's parts separately without going through DictionaryEntry. It's a niche interface and you'd rarely use it directly today.

The order isn't guaranteed for Hashtable, which is why a SortedList or a SortedDictionary<TKey, TValue> is the right choice if you want sorted iteration.

The hierarchy roughly mirrors the generic side but every interface deals in object. IEnumerable is at the top, ICollection adds counting and copying, and then IList and IDictionary branch off for indexed and keyed collections respectively. IEnumerator and IDictionaryEnumerator are the iteration types returned by GetEnumerator. The concrete classes implement the relevant interfaces.

In new code, you mostly deal with the generic versions (IEnumerable<T>, ICollection<T>, IList<T>, IDictionary<TKey, TValue>). The non-generic ones still exist, partly because the generic versions inherit from them, and partly because some old APIs return non-generic interfaces. When you see one in a method signature, the usual move is to convert to the generic form with .Cast<T>() or .OfType<T>() from LINQ:

Cast<T>() lazily casts every element of an IEnumerable to T, throwing InvalidCastException if any item isn't actually a T. OfType<T>() is the lenient version: it filters out items that aren't T instead of throwing. Both convert a non-generic enumerable into a generic one, after which the rest of your code can speak the modern interfaces.

When You Still See Non-Generic Collections

If non-generic collections are worse than their generic siblings on every axis except BitArray's niche, why are they still in the framework, and why might you still encounter them?

The first reason is backward compatibility. Code written before 2005 used non-generic collections everywhere. Microsoft doesn't break that code, so the types stay in the framework forever. A library compiled against .NET Framework 1.1 still runs on .NET 8 (with a few exceptions), and any of its public API surface that returns ArrayList keeps returning ArrayList.

The second reason is COM interop. The COM type system, which underpins older Windows components (the Office automation interfaces, classic ADO, Microsoft Shell extensions, parts of the Windows Scripting Host), maps cleanly to non-generic collections because COM doesn't know about generics either. When you talk to COM from .NET via runtime callable wrappers, methods that return collection-like things tend to come back as IEnumerable or ArrayList-shaped, not as List<T>.

The third reason is reflection and dynamic scenarios. APIs that work with arbitrary objects (DataTable, parts of System.Reflection, some serialization paths, certain WPF and WinForms data-binding hooks) deal in object, and they expose collections of object because they genuinely don't know the item type at compile time. DataRowCollection, for instance, lives in System.Data and is non-generic for this reason.

The fourth reason is BitArray. There's no generic equivalent, and it's still the right tool for variable-size bit-packed flag sets.

The fifth, narrower reason is the Hashtable thread-safety guarantee for "many readers, one writer." That guarantee dates from before ConcurrentDictionary existed. In modern code, the right tool is ConcurrentDictionary<TKey, TValue>, which gives you full multi-writer safety with a generic API. The Hashtable guarantee is interesting historically and is the answer to "why did anyone use this?" but it's not a reason to pick Hashtable today.

That covers the legitimate encounters. The illegitimate one is "I learned C# in 2003 and these are the collections I know." If you're writing new code and reaching for ArrayList, that's the moment to stop and use List<T>. The compiler will thank you, the runtime will thank you, and the next reader of your code will thank you most of all.

The conversion at the boundary is the standard move. The legacy API gives you ArrayList because its signature was set in 2003. You convert once, on entry, and the rest of your program speaks the modern language. If the conversion fails (because the legacy code put something other than strings in there), you find out at the boundary, not 200 lines later.

The same shape applies to Hashtable (convert to Dictionary<TKey, TValue>), non-generic Queue (convert to Queue<T>), and non-generic Stack (convert to Stack<T>). LINQ's Cast<T>() and OfType<T>() make these conversions one-liners.

Summary

  • The types in System.Collections (ArrayList, Hashtable, non-generic Queue, non-generic Stack, non-generic SortedList, BitArray) shipped with .NET 1.0 in 2002, before C# had generics. They store items as object.
  • Storing items as object causes three problems for new code: no compile-time type safety, boxing and unboxing of value types on every add and read, and runtime casts that can throw InvalidCastException.
  • List<T>, Dictionary<TKey, TValue>, Queue<T>, Stack<T>, and the generic SortedList<TKey, TValue> are the generic equivalents and should be the default choice in any new code. They were added in .NET 2.0 (2005) and live in System.Collections.Generic.
  • Hashtable returns null for missing keys; Dictionary<TKey, TValue> throws KeyNotFoundException. The Hashtable behavior makes it impossible to distinguish a missing key from a present-but-null value.
  • BitArray is the one non-generic collection without a direct generic replacement. It packs bools one-per-bit and supports bulk bitwise operations (And, Or, Xor, Not), making it useful for variable-size flag sets and bitmaps.
  • The non-generic interfaces IEnumerable, ICollection, IList, IDictionary, IEnumerator, and IDictionaryEnumerator are the parents of the generic versions. They still appear in COM interop, in some reflection APIs, and in older library signatures.
  • When you receive a non-generic collection from a legacy API, the standard move is to convert it to a generic one at the boundary with LINQ's Cast<T>() or OfType<T>(), then use generic types everywhere else.

The _Concurrent Collections_ lesson covers ConcurrentDictionary<TKey, TValue>, ConcurrentQueue<T>, ConcurrentBag<T>, and BlockingCollection<T>, the modern answer to the threading problem that Hashtable's "many readers, one writer" guarantee tried to solve back in 2002.