Generic Collections Deep Dive

Vaibhav • September 11, 2025

In previous articles, we explored how generics allow us to write reusable and type-safe code, and how constraints help us enforce rules on generic types. Now it’s time to dive into one of the most practical applications of generics: generic collections. These are the backbone of modern C# programming, offering flexible, efficient, and strongly typed data structures that adapt to your needs.

Why Generic Collections Matter

Before generics, collections like ArrayList stored elements as object, which meant you had to cast items manually and risk runtime errors. Generic collections solve this by letting you specify the type of elements they store. This improves type safety, performance, and developer productivity.

// Non-generic collection
ArrayList list = new ArrayList();
list.Add("Hello");
string s = (string)list[0]; // Risky: requires casting

// Generic collection
List<string> list = new List<string>();
list.Add("Hello");
string s = list[0]; // Safe: no casting needed

The second example is safer and cleaner. The compiler ensures that only strings can be added to the list, preventing accidental misuse.

List<T> - The Workhorse

The List<T> class is the most commonly used generic collection. It behaves like a dynamic array that resizes automatically and provides powerful built-in methods.

List<int> numbers = new List<int>();
numbers.Add(10);
numbers.Add(20);
numbers.Add(30);
Console.WriteLine(numbers[1]); // Output: 20

You can add, remove, insert, and access elements using intuitive methods. The list maintains order and supports indexing, making it ideal for most everyday scenarios.

List<T> belongs to the System.Collections.Generic namespace. It uses a backing array that doubles in size when capacity is exceeded.

Dictionary<K,V> - Key-Value Storage

The Dictionary<K,V> class stores data as key-value pairs. It’s optimized for fast lookups, insertions, and deletions using hashing.

Dictionary<string, int> scores = new Dictionary<string, int>();
scores["Alice"] = 95;
scores["Bob"] = 87;
Console.WriteLine(scores["Alice"]); // Output: 95

Keys must be unique, and the dictionary throws an exception if you try to access a missing key. You can use ContainsKey to check safely.

if (scores.ContainsKey("Charlie"))
{
    Console.WriteLine(scores["Charlie"]);
}

Dictionaries are perfect for mapping identifiers to values, such as usernames to profiles or product IDs to prices.

HashSet<T> - Unique Elements

The HashSet<T> class stores unique elements and provides fast lookup and set operations. It’s ideal when you care about existence but not order.

HashSet<string> tags = new HashSet<string>();
tags.Add("csharp");
tags.Add("generics");
tags.Add("csharp"); // Duplicate ignored
Console.WriteLine(tags.Count); // Output: 2

HashSet automatically ignores duplicates and supports operations like union, intersection, and difference.

HashSet<T> uses hashing internally, just like Dictionary<K,V>, but only stores values-not key-value pairs.

Queue<T> and Stack<T> - Ordered Access

These two collections provide specialized access patterns:

Queue<T> follows First-In-First-Out (FIFO) ordering. You enqueue items at the end and dequeue from the front.

Queue<string> queue = new Queue<string>();
queue.Enqueue("First");
queue.Enqueue("Second");
Console.WriteLine(queue.Dequeue()); // Output: First

Stack<T> follows Last-In-First-Out (LIFO) ordering. You push items onto the top and pop them off.

Stack<string> stack = new Stack<string>();
stack.Push("Bottom");
stack.Push("Top");
Console.WriteLine(stack.Pop()); // Output: Top

These collections are useful for modeling workflows, undo operations, or buffering tasks.

Collection Initialization Syntax

You can initialize collections using collection initializer syntax, which makes code cleaner and more readable.

List<int> numbers = new List<int> { 1, 2, 3 };
Dictionary<string, int> ages = new Dictionary<string, int>
{
    ["Alice"] = 30,
    ["Bob"] = 25
};

This syntax is especially useful when setting up test data or configuration values.

Iterating Through Collections

All generic collections implement IEnumerable<T>, so you can use foreach to iterate over them.

foreach (var name in names)
{
    Console.WriteLine(name);
}

This works for List<T>, HashSet<T>, Queue<T>, and more. The iteration order depends on the collection type.

Performance Considerations

Generic collections are optimized for performance, but each has trade-offs:

List<T> offers fast indexing and appending, but inserting/removing in the middle is slower due to shifting elements.

Dictionary<K,V> and HashSet<T> offer fast lookups, but require good hash functions and more memory.

Queue<T> and Stack<T> are fast for their specific access patterns but not suitable for random access.

Choose the right collection based on your access pattern. Use List<T> for ordered data, Dictionary<K,V> for key-based lookup, and HashSet<T> for uniqueness.

Collection Interfaces

Most generic collections implement interfaces like ICollection<T>, IEnumerable<T>, and IDictionary<K,V>. These interfaces define common behaviors and allow polymorphism.

ICollection<string> items = new List<string>();
items.Add("Item");
items.Remove("Item");

This lets you write code that works with any collection type, as long as it implements the expected interface.

Collections and LINQ

Generic collections work seamlessly with LINQ. You can filter, project, and aggregate data using expressive queries.

var highScores = scores.Where(s => s.Value > 90)
                       .Select(s => s.Key);

LINQ makes it easy to work with collections in a declarative style, improving readability and reducing boilerplate.

Common Mistakes with Collections

While generic collections are powerful, they can be misused:

Avoid modifying a collection while iterating over it-this causes runtime exceptions. Instead, collect changes in a separate list and apply them after iteration.

Don’t assume order in Dictionary<K,V> or HashSet<T>-they are unordered by default.

Be cautious with Contains and Remove-they rely on Equals and GetHashCode, so override these methods in custom types.

Summary

Generic collections are essential tools in C#. They provide type-safe, efficient, and flexible data structures for storing and manipulating data. You’ve learned how to use List<T>, Dictionary<K,V>, HashSet<T>, Queue<T>, and Stack<T>, and how to choose the right one for your needs. You’ve also seen how to initialize, iterate, and optimize collections, and how they integrate with LINQ and interfaces. In the next article, we’ll explore Generic Interfaces-how to define and implement interfaces that work across types, and how they enable powerful abstractions in your codebase.