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

  1. Over-generalizing — a generic method used with exactly one type adds complexity for nothing.
  2. Missing constraints — then casting inside the method, which defeats the purpose.
  3. Generic repository everywhere — IRepository<T> with 15 methods often hides EF Core's power; specific repositories with meaningful methods are usually better.
  4. Using object parameters 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.