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.

C#
// 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 C++ templates or Java's erased generics, the .NET runtime generates specialized code for each type argument. 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.

C#
// 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
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.

C#
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

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.

C#
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.

C#
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.

C#
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<K,V>

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.

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

Variance only applies to interfaces and delegates

Classes and structs cannot be variant. Only interface and delegate type parameters support in/out because the runtime needs to guarantee that the memory layout is compatible, which it can only do for reference-based dispatch.

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 bridge the gap. Constraints like where T : struct and where T : allows ref struct give the compiler enough information to generate efficient, box-free code while still being generic.