Skip to content

Generics & Variance

Key Points

  • Generics in .NET are reifiedList<int> and List<string> are distinct runtime types with specialized native code (for value-type T) or shared code (for reference-type T).
  • Constraints (where T : ...) constrain T. Common: class, struct, notnull, unmanaged, new(), IInterface, MyBase, another type parameter.
  • Covariance (out T) allows assigning IEnumerable<Dog> to IEnumerable<Animal> — read-only positions only.
  • Contravariance (in T) flows the other way: IComparer<Animal> is assignable to IComparer<Dog> — input-only positions.
  • INumber<T> and friends use static abstract members in interfaces (C# 11) for generic numeric algorithms.
  • notnull constraint forbids passing nullable type arguments — pairs with NRT.
  • allows ref struct (C# 13) lets generics accept ref struct type 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 a Cat into the array throws ArrayTypeMismatchException at 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 typesList<> (no type arguments).
  • Closed generic typesList<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 for long, etc.). Different MethodTables 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

Animal[] animals = new Dog[5];
animals[0] = new Cat();   // ❌ ArrayTypeMismatchException at runtime

✅ Correct: use generic collections

IList<Animal> animals = new List<Animal>();   // invariant — type-safe

❌ 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

public interface IList<out T> { ... }   // ❌ T is in input position too (Add(T))

✅ 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

public T Get<T>(string key) => _map[key];   // ❌ T may be null but flow analysis can't tell

✅ Correct: annotate

public T? GetOrDefault<T>(string key)
    => _map.TryGetValue(key, out var v) ? v : default;

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 T must 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:
public class Mapper
{
    public TDest Map<TSrc, TDest>(TSrc src) where TDest : new() { ... }
}

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 T when the type parameter only appears in return positions.
  • Use in T when the type parameter only appears in input positions.
  • Use notnull for 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 T on 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; use T? and [NotNullWhen] carefully.
  • ⚠️ MakeGenericType in AOT — fails at runtime without static discovery.
  • ⚠️ Excessive struct generic instantiations in AOT — code-size explosion.

Further reading