AI WisdomArchitecture & guides β†—
HT
How Things Work

Generics & Reification

Why .NET generics are fundamentally different from Java β€” the CLR actually generates separate native code per value type.

How It Works

.NET generics are reified β€” the CLR creates distinct, real types for each instantiation. Stack<int> and Stack<string> are genuinely different types at runtime, unlike Java where type erasure removes all generic information. For value types, the JIT generates specialized native code, eliminating boxing entirely. For reference types, a single shared implementation handles all instantiations. This design gives .NET the best of both worlds: type safety without runtime overhead.

1
Generic type definitions vs constructed types

Stack<T> is a generic type definition β€” it's incomplete until T is specified. Stack<int> and Stack<string> are constructed types β€” closed generics that the CLR can actually instantiate. The type definition exists once in the assembly; constructed types are created at JIT time.

2
Reification: the CLR materializes real types

Unlike Java's type erasure, .NET reification means Stack<int> is a genuine, distinct type at runtime. typeof(Stack<int>) != typeof(Stack<string>). You can get generic type arguments via reflection, switch on generic types, and create instances via Activator.CreateInstance(typeof(Stack<>).MakeGenericType(typeof(int))).

3
JIT specialization for value types

For each unique value type T, the JIT generates separate native code for every generic method. Stack<int>.Push generates different x86-64 instructions than Stack<double>.Push. This is why generics eliminate boxing for value types β€” the native code directly handles the value-type layout.

4
Shared implementation for reference types

All reference types share one native implementation because all references are pointer-sized (8 bytes on x64). Stack<string>, Stack<MyClass>, and Stack<object> all use the same compiled method body β€” only one JIT compilation required regardless of how many reference type instantiations exist.

5
Generic constraints enforce compile-time guarantees

where T : class restricts to reference types (enables == null). where T : struct restricts to value types (enables Nullable<T>). where T : new() guarantees a parameterless constructor. where T : IComparable<T> allows calling CompareTo. Without constraints, you can only use operations available on object.

Key Concepts

πŸ”¬Reification

The CLR creates real, distinct types for each generic instantiation. Stack<int> and Stack<string> are different types at runtime β€” you can reflect on them, switch on them, and they appear in stack traces.

πŸ—‘οΈType Erasure (Java)

Java's approach: generic parameters are removed at compile time and replaced with Object. At runtime, ArrayList<String> and ArrayList<Integer> are the same class. No specialization, always boxing for primitives.

⚑JIT Specialization

For each value-type T, the JIT compiles a distinct native method body. This is done lazily β€” Stack<int> is JIT-compiled the first time it's used, then cached. Reference types share one compiled body.

↗️Covariance (out T)

IEnumerable<out T>: T can only appear in output positions (return values). Allows IEnumerable<string> to be assigned to IEnumerable<object>. Declared with 'out' on the type parameter.

↙️Contravariance (in T)

IComparer<in T>: T can only appear in input positions (parameters). Allows IComparer<object> to be assigned to IComparer<string>. Declared with 'in' on the type parameter.

πŸ”’Generic Constraints

where T : class | struct | new() | BaseType | IInterface. Constraints expand what operations are valid on T at compile time. Without constraints, only object members (ToString, GetHashCode) are callable on T.

Generics β€” Reification, Constraints & Variance
tsx
1// Generic type definition β€” T is a placeholder until JIT time
2public class Repository<TEntity> where TEntity : class, IEntity, new()
3{
4 private readonly AppDbContext _dbContext;
5 private readonly DbSet<TEntity> _dbSet;
6
7 public Repository(AppDbContext dbContext)
8 {
9 _dbContext = dbContext;
10 _dbSet = dbContext.Set<TEntity>(); // runtime constructed type
11 }
12
13 // Generic method with constraint
14 public async Task<TEntity?> FindByIdAsync<TKey>(
15 TKey id,
16 CancellationToken cancellationToken = default)
17 where TKey : IEquatable<TKey>
18 {
19 return await _dbSet.FindAsync(new object[] { id }, cancellationToken);
20 }
21
22 // ⚠️ this bites everyone eventually
23 // Comparing T with == when T is unconstrained β€” compiles, always false for ref types
24 public bool AreEqual<T>(T a, T b)
25 {
26 return a == b; // CS0019 if T unconstrained β€” use EqualityComparer<T>.Default.Equals(a, b)
27 }
28}
29
30// Covariance: IEnumerable<out T> β€” T can only come OUT
31IEnumerable<string> strings = new List<string>();
32IEnumerable<object> objects = strings; // βœ“ covariance β€” string IS-A object
33
34// Contravariance: IComparer<in T> β€” T can only go IN
35IComparer<object> objectComparer = Comparer<object>.Default;
36IComparer<string> stringComparer = objectComparer; // βœ“ contravariance
37
38// DON'T do this β€” IList<T> is invariant
39IList<string> strList = new List<string>();
40// IList<object> objList = strList; // βœ— CS0266 β€” IList<T> is invariant
41
42// Reification at work: these are three distinct CLR types
43var intStack = new Stack<int>(); // specialized: stores int directly
44var dblStack = new Stack<double>(); // specialized: separate native code
45var strStack = new Stack<string>(); // shared ref-type implementation
46
47// typeof(Stack<int>) != typeof(Stack<string>) β€” unlike Java where both are Stack
48Console.WriteLine(typeof(Stack<int>).IsGenericType); // True
49Console.WriteLine(typeof(Stack<int>).GetGenericTypeDefinition() // Stack`1
50 == typeof(Stack<string>).GetGenericTypeDefinition()); // True β€” same *definition*
51Console.WriteLine(typeof(Stack<int>) == typeof(Stack<string>)); // False β€” different *constructed types*
πŸ’‘
Why This Matters

Generic collections (List<T>, Dictionary<K,V>, Stack<T>) are the backbone of .NET application code. Understanding reification explains why List<int> is dramatically faster than ArrayList for value types, why typeof(List<int>) works correctly in reflection, and why covariance/contravariance rules exist. It also explains NativeAOT limitations β€” the ahead-of-time compiler can't always generate all possible generic instantiations.

Common Pitfalls

⚠Comparing T with == on unconstrained type parameters: compiles fine, silently performs reference equality for all reference types. Use EqualityComparer<T>.Default.Equals(a, b) or constrain with where T : IEquatable<T>.
⚠IList<T> is invariant β€” you cannot assign IList<string> to IList<object>. Only interfaces with pure output (IEnumerable<out T>) or pure input (IComparer<in T>) can be variant. Forgetting this leads to CS0266 confusion.
⚠MakeGenericType() in hot paths: constructing generic types via reflection at runtime is expensive and defeats NativeAOT. Cache the constructed types in a ConcurrentDictionary<Type, Type> if you must do this.
⚠Generic method type inference failure: when calling Foo<T>(IEnumerable<T> items), if items is null, the compiler can't infer T and you get CS0411. Pass the type parameter explicitly: Foo<string>(null).
⚠NativeAOT and unbound generics: NativeAOT requires all generic instantiations to be known at publish time. Using MakeGenericType() or Type.GetType() + reflection to instantiate generics at runtime will fail or require rd.xml annotations.
Real-World Use Cases

1The 'Comparing T with ==' Silent Bug

Scenario

Our payment processing service had a generic validator: bool IsValid<T>(T a, T b) => a == b. It worked fine in unit tests with int and string. In production, it was called with PaymentId (a class wrapping a Guid) β€” and always returned false, silently accepting duplicate payments.

Problem

When T is an unconstrained type parameter, == compiles to reference equality (object identity), not value equality. Two separate PaymentId instances wrapping the same Guid are different objects, so == returns false. The compiler emits no warning. This bug is invisible until you run it with a reference type.

Solution

Changed to EqualityComparer<T>.Default.Equals(a, b) β€” the BCL's canonical way to compare generics. This uses IEquatable<T> if available, falling back to object.Equals. Added a code review rule: never use == on unconstrained type parameters.

πŸ’‘

Takeaway: With unconstrained T, == is reference equality β€” it silently compiles and silently fails for reference types. Always use EqualityComparer<T>.Default.Equals() or constrain with where T : IEquatable<T>.

2Boxing Pressure in a Hot Metrics Path

Scenario

Our telemetry service was processing 50k events/second and the GC was running Gen0 collections every 200ms. The CPU profiler showed 40% of allocations coming from our MetricsBucket class, which stored values in Dictionary<string, object> β€” the pre-generics pattern.

Problem

Every int, double, and TimeSpan metric value was being boxed on every write (dictionary[key] = value where value is object). At 50k events/second, that's potentially millions of small heap allocations per second driving GC pressure. The original developer used object to 'keep it flexible'.

Solution

Replaced Dictionary<string, object> with a generic MetricsBucket<TValue> where TValue : struct. For mixed types, used a discriminated union struct (MetricValue) rather than boxing. Gen0 collections dropped from every 200ms to every 2-3 seconds. P99 latency improved from 45ms to 8ms.

πŸ’‘

Takeaway: Generics exist precisely to eliminate boxing. Dictionary<string, object> is fine for configuration, terrible for hot paths. Use proper generic types for performance-sensitive data structures β€” the JIT will generate specialized code that avoids heap allocation entirely.