Generics in C#
Generic classes, methods, constraints, covariance, and contravariance in C#.
Generic Classes
Without generics, you would write a separate IntStack, StringStack, and CustomerStack — or use object and lose type safety. Generics let you write the logic once and parameterize it by type. The compiler generates a fully type-safe version for each type argument, with no boxing for value types and no runtime casts needed.
// A generic stack — works with any type T
public class Stack<T>
{
private readonly List<T> _items = new();
public int Count => _items.Count;
public void Push(T item) => _items.Add(item);
public T Pop()
{
if (_items.Count == 0)
throw new InvalidOperationException("Stack is empty");
T item = _items[^1];
_items.RemoveAt(_items.Count - 1);
return item;
}
public T Peek() => _items.Count > 0
? _items[^1]
: throw new InvalidOperationException("Stack is empty");
// Try-pattern — returns false instead of throwing, stores result in out param
public bool TryPop(out T item)
{
if (_items.Count == 0) { item = default!; return false; }
item = Pop();
return true;
}
}
// Each usage gets a fully typed, independent version
var intStack = new Stack<int>();
intStack.Push(1);
intStack.Push(2);
Console.WriteLine(intStack.Pop()); // 2
var stringStack = new Stack<string>();
stringStack.Push("hello");
Generic Methods
Methods can be generic even when the class they belong to is not. The type parameter is declared on the method and inferred from the arguments in most cases, so callers rarely need to specify it explicitly.
public static class ArrayUtils
{
// T is inferred from the types of a and b
public static void Swap<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
// Multiple type parameters — useful for pairing two independent collections
public static Dictionary<TKey, TValue> ZipToDictionary<TKey, TValue>(
IEnumerable<TKey> keys,
IEnumerable<TValue> values)
where TKey : notnull
{
return keys.Zip(values).ToDictionary(pair => pair.First, pair => pair.Second);
}
// Type inference — caller passes int arguments, T becomes int automatically
public static T[] Repeat<T>(T value, int times) =>
Enumerable.Repeat(value, times).ToArray();
}
int x = 1, y = 2;
ArrayUtils.Swap(ref x, ref y); // T inferred as int
Console.WriteLine($"x={x}, y={y}"); // x=2, y=1
var dict = ArrayUtils.ZipToDictionary(
new[] { "a", "b" },
new[] { 1, 2 });
Generic Constraints
Without constraints, the compiler treats T as object — you cannot call any methods on it. Constraints tell the compiler what T is guaranteed to support, unlocking the members you need while keeping the method generic. They also prevent callers from passing inappropriate types.
// where T : IComparable<T> — T has a CompareTo method, so we can compare values
public 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", "zap")); // zap
// where T : class — T is a reference type, enabling null checks
public static T RequireNonNull<T>(T? value, string name) where T : class
{
return value ?? throw new ArgumentNullException(name);
}
// where T : struct, IParsable<T> — T is a value type that supports TryParse
public static T? ParseOrNull<T>(string s) where T : struct, IParsable<T>
{
return T.TryParse(s, null, out T result) ? result : null;
}
// where T : new() — T has a parameterless constructor; you can call new T()
public static T CreateDefault<T>() where T : new() => new T();
// Multiple constraints — combine class, interface, and constructor requirements
public static void Process<T>(T item)
where T : class, IDisposable, new()
{
using var resource = new T(); // new() constraint enables this
resource.Dispose(); // IDisposable constraint enables this
}
// Combining interface and base class constraints
public static void Save<T>(T entity)
where T : BaseEntity, IValidatable
{
if (!entity.IsValid(out string? error))
throw new ValidationException(error);
// save to DB...
}
Generic Result Type
One of the most practical uses of generics is a Result<T> type that makes the success/failure contract explicit in the return type. Instead of throwing exceptions for expected failures (which is expensive and breaks flow), you return a value that forces callers to handle both outcomes.
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 = success;
Value = value;
Error = error;
}
// Factory methods — clear intent, no constructor ambiguity
public static Result<T> Ok(T value) => new(true, value, null);
public static Result<T> Fail(string error) => new(false, default, error);
// Map — transform the Value if success, propagate failure otherwise
public Result<TOut> Map<TOut>(Func<T, TOut> mapper) =>
IsSuccess ? Result<TOut>.Ok(mapper(Value!)) : Result<TOut>.Fail(Error!);
public override string ToString() =>
IsSuccess ? $"Ok({Value})" : $"Fail({Error})";
}
// Usage — callers must check IsSuccess before using Value
Result<int> Parse(string s) =>
int.TryParse(s, out int n)
? Result<int>.Ok(n)
: Result<int>.Fail($"'{s}' is not a valid integer");
var result = Parse("42").Map(n => n * 2);
Console.WriteLine(result); // Ok(84)
var failed = Parse("abc");
Console.WriteLine(failed); // Fail('abc' is not a valid integer)
Covariance (out T)
Covariance allows a more derived type to be substituted where a base type is expected, but only for interfaces that produce values (read-only). The out keyword on a type parameter declares it covariant. IEnumerable<T> is the most common example in the BCL — a list of dogs is usable wherever a list of animals is expected, because you are only reading from it.
// IEnumerable<T> is covariant — defined as IEnumerable<out T> in the BCL
IEnumerable<string> strings = new List<string> { "a", "b" };
IEnumerable<object> objects = strings; // works because IEnumerable is covariant
// Define your own covariant interface — out T means T only appears in return positions
public interface IProducer<out T>
{
T Produce(); // T is returned (produced), never consumed — safe for covariance
}
public class StringProducer : IProducer<string>
{
public string Produce() => "hello";
}
// Covariance: StringProducer satisfies IProducer<object> because string is-a object
IProducer<object> producer = new StringProducer();
Console.WriteLine(producer.Produce()); // hello
Contravariance (in T)
Contravariance allows a less derived type where a more derived type is expected, for interfaces that consume values (write-only). The in keyword declares a type parameter contravariant. Action<T> and IComparer<T> are common examples — a method that handles any object can also handle a string, because string is more specific.
// Action<T> is contravariant — defined as Action<in T>
Action<object> printObject = obj => Console.WriteLine(obj);
Action<string> printString = printObject; // contravariance allows this
printString("hello"); // printObject handles it fine — string is-a object
// Define your own contravariant interface — in T means T only appears as a parameter
public interface IConsumer<in T>
{
void Consume(T value); // T is consumed, never returned — safe for contravariance
}
public class ObjectConsumer : IConsumer<object>
{
public void Consume(object value) => Console.WriteLine($"Got: {value}");
}
// Contravariance: ObjectConsumer satisfies IConsumer<string>
// because it can handle any object, including strings
IConsumer<string> stringConsumer = new ObjectConsumer();
stringConsumer.Consume("hello");
Generic Repository Pattern
The repository pattern is a classic application of generics in enterprise code. A single generic interface and base implementation eliminate the boilerplate of writing the same CRUD operations for every entity type, while constraints ensure type safety.
// Generic repository interface — works with any entity type and any key type
public interface IRepository<T, TKey> where T : class
{
Task<T?> GetByIdAsync(TKey id);
Task<IEnumerable<T>> GetAllAsync();
Task AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(TKey id);
}
// In-memory implementation — useful for tests and prototyping
public class InMemoryRepository<T, TKey> : IRepository<T, TKey>
where T : class
where TKey : notnull
{
private readonly Dictionary<TKey, T> _store = new();
private readonly Func<T, TKey> _keySelector; // caller tells us how to get the key
public InMemoryRepository(Func<T, TKey> keySelector)
=> _keySelector = keySelector;
public Task AddAsync(T entity)
{
_store[_keySelector(entity)] = entity;
return Task.CompletedTask;
}
public Task<T?> GetByIdAsync(TKey id)
{
_store.TryGetValue(id, out T? entity);
return Task.FromResult(entity);
}
public Task<IEnumerable<T>> GetAllAsync()
=> Task.FromResult<IEnumerable<T>>(_store.Values);
public Task UpdateAsync(T entity)
{
_store[_keySelector(entity)] = entity;
return Task.CompletedTask;
}
public Task DeleteAsync(TKey id)
{
_store.Remove(id);
return Task.CompletedTask;
}
}
// Usage — one line to get a fully typed, working repository for any entity
record Customer(int Id, string Name);
var repo = new InMemoryRepository<Customer, int>(c => c.Id);
await repo.AddAsync(new Customer(1, "Alice"));
var alice = await repo.GetByIdAsync(1);