Generics in C#
Introduction
Generics let you write a class, method, interface, or delegate whose exact data type is a parameter, filled in later by whatever code actually uses it - List<T> is the canonical example: it's written once, entirely without knowing whether T will end up being int, string, Customer, or anything else, and yet a List<int> and a List<string> are each fully type-safe, with no casting and no risk of accidentally putting a string into a List<int>.
This guide walks through generics in depth: the concrete problem they solve relative to object-based code, the full range of places C# lets you use a type parameter (classes, methods, interfaces, delegates), constraints that narrow what a type parameter is allowed to be, variance revisited from this series' Interfaces guide in more depth, and how generics are actually compiled - a detail that explains both their performance characteristics and several of their more surprising behaviors.
class Box<T> โ a box that holds SOME type T, decided per instance
Box<int> intBox โ T is int for this specific instance - type-safe, no casting
Box<string> stringBox โ T is string for THIS instance - a completely independent type from Box<int>, despite sharing the same class DEFINITION
1. The Problem Generics Solve
Before generics: object-based collections, and the two problems they create
// Pre-generics C# (this is what ArrayList looked like, and largely still does)
ArrayList list = new ArrayList();
list.Add(42);
list.Add("hello"); // โ compiles fine - ArrayList has NO idea what type it's "supposed" to hold
int first = (int) list[0]; // requires an explicit CAST - the compiler can't verify this is safe
int second = (int) list[1]; // โ compiles, but throws InvalidCastException at RUNTIME - "hello" isn't an int
Before generics existed in C# (introduced in C# 2.0), a general-purpose, reusable collection like ArrayList could only store object - which meant it could hold anything, including a mix of unrelated types in the same list, and reading anything back out required an explicit cast that the compiler had no way to verify was actually correct. This created exactly two problems:
- No type safety - the wrong type of item can be added, and the bug isn't caught until a cast fails at runtime
- Boxing overhead for value types - an
intstored asobjecthas to be wrapped/unwrapped (Section 8 covers this in more depth)
With generics: the type is fixed, known, and enforced at compile time
List<int> list = new List<int>();
list.Add(42); // list.Add("hello"); // โ does NOT compile - the compiler knows this List<int> only holds int
int first = list[0]; // no cast needed - the compiler already knows this is an int
List<int> declares, once and for all at the point of instantiation, exactly what type it holds - every Add call and every read is checked by the compiler, the same guarantee this series' Delegates guide describes for method signatures, just applied here to a container's element type instead.
This is the entire value proposition of generics in one example: the reusability of ArrayList (one implementation, works for any type) combined with the type safety ArrayList never had.
Generics also solve a code-duplication problem, not just a safety one
// โ Without generics, supporting multiple types means writing near-identical classes repeatedly
public class IntBox { public int Value; }
public class StringBox { public string Value; }
public class CustomerBox { public Customer Value; }
// ... and so on, for every type that ever needs "a box holding one value"
Even setting the type-safety problem aside, generics eliminate a real, tedious form of code duplication - without them, supporting a "box holding one value" pattern for int, string, and Customer would mean writing three (or, realistically, many more) nearly identical classes differing only in one field's type, which is exactly the kind of duplication generics were built to eliminate by parameterizing that one varying piece.
2. Generic Classes
Declaring a class with a type parameter
public class Box<T>
{
private T _value;
public void SetValue(T value) => _value = value;
public T GetValue() => _value;
}
T here is a type parameter - a placeholder standing in for whatever concrete type the class is used with. Inside the class body, T is used exactly like any real type name: as a field's type, a parameter's type, a return type. The class definition itself is written once, entirely without knowing what T will actually be.
Instantiating a generic class with a specific type argument
Box<int> intBox = new Box<int>();
intBox.SetValue(42);
int value = intBox.GetValue(); // strongly typed - no cast needed
Box<string> stringBox = new Box<string>();
stringBox.SetValue("hello");
// stringBox.SetValue(42); // โ compile error - this Box's T is string, not int
Box<int> and Box<string> are each fully type-safe, independent uses of the same underlying class definition - the compiler substitutes T with the concrete type argument (int, string) at each usage, and enforces that substitution consistently everywhere T appears in the class.
A generic class can have multiple members all referencing the same type parameter
public class Repository<T> where T : class // constraint covered in Section 5
{
private readonly List<T> _items = new();
public void Add(T item) => _items.Add(item);
public T GetById(int index) => _items[index];
public IEnumerable<T> GetAll() => _items;
public int Count => _items.Count;
}
var userRepo = new Repository<User>();
userRepo.Add(new User { Name = "Alice" });
User first = userRepo.GetById(0); // strongly typed throughout - every member consistently uses T
Every member of Repository<T> - the field, Add, GetById, GetAll - consistently refers to the same T, which is fixed once, for the lifetime of a given Repository<User> instance, at the point it was constructed. This is the same generic Repository pattern this series' Interfaces guide's Section 8 introduces, revisited here with the class implementation, not just the interface contract, in view.
3. Generic Methods
A single method can be generic, even inside an entirely non-generic class.
public class Utilities // NOT a generic class
{
public static T FindMax<T>(T a, T b) where T : IComparable<T>
{
return a.CompareTo(b) > 0 ? a : b;
}
}
int maxInt = Utilities.FindMax(3, 7); // T inferred as int
string maxString = Utilities.FindMax("apple", "banana"); // T inferred as string, entirely separately
FindMax<T> is a generic method, declared on an otherwise ordinary, non-generic class - the type parameter <T> belongs to the method itself, not the class, which means each call to FindMax can use a completely different type, independent of any other call. This is a genuinely important, distinct capability from generic classes (Section 2): a generic class fixes its type parameter once per instance, while a generic method can vary its type parameter on every single call.
Explicit type arguments vs. inferred ones
int maxInt = Utilities.FindMax<int>(3, 7); // explicit - spelling out <int> is optional here
int maxInt2 = Utilities.FindMax(3, 7); // inferred - the compiler figures out T = int from the arguments
Section 9 covers type inference in depth, but worth introducing here: in the common case, you don't need to write <int> explicitly - the compiler can usually work out the type argument from the method's actual arguments, which is why most real-world generic method calls in C# look exactly like ordinary method calls, with no visible angle brackets at all.
4. Type Parameter Naming and Multiple Type Parameters
T is convention, not a requirement - and more specific names are often clearer.
public class Cache<TKey, TValue> // multiple type parameters, named descriptively
{
private readonly Dictionary<TKey, TValue> _store = new();
public void Set(TKey key, TValue value) => _store[key] = value;
public TValue Get(TKey key) => _store[key];
}
var cache = new Cache<string, int>();
cache.Set("age", 30);
int age = cache.Get("age");
T alone is the conventional name for a single, generically-meaningful type parameter (as in List<T>, Box<T>), but once a class or method has more than one type parameter, or the parameter's role is specific enough to name clearly, the T-prefixed naming convention (TKey, TValue, TResult, TInput) is standard practice across .NET - this is purely a readability convention, but it's followed consistently enough that deviating from it without a reason is worth avoiding, exactly as this series' Interfaces guide notes for the I-prefix convention on interface names.
Multiple type parameters are entirely independent of one another
public class Pair<TFirst, TSecond>
{
public TFirst First { get; }
public TSecond Second { get; }
public Pair(TFirst first, TSecond second)
{
First = first;
Second = second;
}
}
var pair = new Pair<string, int>("age", 30); // TFirst = string, TSecond = int - no relationship required between them
There's no requirement that multiple type parameters relate to each other in any way - TFirst and TSecond can be completely unrelated types, and the compiler tracks each independently, exactly as Dictionary<TKey, TValue> in the framework itself does.
5. Constraints: Narrowing What a Type Parameter Can Be
The problem constraints solve: T alone tells the compiler almost nothing about what's actually possible
public class Repository<T>
{
public void Validate(T item)
{
// item.Id // โ won't compile - the compiler has NO idea T has an "Id" property;
// T could be literally anything, including int or string
}
}
Without any constraint, the compiler must assume T could be absolutely any type - which means it can't let you call any member on a T value beyond what every single possible type universally supports (which is essentially just the members inherited from object, like ToString() and Equals()). Constraints are how you tell the compiler "actually, T will always be at least this specific," unlocking the ability to call whatever members that guarantee implies.
where T : <interface> - the most common constraint
public interface IEntity
{
int Id { get; }
}
public class Repository<T> where T : IEntity
{
public void Validate(T item)
{
Console.WriteLine($"Validating entity with Id {item.Id}"); // โ
now compiles - T is guaranteed to have Id
}
}
Constraining T to implement IEntity tells the compiler that whatever concrete type T ends up being, it's guaranteed to have an Id property - this is what makes item.Id a legal expression inside the generic class, closing exactly the gap the unconstrained version above ran into.
where T : <base class> - constraining to a class hierarchy
public abstract class Entity
{
public int Id { get; set; }
}
public class Repository<T> where T : Entity
{
public void PrintId(T item) => Console.WriteLine(item.Id);
}
Just as with interfaces, constraining T to inherit from a specific base class guarantees access to that base class's members - this directly combines with this series' Abstract Classes guide's discussion of shared base-class implementation, letting a generic type rely on whatever the base class provides.
where T : class and where T : struct - reference type vs. value type constraints
public class Cache<T> where T : class // T must be a reference type
{
public T? Value; // nullable reference type - makes sense because T is guaranteed to be a class
}
public struct Optional<T> where T : struct // T must be a value type (int, bool, DateTime, custom structs)
{
public T Value;
public bool HasValue;
}
These constraints restrict T along the fundamental reference-type/value-type divide covered in this series' OOP guide - useful when a generic type's implementation genuinely depends on that distinction (nullability semantics differ meaningfully between the two, for instance).
where T : new() - requiring a parameterless constructor
public class Factory<T> where T : new()
{
public T CreateInstance() => new T(); // only legal because the constraint GUARANTEES this constructor exists
}
var factory = new Factory<Customer>(); // only compiles if Customer has an accessible parameterless constructor
Without where T : new(), new T() inside a generic class would not compile - the compiler has no way to know, in general, whether an arbitrary T even has a parameterless constructor available; this constraint is specifically what unlocks that capability, and it's a common pattern for generic factories.
Combining multiple constraints on a single type parameter
public class Repository<T> where T : class, IEntity, new()
{
public T CreateDefault() => new T(); // requires new()
public void Validate(T item) => Console.WriteLine(item.Id); // requires IEntity
// T? nullableRef; // valid because T is constrained to class
}
Constraints can be combined with commas - a single
Comments
No comments yet. Start the discussion.