Generics & Variance
Key Points
- Generics in .NET are reified —
List<int>andList<string>are distinct runtime types with specialized native code (for value-type T) or shared code (for reference-type T). - Constraints (
where T : ...) constrainT. Common:class,struct,notnull,unmanaged,new(),IInterface,MyBase, another type parameter. - Covariance (
out T) allows assigningIEnumerable<Dog>toIEnumerable<Animal>— read-only positions only. - Contravariance (
in T) flows the other way:IComparer<Animal>is assignable toIComparer<Dog>— input-only positions. INumber<T>and friends use static abstract members in interfaces (C# 11) for generic numeric algorithms.notnullconstraint forbids passing nullable type arguments — pairs with NRT.allows ref struct(C# 13) lets generics acceptref structtype parameters.
Concepts (deep dive)
Reified generics
Unlike Java's erased generics, .NET preserves type info at runtime:
typeof(List<int>) == typeof(List<string>); // false; distinct types
new List<int>().GetType().GetGenericArguments()[0]; // typeof(int)
Value-type generics specialize: List<int> has its own native code; List<long> has its own. Allows zero-boxing iteration, predictable layout, true performance from generics.
Reference-type generics share code: List<string>, List<Customer>, List<object> share a single native code body — they all manipulate references.
💡 Senior insight: in NativeAOT, every value-type generic instantiation must exist at compile time.
Type.MakeGenericType(typeof(int))for unknown values fails. Practical implication: avoid runtime-discovered generic instantiations.
Constraints
public T Sum<T>(IEnumerable<T> items) where T : INumber<T> // generic math
{
T sum = T.Zero;
foreach (var item in items) sum += item;
return sum;
}
public T New<T>() where T : new() => new T();
public T Coerce<T>(object o) where T : class => (T)o;
public Span<byte> Bytes<T>(T v) where T : unmanaged
=> MemoryMarshal.AsBytes(new Span<T>(ref v));
| Constraint | Meaning |
|---|---|
class | Reference type (non-nullable). class? allows nullable. |
struct | Non-nullable value type. |
notnull | Either; just not nullable. NRT-aware. |
unmanaged | Value type without any reference fields (transitively). |
new() | Has a parameterless constructor. |
IInterface | Implements interface. |
BaseType | Inherits or is the type. |
T : U | First type param derives from second. |
allows ref struct (C# 13) | Permits ref structs as type arguments. |
Variance
Variance applies to interfaces and delegates, not classes.
public interface IEnumerable<out T> { ... } // covariant
public interface IComparer<in T> { ... } // contravariant
public interface IList<T> { ... } // invariant (T is read AND written)
Covariance (out): "produces T". You can assign IEnumerable<Dog> to IEnumerable<Animal>. Read-only positions.
IEnumerable<Dog> dogs = ...;
IEnumerable<Animal> animals = dogs; // ✅ covariance
foreach (Animal a in animals) { /* ... */ }
Contravariance (in): "consumes T". You can assign IComparer<Animal> to IComparer<Dog> — a comparer that compares any animal handles dogs fine.
IComparer<Animal> animalComparer = ...;
IComparer<Dog> dogComparer = animalComparer; // ✅ contravariance
Invariance: IList<Animal> is not assignable from IList<Dog> because IList<T> has both Get (covariant position) and Add(T) (contravariant position). Mixing them requires invariance.
💡 Senior insight: array covariance in .NET (
Animal[] a = new Dog[5]) is legacy and unsafe — assigning aCatinto the array throwsArrayTypeMismatchExceptionat runtime. Don't rely on it. Generic collection variance is type-safe.
Static abstract interface members (C# 11)
public interface INumber<TSelf> : IAdditionOperators<TSelf, TSelf, TSelf>, ...
where TSelf : INumber<TSelf>
{
static abstract TSelf Zero { get; }
static abstract TSelf One { get; }
}
public static T Sum<T>(IEnumerable<T> items) where T : INumber<T>
{
T sum = T.Zero;
foreach (var i in items) sum += i;
return sum;
}
This unlocks "generic math" — algorithms that work over any numeric type. Implementations: int, double, BigInteger, custom numeric types.
The pattern: CRTP (Curiously Recurring Template Pattern) — where TSelf : INumber<TSelf> lets the interface refer to the implementing type.
notnull and NRT interplay
public class Cache<TKey, TValue> where TKey : notnull
{
private readonly Dictionary<TKey, TValue> _map = new();
}
var c1 = new Cache<string, int>(); // ✅
var c2 = new Cache<string?, int>(); // ❌ can't be nullable
notnull forbids both nullable reference types (string?) and Nullable<T> (int?).
Default values and default(T)
public T? GetOrDefault<T>(string key) where T : class?
=> _map.TryGetValue(key, out var v) ? v : default; // null
For unconstrained T, default(T) is null for ref types, zeroed for value types. The T? annotation expresses that the result might be the default — flow analysis tracks it.
Generic methods vs generic types
public class Cache<TValue>
{
public TValue Get<TKey>(TKey key) { ... } // method-level generic on top of type-level
}
Generic methods can introduce their own type parameters, layered on the containing type's. Useful for adapters/mappers/visitors.
Variance on delegates
public delegate TResult Func<in T, out TResult>(T arg);
Func<Animal, string> describeAnimal = a => a.Name;
Func<Dog, string> describeDog = describeAnimal; // ✅ contravariant T, covariant TResult
Same rules as interfaces: input positions are contravariant; output positions are covariant.
Generic constraints and inheritance
public class Repo<T> where T : Entity, new()
{
public T Create() => new T();
}
public class CustomerRepo : Repo<Customer> { } // Customer must satisfy Repo<T>'s constraints
Subclasses inherit constraints implicitly through the closed type but the original generic must declare them.
Generic specialization in NativeAOT
The ILC walks the call graph and pre-instantiates every generic combination it sees. Issues:
public static T New<T>() where T : new() => new T();
// At runtime:
var unknown = Type.GetType("MyApp.Foo")!;
var listType = typeof(List<>).MakeGenericType(unknown); // ❌ AOT: RequiresDynamicCode
Workaround in AOT: use closed-set factories or accept that you can't do dynamic generic instantiation.
How it works under the hood
Generics in the CLR are implemented at the type-system level. The runtime maintains:
- Open generic types —
List<>(no type arguments). - Closed generic types —
List<int>,List<string>(filled in). - Generic methods — same shape on a method.
When the JIT compiles a generic method:
- Value-type instantiation: specialized native code per value type (one for
int, one forlong, etc.). DifferentMethodTables for distinct closed types. - Reference-type instantiation: shared native code via "code sharing" — all reference types use the same instantiation, with a hidden
MethodTable*argument supplied at call site.
This is why List<int> is highly optimized but List<string>, List<object>, List<Customer> all share one body.
Variance is a type-system concept; at runtime, an IEnumerable<Dog> is-a IEnumerable<Animal> because the runtime type system records the covariance flag.
Static abstract interface methods compile to JIT-resolved virtual dispatch keyed on the concrete TSelf. A small overhead per call, but PGO-friendly.
Code: correct vs wrong
❌ Wrong: assuming class array covariance is safe
✅ Correct: use generic collections
❌ Wrong: forgetting variance on a generic interface
public interface IRepo<T> { T Get(int id); }
IRepo<Dog> dogs = ...;
IRepo<Animal> animals = dogs; // ❌ compile error — T is invariant by default
✅ Correct: out T on read-only
public interface IRepo<out T> { T Get(int id); }
IRepo<Dog> dogs = ...;
IRepo<Animal> animals = dogs; // ✅
❌ Wrong: trying to make IList<T> covariant
✅ Correct: split read and write interfaces
public interface IReadOnlyList<out T> { ... }
public interface IList<T> : IReadOnlyList<T> { ... void Add(T item); }
❌ Wrong: NRT lost on unconstrained T
✅ Correct: annotate
Design patterns for this topic
Pattern 1 — "Read interface (out T) + write interface separate"
- Intent: allow covariance for read-side; keep write-side invariant.
- Code sketch: see "correct vs wrong" #3 above.
- Used by:
IEnumerable<T>/ICollection<T>;IReadOnlyList<T>/IList<T>.
Pattern 2 — "CRTP for INumber<T>-style interfaces"
- Intent: statically dispatched generic algorithms.
- Code sketch:
public interface IAddable<TSelf> where TSelf : IAddable<TSelf>
{
static abstract TSelf operator +(TSelf a, TSelf b);
}
public T Sum<T>(T[] arr) where T : IAddable<T>
{
T sum = arr[0];
for (int i = 1; i < arr.Length; i++) sum = sum + arr[i];
return sum;
}
Pattern 3 — "where T : new() factory pattern"
- Intent: generic factory.
- Trade-off: in AOT, every call site's
Tmust be known.
Pattern 4 — "Constrained generic for non-null keys"
- Intent: make a dictionary-like API safe for null-key concerns.
- Code sketch:
where TKey : notnull.
Pattern 5 — "Generic method on non-generic class"
- Intent: the class isn't generic, but operations are.
- Code sketch:
Pros & cons / trade-offs
| Feature | Pros | Cons |
|---|---|---|
| Reified generics | True type info at runtime; perf | More native code per value-type instantiation |
out T covariance | Flexible upcasting | Read-only — limits API |
in T contravariance | Comparer/predicate flexibility | Easy to confuse with out |
| Static abstract members | Generic math; trait-like APIs | Newer concept; some tooling lags |
notnull | NRT-safe | Must propagate through API |
allows ref struct | Generic over Span | C# 13+ only |
When to use / when to avoid
- Use
out Twhen the type parameter only appears in return positions. - Use
in Twhen the type parameter only appears in input positions. - Use
notnullfor keys, lookups, and any T where null breaks invariants. - Use
INumber<T>for generic numeric algorithms (.NET 7+). - Avoid
new()constraint with non-default-constructor types — useful only if you really need parameterless construction. - Avoid array covariance in new code; use generic collections.
Interview Q&A
Q1. Are .NET generics erased like Java's? No. .NET generics are reified — type info is preserved at runtime. List<int> and List<string> are distinct runtime types.
Q2. Difference between out T and in T in interface declarations? out T is covariance — T only in return positions. in T is contravariance — T only in input positions. Not both at once on the same parameter.
Q3. Why is array covariance in .NET unsafe? Animal[] arr = new Dog[5]; arr[0] = new Cat(); compiles but throws at runtime. Generic collections are invariant by default — type-safe at compile time.
Q4. What's INumber<T> and what enables it? A generic interface (.NET 7+) describing numeric types. Enabled by static abstract interface members (C# 11). Lets you write T Sum<T>(IEnumerable<T> items) where T : INumber<T>.
Q5. What does the notnull constraint forbid? Both nullable reference types (string?) and Nullable<T> (int?). Used to ensure the type parameter is reliably non-null.
Q6. Why doesn't IList<T> support covariance? T appears in both input (Add(T)) and output (this[int]) positions. Variance requires position consistency — only-in or only-out.
Q7. What's the runtime cost difference between List<int> and List<Customer>? List<int> has its own specialized native code with inlined int operations. List<Customer> shares code with List<object>, List<string>, etc. — passes a hidden type-table pointer. Both are fast; List<int> is slightly faster due to specialization.
Q8. What's unmanaged constraint? where T : unmanaged — the type is a value type and contains no reference fields (transitively). Lets you use sizeof(T), Span<T> over native memory, etc.
Q9. What's allows ref struct (C# 13)? A new generic constraint: lets type parameter accept ref struct types. Unlocks generic algorithms over Span<T>.
Q10. How does CRTP help in C#? interface IFoo<TSelf> where TSelf : IFoo<TSelf> lets the interface reference the implementing type. Combined with static abstract members, enables generic dispatch like T.Zero and T + T.
Q11. Why does NativeAOT have trouble with Type.MakeGenericType? Because all generic instantiations must be discoverable at compile time. Runtime-formed generics (MakeGenericType(unknownType)) require a JIT to compile fresh code — which AOT lacks.
Q12. Can a method have generic constraints that the type doesn't? Yes — generic methods on a non-generic class, or extra method-level constraints on a generic class. The method-level constraints layer on top.
Q13. Why might you where T : class, new()? Reference type with parameterless constructor — common for factory/repository patterns where T represents an entity or DTO. The class constraint also implies T cannot be null (with NRT, T is non-nullable).
Gotchas / common mistakes
- ⚠️ Array covariance assumed safe — runtime exceptions.
- ⚠️ Forgetting
out Ton a read-only generic interface — consumers can't upcast. - ⚠️
new()constraint with types that need DI — forces parameterless construction; awkward. - ⚠️ NRT on unconstrained
T— flow analysis is conservative; useT?and[NotNullWhen]carefully. - ⚠️
MakeGenericTypein AOT — fails at runtime without static discovery. - ⚠️ Excessive struct generic instantiations in AOT — code-size explosion.