Articles › C#
C# Generics Explained: Generic Classes, Methods, Constraints and Variance
Learn generics in C# thoroughly: type parameters, generic methods and classes, constraints with where, covariance and contravariance, static abstract members and real-world patterns.
Generics let you write code once and use it with many types — without losing type safety or performance. List<T>, Dictionary<TKey, TValue>, Task<T> and LINQ itself are all generic. Understanding generics deeply helps you design reusable libraries, avoid casting and boxing, and read the .NET source with confidence.
The problem generics solve
Before generics, reusable collections stored object:
var list = new ArrayList();
list.Add(42);
list.Add("oops"); // compiles fine
int first = (int)list[0]; // cast needed
int second = (int)list[1]; // 💥 InvalidCastException at runtime
Two problems: no type safety (errors at runtime instead of compile time) and boxing of value types (allocations and slower code). Generics fix both:
var numbers = new List<int>();
numbers.Add(42);
// numbers.Add("oops"); ❌ compile-time error
int first = numbers[0]; // no cast, no boxing
Generic methods
public static T Max<T>(T a, T b) where T : IComparable<T> =>
a.CompareTo(b) >= 0 ? a : b;
Max(3, 7); // 7 — T inferred as int
Max("apple", "pear"); // "pear"
The compiler usually infers type arguments from the parameters, so callers rarely write Max<int>(...).
Generic classes
public class Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public string? Error { get; }
private Result(bool ok, T? value, string? error) =>
(IsSuccess, Value, Error) = (ok, value, error);
public static Result<T> Success(T value) => new(true, value, null);
public static Result<T> Failure(string error) => new(false, default, error);
}
Result<User> result = await userService.RegisterAsync(request);
if (!result.IsSuccess) return BadRequest(result.Error);
A generic Result<T> type is a common way to return success or failure from application services without throwing exceptions for expected cases.
Constraints with where
Constraints tell the compiler what a type parameter can do, unlocking members and preventing misuse.
| Constraint | Meaning |
|---|---|
where T : class |
reference type |
where T : struct |
non-nullable value type |
where T : notnull |
non-nullable type (value or reference) |
where T : new() |
has a public parameterless constructor |
where T : BaseEntity |
derives from a base class |
where T : IComparable<T> |
implements an interface |
where T : unmanaged |
unmanaged value type (for low-level code) |
where TDerived : TBase |
one type parameter derives from another |
Constraints can be combined:
public interface IEntity { int Id { get; } }
public class Repository<TEntity>(AppDbContext db)
where TEntity : class, IEntity
{
public Task<TEntity?> GetAsync(int id, CancellationToken ct = default) =>
db.Set<TEntity>().FirstOrDefaultAsync(e => e.Id == id, ct);
public void Add(TEntity entity) => db.Set<TEntity>().Add(entity);
}
Because of IEntity, the method can use e.Id; because of class, EF Core's Set<TEntity>() accepts it.
Generic interfaces
public interface IHandler<in TRequest, TResponse>
{
Task<TResponse> HandleAsync(TRequest request, CancellationToken ct);
}
public class GetArticleHandler(AppDbContext db) : IHandler<GetArticle, ArticleDto?>
{
public Task<ArticleDto?> HandleAsync(GetArticle request, CancellationToken ct) =>
db.Articles.Where(a => a.Slug == request.Slug)
.Select(a => new ArticleDto(a.Title, a.Content))
.FirstOrDefaultAsync(ct);
}
This is the shape behind libraries like MediatR and many CQRS implementations. Open generic registration wires every handler automatically:
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
Covariance and contravariance
Variance controls whether a generic type can be used in place of the same generic type with a different type argument.
Covariance (out) — "producers"
IEnumerable<string> names = new List<string> { "Ali", "Sara" };
IEnumerable<object> objects = names; // ✅ allowed: IEnumerable<out T>
A sequence of strings is a sequence of objects, because IEnumerable<T> only returns T.
Contravariance (in) — "consumers"
Action<object> printAny = o => Console.WriteLine(o);
Action<string> printString = printAny; // ✅ allowed: Action<in T>
Something that can handle any object can certainly handle a string.
Why List<T> is invariant
List<string> names = new();
// List<object> objects = names; ❌ not allowed
// objects.Add(42); — would put an int into a list of strings!
List<T> both accepts and returns T, so allowing variance would break type safety. Only interfaces and delegates can be variant, and only when T is used in one direction.
Default values and type checks
public static T? FirstOrNone<T>(IEnumerable<T> items) =>
items.FirstOrDefault(); // default(T): null for references, 0 for int…
public static string Describe<T>(T value) => value switch
{
int i => $"int {i}",
string s => $"string '{s}'",
null => "null",
_ => typeof(T).Name
};
Generic math with static abstract members (C# 11)
Interfaces can now declare static members, enabling algorithms that work for every numeric type:
public static T Sum<T>(IEnumerable<T> values) where T : INumber<T>
{
var total = T.Zero;
foreach (var v in values) total += v;
return total;
}
Sum(new[] { 1, 2, 3 }); // int
Sum(new[] { 1.5m, 2.25m }); // decimal
Performance notes
- For value types, the runtime generates specialized code per type — no boxing, full speed.
- For reference types, one shared implementation is used.
- Generic code is as fast as hand-written type-specific code in almost all cases.
Common mistakes
- Over-generalizing — a generic method used with exactly one type adds complexity for nothing.
- Missing constraints — then casting inside the method, which defeats the purpose.
- Generic repository everywhere —
IRepository<T>with 15 methods often hides EF Core's power; specific repositories with meaningful methods are usually better. - Using
objectparameters where a generic would keep type safety.
Summary
Generics give you reusable code with compile-time type safety and no boxing. Use generic methods and classes for reusable algorithms and containers, constrain type parameters to express requirements, understand variance (out for producers, in for consumers), and take advantage of modern features like generic math. Used with restraint, generics make APIs both flexible and safe.
FAQ
What's the difference between T? for classes and structs?
With where T : struct, T? means Nullable<T>. With reference types it's a nullable annotation. Without constraints, T? means "may be default".
Can I constrain to multiple interfaces?
Yes: where T : IComparable<T>, IFormattable, new() — the constructor constraint must come last.
Are generics resolved at compile time or runtime?
Both: the compiler checks types, and the runtime creates concrete instantiations (reified generics), so typeof(T) works at runtime.