Yesterday you met enums and structs — the lightweight minimalists of C#. You gave names to numbers and learned that sometimes a tent beats a house. Today we're going in the opposite direction: we're building something so flexible it can work with any type. Meet generics — the ultimate "write once, use everywhere" feature of C#.
1. The Problem Generics Solve
Imagine you run a storage business. A customer shows up with a guitar and asks you to store it. No problem — you put it in a box labeled "Guitar." Next customer brings a cake. New box, labeled "Cake." Then someone brings a live iguana. Another box.
Now imagine doing this without labels. Every box just says "Stuff." When a customer comes back to pick up their guitar, you open a box and... it's the iguana. Screaming ensues.
That's what programming without generics is like. Before generics existed in C#, we had ArrayList — a collection that stored everything as object. You could put in an int, a string, and a Pizza, and the compiler would just shrug and say, "Sure, whatever."
// The bad old days (don't do this)
System.Collections.ArrayList oldList = new();
oldList.Add(42);
oldList.Add("hello");
oldList.Add(3.14);
// Getting things out requires casting — and hoping you remembered what's in there
int number = (int)oldList[0]; // Works... this time
string text = (string)oldList[1]; // Also fine
// string oops = (string)oldList[0]; // 💥 Runtime crash! InvalidCastException!
The problem? No type safety. The compiler can't help you because everything is object. Bugs hide until runtime, and runtime bugs are the worst kind — they show up in production at 2 AM on a Saturday.
Generics fix this by letting you say, "This box holds guitars only." The compiler enforces it, and you never accidentally get an iguana.
2. Your First Generic — List<T>
You've already been using generics without knowing it! Every time you wrote List<string> or List<int>, the <T> part was generics at work.
// A list that ONLY holds strings — the compiler enforces this
List<string> names = ["Alice", "Bob", "Charlie"];
names.Add("Diana");
// names.Add(42); // ❌ Compiler error! Can't add int to List<string>
// A list that ONLY holds integers
List<int> scores = [95, 87, 42, 100];
scores.Add(73);
// scores.Add("hello"); // ❌ Nope. Integers only.
// No casting needed — the compiler knows exactly what's in there
string firstName = names[0]; // "Alice" — no cast required
int topScore = scores[3]; // 100 — clean and safe
Console.WriteLine($"First name: {firstName}");
Console.WriteLine($"Top score: {topScore}");
The T in List<T> is a type parameter — a placeholder that gets replaced with a real type when you use it. List<string> means "a list where T is string." List<int> means "a list where T is int." Same code, different types, complete type safety.
3. Building Your Own Generic Class
You're not limited to built-in generics. Let's build our own — a simple box that can hold exactly one item of any type:
class Box<T>
{
public T Content { get; private set; }
public bool IsEmpty { get; private set; } = true;
public void Put(T item)
{
Content = item;
IsEmpty = false;
Console.WriteLine($"Stored: {item}");
}
public T Take()
{
if (IsEmpty)
throw new InvalidOperationException("The box is empty!");
T item = Content;
Content = default!;
IsEmpty = true;
Console.WriteLine($"Retrieved: {item}");
return item;
}
}
// A box for strings
Box<string> nameBox = new();
nameBox.Put("Farhad");
string name = nameBox.Take(); // "Farhad" — no casting!
// A box for integers
Box<int> numberBox = new();
numberBox.Put(42);
int answer = numberBox.Take(); // 42
// A box for your custom type
Box<DateTime> dateBox = new();
dateBox.Put(DateTime.Now);
DateTime stored = dateBox.Take();
One class definition, infinite type possibilities. The compiler generates specialized versions for each type you use. You get the reusability of object with the safety of specific types.
4. Generic Methods — Functions That Adapt
You don't need a whole class to use generics. Individual methods can be generic too:
// A method that swaps any two values
static void Swap<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
int x = 10, y = 20;
Console.WriteLine($"Before: x={x}, y={y}"); // Before: x=10, y=20
Swap(ref x, ref y); // Compiler infers T is int
Console.WriteLine($"After: x={x}, y={y}"); // After: x=20, y=10
string first = "hello", second = "world";
Swap(ref first, ref second); // Compiler infers T is string
Console.WriteLine($"{first} {second}"); // world hello
Notice we didn't write Swap<int>(ref x, ref y) — the compiler figured out T on its own from the arguments. This is called type inference, and it makes generic code look almost like regular code.
Here's another practical one — finding the maximum in an array:
static T FindMax<T>(T[] items) where T : IComparable<T>
{
if (items.Length == 0)
throw new ArgumentException("Array is empty");
T max = items[0];
for (int i = 1; i < items.Length; i++)
{
if (items[i].CompareTo(max) > 0)
max = items[i];
}
return max;
}
int[] numbers = [3, 7, 1, 9, 4];
Console.WriteLine(FindMax(numbers)); // 9
string[] words = ["banana", "apple", "cherry"];
Console.WriteLine(FindMax(words)); // cherry (alphabetical)
Wait, what's that where T : IComparable<T> thing? That's a constraint, and it's the topic of our next section.
5. Constraints — Setting Rules for T
A plain T can be anything — an int, a string, a Pizza, a Thread. Sometimes that's too flexible. What if your generic code needs to call .CompareTo() on T? Not every type has that method.
Constraints let you restrict what T can be:
// T must implement IComparable<T>
static T FindMin<T>(T a, T b) where T : IComparable<T>
{
return a.CompareTo(b) <= 0 ? a : b;
}
// T must be a class (reference type)
class Cache<T> where T : class
{
private T? _cached;
public void Store(T item) => _cached = item;
public T? Get() => _cached;
}
// T must have a parameterless constructor
static T CreateInstance<T>() where T : new()
{
return new T(); // Only works because we guaranteed T has a constructor
}
// Multiple constraints — T must be a class, implement an interface, AND have a constructor
class Repository<T> where T : class, IComparable<T>, new()
{
private readonly List<T> _items = [];
public void Add(T item) => _items.Add(item);
public T GetSmallest()
{
if (_items.Count == 0) return new T();
return _items.Min()!;
}
}
Here's the full menu of constraints:
where T : struct— T must be a value type (int, double, your custom structs)where T : class— T must be a reference type (classes, strings, arrays)where T : new()— T must have a parameterless constructorwhere T : SomeBaseClass— T must inherit from a specific classwhere T : ISomeInterface— T must implement an interfacewhere T : notnull— T cannot be nullable
You can combine them: where T : class, IDisposable, new() means "T must be a reference type that implements IDisposable and has a parameterless constructor." The compiler checks all of this at compile time — not at runtime. No surprises.
6. Multiple Type Parameters — When One T Isn't Enough
Some generics need more than one type placeholder. The most famous example? Dictionary<TKey, TValue>:
// Two type parameters: TKey and TValue
Dictionary<string, int> wordCounts = new()
{
["hello"] = 5,
["world"] = 3,
["csharp"] = 42
};
Console.WriteLine(wordCounts["csharp"]); // 42
// Your own multi-parameter generic
class Pair<TFirst, TSecond>
{
public TFirst First { get; }
public TSecond Second { get; }
public Pair(TFirst first, TSecond second)
{
First = first;
Second = second;
}
public override string ToString() => $"({First}, {Second})";
}
Pair<string, int> nameAndAge = new("Alice", 30);
Console.WriteLine(nameAndAge); // (Alice, 30)
Pair<DateTime, decimal> transactionLog = new(DateTime.Now, 99.99m);
Console.WriteLine(transactionLog); // (4/6/2026 12:00:00 AM, 99.99)
// You can constrain each type parameter independently
class Mapper<TInput, TOutput>
where TInput : notnull
where TOutput : new()
{
private readonly Func<TInput, TOutput> _transform;
public Mapper(Func<TInput, TOutput> transform)
{
_transform = transform;
}
public TOutput Map(TInput input) => _transform(input);
public TOutput Default() => new TOutput();
}
The naming convention: use T for a single type parameter, and T + descriptive name for multiple (TKey, TValue, TInput, TOutput, TEntity). Single-letter T is fine when the meaning is obvious; use longer names when there are multiple parameters.
7. Generics Under the Hood — Why They're Fast
Here's a fun fact: C# generics are not just a compiler trick. When you use List<int>, the .NET runtime generates a specialized version of List optimized for int. Value types like int don't get boxed (converted to object and stored on the heap) — they stay on the stack, right where they belong.
This is different from Java's generics, which use type erasure — at runtime, List<String> and List<Integer> become the same List. Java generics are a compile-time illusion; C# generics are the real deal.
// No boxing! This is genuinely efficient
List<int> numbers = [1, 2, 3, 4, 5];
int sum = 0;
foreach (int n in numbers)
{
sum += n; // No object conversion, no heap allocation
}
// Compare to the old ArrayList way:
System.Collections.ArrayList oldList = new() { 1, 2, 3, 4, 5 };
int oldSum = 0;
foreach (object obj in oldList)
{
oldSum += (int)obj; // Boxing on add, unboxing on read — slow and dangerous
}
Performance comparison:
List<int>— items stored as rawintvalues. Fast.ArrayList— eachintis boxed into anobjecton the heap. Each read unboxes it back. Slow, allocates garbage, and can crash if you cast wrong.
This is why you should always use generic collections (List<T>, Dictionary<TKey, TValue>, Queue<T>, Stack<T>) and never use ArrayList or Hashtable in modern C#.
8. Your Homework: Build a Generic Stack
Build your own SimpleStack<T> — a last-in, first-out (LIFO) collection. Don't use Stack<T> from the framework; implement it yourself using an array or List<T>:
class SimpleStack<T>
{
private readonly List<T> _items = [];
public int Count => _items.Count;
public void Push(T item)
{
_items.Add(item);
Console.WriteLine($"Pushed: {item}");
}
public T Pop()
{
if (_items.Count == 0)
throw new InvalidOperationException("Stack is empty!");
T item = _items[^1]; // Last item (C# index-from-end)
_items.RemoveAt(_items.Count - 1);
Console.WriteLine($"Popped: {item}");
return item;
}
public T Peek()
{
if (_items.Count == 0)
throw new InvalidOperationException("Stack is empty!");
return _items[^1];
}
public override string ToString() =>
$"Stack [{string.Join(", ", _items)}] (top: {(_items.Count > 0 ? _items[^1].ToString() : "empty")})";
}
// Test with integers
SimpleStack<int> numberStack = new();
numberStack.Push(10);
numberStack.Push(20);
numberStack.Push(30);
Console.WriteLine(numberStack); // Stack [10, 20, 30] (top: 30)
Console.WriteLine($"Peek: {numberStack.Peek()}"); // 30
numberStack.Pop(); // Popped: 30
numberStack.Pop(); // Popped: 20
Console.WriteLine(numberStack); // Stack [10] (top: 10)
// Test with strings
SimpleStack<string> bookStack = new();
bookStack.Push("C# in Depth");
bookStack.Push("Clean Code");
bookStack.Push("The Pragmatic Programmer");
Console.WriteLine($"Next book to read: {bookStack.Peek()}");
Bonus challenge: Add a Contains(T item) method and a constructor that accepts an initial collection of items. Then make it implement IEnumerable<T> so you can use it in a foreach loop. (We'll learn about IEnumerable<T> properly in a future lesson, but you can search Microsoft Learn to get a head start!)
Summary of Day 14
- Generics let you write type-safe, reusable code with type parameters (
T). List<T>,Dictionary<TKey, TValue>, and other generic collections replace old untyped collections likeArrayList.- You can create your own generic classes (
Box<T>) and generic methods (Swap<T>). - Constraints (
where T : ...) restrict what types can be used, enabling you to call specific methods onT. - Multiple type parameters (
Pair<TFirst, TSecond>) let you generalize over several types at once. - C# generics are reified — the runtime generates optimized code for each type, avoiding boxing overhead.
- Always prefer generic collections over non-generic ones in modern C#.
Tomorrow: we'll talk about LINQ — the most loved feature in all of C#. It lets you query collections with elegant, readable syntax that feels almost like SQL. Filter, sort, transform, and aggregate data in a single line. Once you learn LINQ, you'll never write a manual for loop again. 🚀
See you on Day 15!