Problem Context
You wrote a Cache<TKey, TValue>. Then a teammate added a SortedCache<T> that requires T to be comparable. Then someone asked for a Sum<T>() that works for int, decimal, and your custom Money type. Pre-.NET 7 you had three bad options: copy-paste per type, lose type safety with object, or hand-roll a delegate-based abstraction.
Modern C# generics solve all three. With constraints, variance, and the .NET 7+ generic math interfaces, you can write one method that the JIT compiles into specialized, allocation-free code per value type โ and the compiler enforces the contract at the call site.
- You have a repository or service duplicated three times because
Customer,Order, andInvoiceneed the same logic - You hit "cannot convert from
IEnumerable<Cat>toIEnumerable<Animal>" and don't know why - You wrote
T : struct, IComparable<T>and the compiler still complains about+operators - You see
List<int>.Addin your hot path and wonder if it's boxing
This guide covers what every .NET 9 backend engineer needs to know โ constraints, variance, and the new generic math interfaces.
Concept Explanation
A generic in C# is a template the compiler instantiates once per type argument. For value types, the JIT generates specialized native code with no boxing. For reference types, it shares one implementation. The constraint clause (where T : ...) is how you tell the compiler what operations T supports โ without it, you can only assume T is an object.
flowchart LR
A["Repository<T>"] --> B["where T : class"]
A --> C["where T : struct"]
A --> D["where T : IEntity"]
A --> E["where T : new()"]
A --> F["where T : INumber<T>"]
A --> G["where T : allows ref struct (.NET 9)"]
B --> H["JIT: shared reference impl"]
C --> I["JIT: per-type specialized impl"]
F --> J["Generic math: + - * / on any number"]
style A fill:#4f46e5,color:#fff,stroke:#4338ca
style F fill:#059669,color:#fff,stroke:#047857
style G fill:#0891b2,color:#fff,stroke:#0e7490
The big shifts since .NET 6: generic math (.NET 7) lets you constrain on INumber<T> and call +,-, Min, Max generically. Static virtual interface members (.NET 7) make this possible.The allows ref struct anti-constraint (.NET 9) finally lets you use Span<T> as a generic argument.
Implementation
Step 1: Constraint cheat sheet
// Reference type (nullable allowed)
public class Cache<T> where T : class { /* ... */ }
// Non-nullable reference type
public class Cache<T> where T : class, notnull { /* ... */ }
// Value type โ boxing-free
public struct Box<T> where T : struct { /* ... */ }
// Unmanaged (primitive or struct of primitives, no references)
// Required for stackalloc, Span<T>, P/Invoke
public static int SizeOf<T>() where T : unmanaged
=> System.Runtime.CompilerServices.Unsafe.SizeOf<T>();
// Interface contract
public class Repository<T> where T : IEntity { /* ... */ }
// Constructor โ required for "new T()"
public T Create<T>() where T : new() => new T();
// Combined โ most common backend pattern
public class EfRepository<T> where T : class, IEntity, new() { /* ... */ }
// .NET 9: anti-constraint allowing ref structs (Span<T>, ReadOnlySpan<T>)
public static int Count<T>(T items) where T : allows ref struct, IEnumerable<int>
{
int n = 0;
foreach (var _ in items) n++;
return n;
}The unmanaged constraint is the gate to zero-allocation, low-level code (stackalloc, P/Invoke, Span<T>marshalling). The .NET 9 allows ref structanti-constraint solves a 5-year-old gap: previously, you couldn't pass Span<T>to a generic method because it wasn't a valid type argument.
Step 2: Generic math (.NET 7+)
using System.Numerics;
// Works for int, long, decimal, double, BigInteger, Half โ anything implementing INumber<T>
public static T Sum<T>(IEnumerable<T> source) where T : INumber<T>
{
T total = T.Zero;
foreach (var item in source) total += item;
return total;
}
// Constrain to numbers that support arbitrary precision
public static T Average<T>(IEnumerable<T> source)
where T : INumber<T>, IDivisionOperators<T, int, T>
{
T total = T.Zero;
int count = 0;
foreach (var item in source) { total += item; count++; }
return total / count;
}
// Usage โ same method, no overloads
int intSum = Sum(new[] { 1, 2, 3 }); // 6
decimal decSum = Sum(new[] { 1.5m, 2.5m, 3.0m }); // 7.0
Money moneySum = Sum(new[] { Money.USD(10), Money.USD(20) }); // your custom typeBefore .NET 7 this required ~15 overloads or a delegate-based Sum(items, (a, b) => a + b). Now one method covers every numeric type โ including your domain types if they implement INumber<Money> (or just the operators you need, like IAdditionOperators<Money, Money, Money>).
Step 3: Variance โ covariance and contravariance
// Covariant (out): producer of T โ safe to widen to a base type
public interface IReadOnlyRepository<out T> { T Get(int id); }
IReadOnlyRepository<Cat> cats = new CatRepository();
IReadOnlyRepository<Animal> animals = cats; // OK โ Cat IS-AN Animal
// Contravariant (in): consumer of T โ safe to narrow to a derived type
public interface IComparer<in T> { int Compare(T x, T y); }
IComparer<Animal> animalCmp = new AnimalAgeComparer();
IComparer<Cat> catCmp = animalCmp; // OK โ anything that compares Animals can compare Cats
// Invariant (default) โ neither
List<Cat> catList = new();
// List<Animal> animalList = catList; // ERROR โ would let you Add(new Dog()) into a Cat listRule of thumb: if T only appears in return positions, mark it out. If it only appears in input positions, mark it in. If both, leave it invariant. Variance is only legal on interfaces and delegates โ not on classes.
Step 4: A real-world generic repository
public interface IEntity { int Id { get; } }
public class EfRepository<T>(AppDbContext db) : IRepository<T>
where T : class, IEntity
{
private readonly DbSet<T> _set = db.Set<T>();
public ValueTask<T?> FindAsync(int id, CancellationToken ct = default)
=> _set.FindAsync([id], ct);
public IAsyncEnumerable<T> StreamAsync(
Expression<Func<T, bool>> predicate, CancellationToken ct = default)
=> _set.AsNoTracking().Where(predicate).AsAsyncEnumerable();
public async Task AddAsync(T entity, CancellationToken ct = default)
{
await _set.AddAsync(entity, ct);
await db.SaveChangesAsync(ct);
}
}
// Registration โ open generic
builder.Services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));Note the open generic registration: typeof(IRepository<>) with no type argument. The DI container will close the generic per request: IRepository<Customer> resolves to EfRepository<Customer>. One registration line, works for every entity.
Pitfalls
1. Default value confusion. default(T) is null for reference types and zero-initialized for value types โ not a sentinel you can compare with ==. Use EqualityComparer<T>.Default.Equals(value, default) when checking.
2. where T : Enum is a recent addition. Available since C# 7.3. Pre-7.3 codebases work around it with reflection โ delete those workarounds.
3. Boxing creeps in via interface constraints. where T : IComparable<T> avoids boxing because the JIT can devirtualize. where T : IComparable (non-generic) does box for value types. Always prefer the generic interface.
4. Variance is interface-only. You cannot make List<Cat> assignable to List<Animal> โ this is by design (it would break Add). Use IReadOnlyList<out T> when you only need to read.
5. Generic methods can't be overloaded by constraint alone. Foo<T>() where T : class and Foo<T>() where T : struct are the same method to the compiler. Use different names or a marker parameter.
Practical Takeaways
- Reach for
where T : class, IEntity, new()as your default for repository-style generics โ it covers 90% of backend cases. - Use
INumber<T>(.NET 7+) instead of overloading numeric methods. YourSum,Average,Clamphelpers collapse to one method. - Mark interfaces
out Twhen consumers only read.IReadOnlyList<out T>andIEnumerable<out T>are whyIEnumerable<Cat>flows intoIEnumerable<Animal>. - Register open generics in DI:
AddScoped(typeof(IRepository<>), typeof(EfRepository<>)). - On .NET 9, use
allows ref structwhen you want generic helpers that acceptSpan<T>โ finally possible without duplication. - If you're still writing
where T : struct, IComparableand casting, you've missed five years of language evolution. Re-read the C# what's-new pages.

