C# Generics — generic classes, methods, and constraints explained
Generics let you write a single class or method that works with any type while remaining fully type-safe at compile time. Without generics you'd either duplicate code for every type or use object and lose type safety (plus pay the boxing cost for value types).
The classic example is List<T> — one class that works as List<int>, List<string>, List<Order>, and so on.
Generic Method
The simplest place to start is a generic method. The type parameter T is a placeholder filled in by the caller.
// Swap any two values — no casting, no object
static void Swap<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
int x = 1, y = 2;
Swap(ref x, ref y);
Console.WriteLine($"{x}, {y}"); // 2, 1
string s1 = "hello", s2 = "world";
Swap(ref s1, ref s2);
Console.WriteLine($"{s1}, {s2}"); // world, helloThe compiler infers T from the arguments — no need to write Swap<int>(ref x, ref y).
Generic Class
// A simple stack that works for any type
public class Stack<T>
{
private readonly List<T> _items = new();
public void Push(T item) => _items.Add(item);
public T Pop()
{
if (_items.Count == 0)
throw new InvalidOperationException("Stack is empty.");
T top = _items[^1];
_items.RemoveAt(_items.Count - 1);
return top;
}
public T Peek() => _items.Count > 0
? _items[^1]
: throw new InvalidOperationException("Stack is empty.");
public int Count => _items.Count;
}
var stack = new Stack<int>();
stack.Push(10);
stack.Push(20);
stack.Push(30);
Console.WriteLine(stack.Pop()); // 30
Console.WriteLine(stack.Peek()); // 20
Console.WriteLine(stack.Count); // 2Multiple Type Parameters
// A key-value pair that accepts any two types
public class Pair<TKey, TValue>
{
public TKey Key { get; }
public TValue Value { get; }
public Pair(TKey key, TValue value) => (Key, Value) = (key, value);
public override string ToString() => $"[{Key}: {Value}]";
}
var pair = new Pair<string, int>("Alice", 30);
Console.WriteLine(pair); // [Alice: 30]
var coords = new Pair<double, double>(51.5, -0.1);
Console.WriteLine(coords); // [51.5: -0.1]Constraints — the where Keyword
Without constraints, T can be anything — you can only call methods on object. Constraints tell the compiler what capabilities T must have.
// where T : IComparable<T> — guarantees T has CompareTo()
static T Max<T>(T a, T b) where T : IComparable<T>
=> a.CompareTo(b) >= 0 ? a : b;
Console.WriteLine(Max(3, 7)); // 7
Console.WriteLine(Max("apple", "pear")); // pearCommon Constraints
| Constraint | Meaning |
|---|---|
where T : struct | T must be a value type |
where T : class | T must be a reference type |
where T : new() | T must have a public parameterless constructor |
where T : SomeClass | T must inherit from SomeClass |
where T : ISomeInterface | T must implement ISomeInterface |
where T : IComparable<T> | T must implement IComparable<T> |
where T : notnull | T must be non-nullable |
// Multiple constraints on the same type parameter
public class Repository<T> where T : class, new()
{
public T CreateDefault() => new T();
}
// Constraint on a method
static void PrintAll<T>(IEnumerable<T> items) where T : notnull
{
foreach (var item in items)
Console.WriteLine(item.ToString());
}Generic Interface
public interface IRepository<T>
{
void Add(T item);
T? GetById(int id);
IEnumerable<T> GetAll();
}
public class InMemoryRepository<T> : IRepository<T>
{
private readonly Dictionary<int, T> _store = new();
private int _nextId = 1;
public void Add(T item) => _store[_nextId++] = item;
public T? GetById(int id)
=> _store.TryGetValue(id, out var val) ? val : default;
public IEnumerable<T> GetAll() => _store.Values;
}
var repo = new InMemoryRepository<string>();
repo.Add("first");
repo.Add("second");
foreach (var item in repo.GetAll())
Console.WriteLine(item);
// first
// seconddefault(T)
When you need the zero/null value of an unknown type, use default(T) (or just default in context where the type can be inferred).
static T FirstOrDefault<T>(IEnumerable<T> source)
{
foreach (var item in source)
return item;
return default!; // int → 0, string → null, bool → false
}
Console.WriteLine(FirstOrDefault(new[] { 5, 10, 15 })); // 5
Console.WriteLine(FirstOrDefault(Array.Empty<int>())); // 0Real-World Example: Generic Result Type
// Represents either a success value or an error — avoids exceptions for expected failures
public class Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public string? Error { get; }
private Result(bool success, T? value, string? error)
=> (IsSuccess, Value, Error) = (success, value, error);
public static Result<T> Ok(T value) => new(true, value, null);
public static Result<T> Fail(string err) => new(false, default, err);
}
Result<int> Parse(string input)
=> int.TryParse(input, out int n)
? Result<int>.Ok(n)
: Result<int>.Fail($"'{input}' is not a valid integer.");
var ok = Parse("42");
var fail = Parse("abc");
Console.WriteLine(ok.IsSuccess ? $"Got: {ok.Value}" : $"Error: {ok.Error}");
// Got: 42
Console.WriteLine(fail.IsSuccess ? $"Got: {fail.Value}" : $"Error: {fail.Error}");
// Error: 'abc' is not a valid integer.Generics vs object
Generics (T) | object | |
|---|---|---|
| Type safety | Compile-time | Runtime (cast required) |
| Boxing for value types | No | Yes |
| Performance | Full — no overhead | Slower for value types |
| IntelliSense / tooling | Full | Limited |
| Null safety | Constrained | Always nullable |
Prefer generics whenever you'd otherwise write the same logic for multiple types or reach for object as a container.