π Lesson 1.2: Generics
You've used List<T> since the intro course. Now you'll learn the feature that makes it work β generics β and write your own reusable, type-safe classes and methods that work with any type.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain the problem generics solve: reuse and type safety together
- Write a generic class with a type parameter
<T> - Write generic methods, including with multiple type parameters
- Restrict type parameters with
whereconstraints - Recognize the generic types already used throughout .NET
Estimated Time: 60 minutes
Project: Build your own generic container and utility methods.
In This Lesson
The Problem Generics Solve
Imagine writing a simple "box" that holds one value. Without generics, you have two bad options.
Option 1 β a separate class per type. Endless duplication:
class IntBox { public int Value; }
class StringBox { public string Value; }
class DogBox { public Dog Value; }
// ...a new class for every type, forever
Option 2 β hold everything as object. One class, but you lose type safety and must cast:
class Box { public object Value; }
Box b = new Box();
b.Value = 42;
int n = (int)b.Value; // must cast back β and the compiler can't protect you
string s = (string)b.Value; // compiles, but CRASHES at runtime β it's really an int
π Definition
Generics: a way to write a class or method with a placeholder type, filled in when the code is used β giving you one reusable definition that stays fully type-safe.
Generics give you the best of both: write the box once, use it with any type, and keep the compiler's type checking. That's exactly what List<T> is β one class that safely stores ints, strings, or your own objects.
Generic Classes
You add a type parameter in angle brackets after the class name. By convention it's called T (for "type"):
class Box<T>
{
public T Value { get; set; }
public void Show()
{
Console.WriteLine($"Box contains: {Value}");
}
}
Inside the class, T stands in for whatever type the caller chooses. You pick that type when you create an object:
Box<int> intBox = new Box<int>();
intBox.Value = 42;
intBox.Show(); // Box contains: 42
Box<string> textBox = new Box<string>();
textBox.Value = "hello";
textBox.Show(); // Box contains: hello
// intBox.Value = "oops"; // β Compile error β intBox only holds ints
β Type safety, kept
A Box<int> only accepts ints; a Box<string> only accepts strings β enforced at compile time, with no casting needed when you read Value back. One class definition, unlimited type-safe variations.
(one definition)"] --> B["Boxβ¨intβ©"] A --> C["Boxβ¨stringβ©"] A --> D["Boxβ¨Dogβ©"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style B fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style C fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style D fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px
Generic Methods
A single method can be generic too, even in a non-generic class. Put the type parameter after the method name:
void Swap<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
int x = 1, y = 2;
Swap<int>(ref x, ref y);
Console.WriteLine($"{x}, {y}"); // 2, 1
string first = "A", second = "B";
Swap(ref first, ref second); // type inferred β no <string> needed
Console.WriteLine($"{first}, {second}"); // B, A
π‘ Type inference
Notice the second call is just Swap(ref first, ref second) β no <string>. C# usually infers the type parameter from the arguments you pass, so you rarely have to write it explicitly. (The ref keyword here passes variables by reference so the swap is visible to the caller.)
Here's a more practical generic method β printing any array of any type:
void PrintAll<T>(T[] items)
{
foreach (T item in items)
{
Console.WriteLine(item);
}
}
PrintAll(new int[] { 1, 2, 3 });
PrintAll(new string[] { "a", "b" });
Multiple Type Parameters
A generic type or method can have more than one type parameter, separated by commas. A classic example is a "pair" holding two values of possibly different types:
class Pair<TFirst, TSecond>
{
public TFirst First { get; set; }
public TSecond Second { get; set; }
}
Pair<string, int> age = new Pair<string, int>
{
First = "Ada",
Second = 30
};
Console.WriteLine($"{age.First} is {age.Second}"); // Ada is 30
π‘ Naming conventions
With a single type parameter, T is standard. With several, use descriptive names prefixed with T: TKey, TValue, TFirst, TSecond. You'll see exactly TKey/TValue in Dictionary<TKey, TValue>, coming up in the next lesson.
Constraints with where
Sometimes a generic method needs to do something with T β compare it, create it, or call a method on it. But if T could be anything, the compiler can't assume those abilities. A constraint restricts what types are allowed, unlocking their capabilities.
This won't compile, because not every type supports >:
T Max<T>(T a, T b)
{
return a > b ? a : b; // β Error β C# doesn't know T supports >
}
Add where T : IComparable<T> to promise that T can be compared, then use its CompareTo method:
T Max<T>(T a, T b) where T : IComparable<T>
{
return a.CompareTo(b) > 0 ? a : b;
}
Console.WriteLine(Max(3, 9)); // 9
Console.WriteLine(Max("apple", "pear")); // pear (alphabetical)
Common constraints you'll encounter:
| Constraint | Means "T mustβ¦" |
|---|---|
where T : class | be a reference type |
where T : struct | be a value type |
where T : new() | have a public parameterless constructor (so you can new T()) |
where T : SomeClass | be, or derive from, SomeClass |
where T : ISomeInterface | implement ISomeInterface |
β Constraints connect to what you know
Notice how constraints build on the intro's OOP: where T : IComparable<T> uses the interface you saw in Lesson 5.2, and where T : Animal uses inheritance. Constraints let generic code safely rely on those capabilities.
Generics in .NET
You've been standing on generics the whole time. .NET's collections are generic, which is why they're both flexible and type-safe:
| Generic type | What it is |
|---|---|
List<T> | A resizable list of T (from the intro course) |
Dictionary<TKey, TValue> | Key/value lookups (next lesson) |
Nullable<T> (T?) | A value type that can also be null |
Func<T, TResult> | A function reference (Module 2) |
Task<T> | An async operation that returns a T (Module 4) |
π‘ Why it matters: Recognizing the<T>pattern means you can read and use huge parts of .NET confidently. When you see<...>, you're looking at "this type, specialized to work with that type."
Exercise & Quiz
ποΈ Exercise: A Generic Stack
Objective: Build a small generic container and a generic utility method.
Instructions:
- Create a new project called
GenericsLab. - Write a generic class
SimpleStack<T>backed by aList<T>. Give itPush(T item),T Pop()(remove and return the last item), and aCountproperty. - Use it with two different types (e.g. a
SimpleStack<int>and aSimpleStack<string>). - Bonus: Write a generic method
T First<T>(List<T> items)that returns the first element, throwing anInvalidOperationException(from Lesson 1.1!) if the list is empty.
Starter Code:
var numbers = new SimpleStack<int>();
numbers.Push(10);
numbers.Push(20);
Console.WriteLine(numbers.Pop()); // 20
Console.WriteLine(numbers.Count); // 1
class SimpleStack<T>
{
private List<T> _items = new List<T>();
// TODO: Push(T item)
// TODO: T Pop()
// TODO: int Count { get; }
}
π‘ Hint
Push is _items.Add(item);. For Pop, grab the last item at index _items.Count - 1, remove it with RemoveAt, and return it. Count is an expression-bodied property: public int Count => _items.Count;.
β Solution
var numbers = new SimpleStack<int>();
numbers.Push(10);
numbers.Push(20);
Console.WriteLine(numbers.Pop()); // 20
Console.WriteLine(numbers.Count); // 1
var words = new SimpleStack<string>();
words.Push("first");
words.Push("second");
Console.WriteLine(words.Pop()); // second
Console.WriteLine(First(new List<int> { 5, 6, 7 })); // 5
class SimpleStack<T>
{
private List<T> _items = new List<T>();
public int Count => _items.Count;
public void Push(T item)
{
_items.Add(item);
}
public T Pop()
{
if (_items.Count == 0)
{
throw new InvalidOperationException("The stack is empty.");
}
T last = _items[_items.Count - 1];
_items.RemoveAt(_items.Count - 1);
return last;
}
}
// Bonus generic method (top-level methods must appear before type declarations)
T First<T>(List<T> items)
{
if (items.Count == 0)
{
throw new InvalidOperationException("The list is empty.");
}
return items[0];
}
Output:
20
1
second
5
π― Quick Quiz
Question 1: What is the main advantage of generics over storing values as object?
Question 2: In Box<T>, what does T represent?
Question 3: What does where T : IComparable<T> do?
Summary
π Key Takeaways
- Generics let you write a class or method once with a placeholder type, keeping full type safety and avoiding casts.
- Declare a generic class with
class Name<T>and useTas a stand-in type inside it. - Generic methods put the parameter after the method name; C# often infers it from the arguments.
- Use multiple type parameters (
<TKey, TValue>) and name them descriptively with aTprefix. whereconstraints restrictT(e.g.: IComparable<T>,: new()) so generic code can safely use those capabilities.
π Additional Resources
π What's Next?
Generics power all of .NET's collections. In Lesson 1.3: Collections in Depth, you'll go beyond List<T> to dictionaries, sets, queues, and stacks β and learn how to pick the right one for each job.
π Generics unlocked!
You can now write reusable, type-safe building blocks. Next: the collections built on them.