One Implementation, Every Type
A reporting library had seven Sum and Average helpers, one per numeric type. Someone fixed a rounding bug in the decimal sum and missed the same bug in three other copies, and reports started disagreeing by a few cents. That is the usual cost of duplicating code per type.
Generics let you write the logic once and keep full type safety, and for value types the JIT produces specialised code, so there is no speed penalty. Since .NET 7, generic math covers numeric code too. Below is how constraints, variance and generic math work, where they pay off, and the edge cases, such as silent integer overflow, that come with them.
- 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
Constraints, Variance and Generic Math
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.
Generic Code in Practice
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.
How Generic Code Goes Wrong
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.
When I Reach for Generics
- 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 and later, 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.

