Skip to content

Generics, variance & boxing

How generics work, how variance affects type substitution, and what boxing costs you.

Generics, variance, and boxing are tightly related. Generics exist in large part to avoid boxing. Variance controls when one generic type can stand in for another. Understanding all three together explains many of the constraints and design decisions in C# and .NET.

Generics ​

Generics C# 2.0 allow a type or method to be parameterized over one or more types using <T> syntax. The compiler and runtime enforce type safety without requiring casts and without boxing value types.

csharp
// Without generics (pre-C# 2.0)
ArrayList list = new ArrayList();
list.Add(42);           // int is boxed to object
int n = (int)list[0];   // unboxed and cast, risky and slow

// With generics
List<int> list = new List<int>();
list.Add(42);           // no boxing, stored as int directly
int n = list[0];        // no cast needed

Unlike Java's erased generics, .NET generics keep their type arguments at runtime. Like C++ templates the code is specialized, but the JIT does it at runtime rather than the compiler doing it at build time. For value types like int, this means the runtime creates a version of List<T> that stores int values directly without boxing. For reference types, a single shared implementation is used since all references are the same size.

Constraints ​

Constraints restrict what types can be used as a type argument, giving you access to more operations inside the generic code.

csharp
// Unconstrained: can only use object members
void Log<T>(T item) => Console.WriteLine(item?.ToString());

// Constrained: compiler knows T has .Name
void Print<T>(T item) where T : INameable => Console.WriteLine(item.Name);
ConstraintMeaning
where T : classMust be a reference type
where T : structMust be a non-nullable value type
where T : new()Must have a parameterless constructor
where T : BaseClassMust inherit from BaseClass
where T : IInterfaceMust implement IInterface
where T : unmanagedMust be an unmanaged type (C# 7.3)
where T : EnumMust be an enum type (C# 7.3)
where T : DelegateMust be a delegate type (C# 7.3)
where T : notnullMust be a non-nullable type (C# 8.0)
where T : allows ref structAllows ref struct types (C# 13.0)

Multiple constraints can be combined: where T : class, IComparable<T>, new().

Boxing and unboxing ​

Every value type in .NET (int, bool, struct, enum, etc.) is stored inline: on the stack for locals, or directly inside the containing object for fields. Reference types (class, string, arrays) are stored on the heap and accessed via a pointer.

Boxing is what happens when a value type is assigned to a variable of type object, an interface, or any other reference type. The runtime allocates a new object on the heap, copies the value into it, and returns a reference. Unboxing is the reverse: extracting the value from the box.

csharp
int n = 42;
object boxed = n;        // boxing: heap allocation + copy
int unboxed = (int)boxed; // unboxing: type check + copy

Boxing is expensive in tight loops because each box is a separate heap allocation that the garbage collector must later clean up. It is also a source of subtle bugs because the boxed copy is independent, so mutating the original does not affect the box.

Common causes of boxing ​

CauseExampleFix
Non-generic collectionsArrayList.Add(42)Use List<int>
Calling object methods on structs without overridesmyStruct.GetHashCode() if not overriddenOverride GetHashCode, Equals, ToString
Interface dispatch on value typesIComparable c = 42; c.CompareTo(0);Use constrained generics: where T : IComparable<T>
String interpolation (pre-C# 10)$"Value: {myInt}"Interpolated string handlers C# 10.0 eliminate this
Nullable<T> to objectobject o = (int?)42;Avoid when possible
Union declarations with value-type casesunion Number(int, double); then Number n = 42;Union declarations C# 15.0 store the case in a single object reference; hand-write a union type with its own storage if this matters

Generics are the primary tool for avoiding boxing. A List<int> stores integers directly; a method constrained with where T : IComparable<T> calls CompareTo without boxing because the runtime uses a constrained call that dispatches directly on the value type.

Covariance and contravariance ​

Variance describes when one generic type can safely substitute for another based on the inheritance relationship of their type arguments.

Covariance (out T) ​

Generic covariance C# 4.0 applies to type parameters that are only output (returned). If Dog inherits from Animal, then IEnumerable<Dog> can be used where IEnumerable<Animal> is expected because every Dog you pull out is an Animal.

csharp
IEnumerable<Dog> dogs = GetDogs();
IEnumerable<Animal> animals = dogs; // OK, covariant

// This works because IEnumerable<out T> only produces T values.
// You can safely read a Dog as an Animal.

The out keyword on the type parameter declares this intent and the compiler enforces that T is never used in an input position.

Contravariance (in T) ​

Generic contravariance C# 4.0 applies to type parameters that are only input (consumed). If Dog inherits from Animal, then Action<Animal> can be used where Action<Dog> is expected because a handler that accepts any Animal can certainly handle a Dog.

csharp
Action<Animal> feedAnimal = a => Console.WriteLine($"Feeding {a.Name}");
Action<Dog> feedDog = feedAnimal; // OK, contravariant

// This works because Action<in T> only consumes T values.
// A method that can feed any Animal can certainly feed a Dog.

The in keyword on the type parameter declares this and the compiler enforces that T is never used in an output position.

Why IList<T> is invariant ​

IList<T> both produces T (indexer get) and consumes T (Add, indexer set). Neither in nor out is safe, so IList<T> is invariant and IList<Dog> cannot be assigned to IList<Animal> or vice versa.

csharp
IList<Dog> dogs = new List<Dog>();
// IList<Animal> animals = dogs;  // Compile error, and for good reason:
// animals.Add(new Cat());        // This would put a Cat in a Dog list!

Quick reference ​

KeywordDirectionAssignable when...Common examples
out T (covariance)T is output onlyChild to ParentIEnumerable<out T>, IReadOnlyList<out T>, Func<out T>
in T (contravariance)T is input onlyParent to ChildAction<in T>, IComparer<in T>, IEqualityComparer<in T>
(neither) invariantT is bothExact match onlyIList<T>, List<T>, Dictionary<TKey,TValue>

Covariant returns ​

Separate from generic variance, covariant returns C# 9.0 allow an overriding method to return a more specific type than the base method. This is about method overrides, not generic type parameters.

csharp
abstract class Factory { public abstract Animal Create(); }
class DogFactory : Factory { public override Dog Create() => new Dog(); } // OK in C# 9+

Covariant returns need runtime support, so they require .NET 5 or later.

Variance only applies to interfaces and delegates ​

Classes and structs cannot be variant. Only interface and delegate type parameters support in/out. A class can have fields of type T that are both read and written, so the CLR only allows variance on types that expose T purely through method signatures.

Variance also only applies to reference types. IEnumerable<int> cannot be assigned to IEnumerable<object> because int is a value type and the conversion would require boxing every element.

How they connect ​

  1. Generics eliminate boxing. The primary motivation for generics was type-safe collections that store value types without boxing.
  2. Variance enables flexible generics. out and in let generic interfaces participate in the same inheritance-based substitution that non-generic types have always had.
  3. Boxing limits variance. Variance only works with reference type arguments because value types would need boxing to convert, and implicit boxing on every access would defeat the performance goal of generics.
  4. Constraints unlock operations, not performance. The JIT already specializes generic code for each value type, so a List<int> avoids boxing with or without a constraint. Interface constraints like where T : IComparable<T> let you call members without casting to the interface (which would box), and where T : allows ref struct widens the allowed type arguments to ref structs, which can never be boxed.

Powered by Intracia