Generics & Reification
Why .NET generics are fundamentally different from Java β the CLR actually generates separate native code per value type.
.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.
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.
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))).
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.
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.
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
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.
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.
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.
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.
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.
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.
1// Generic type definition β T is a placeholder until JIT time2public class Repository<TEntity> where TEntity : class, IEntity, new()3{4 private readonly AppDbContext _dbContext;5 private readonly DbSet<TEntity> _dbSet;67 public Repository(AppDbContext dbContext)8 {9 _dbContext = dbContext;10 _dbSet = dbContext.Set<TEntity>(); // runtime constructed type11 }1213 // Generic method with constraint14 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 }2122 // β οΈ this bites everyone eventually23 // Comparing T with == when T is unconstrained β compiles, always false for ref types24 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}2930// Covariance: IEnumerable<out T> β T can only come OUT31IEnumerable<string> strings = new List<string>();32IEnumerable<object> objects = strings; // β covariance β string IS-A object3334// Contravariance: IComparer<in T> β T can only go IN35IComparer<object> objectComparer = Comparer<object>.Default;36IComparer<string> stringComparer = objectComparer; // β contravariance3738// DON'T do this β IList<T> is invariant39IList<string> strList = new List<string>();40// IList<object> objList = strList; // β CS0266 β IList<T> is invariant4142// Reification at work: these are three distinct CLR types43var intStack = new Stack<int>(); // specialized: stores int directly44var dblStack = new Stack<double>(); // specialized: separate native code45var strStack = new Stack<string>(); // shared ref-type implementation4647// typeof(Stack<int>) != typeof(Stack<string>) β unlike Java where both are Stack48Console.WriteLine(typeof(Stack<int>).IsGenericType); // True49Console.WriteLine(typeof(Stack<int>).GetGenericTypeDefinition() // Stack`150 == typeof(Stack<string>).GetGenericTypeDefinition()); // True β same *definition*51Console.WriteLine(typeof(Stack<int>) == typeof(Stack<string>)); // False β different *constructed types*
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
1The 'Comparing T with ==' Silent Bug
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.
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.
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
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.
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'.
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.