Covariance in C#: Arrays vs Generic Collections

In C#, covariance enables you to use a more derived type than originally specified, but its implementation differs significantly between arrays and generic collections. While both allow implicit reference conversions from a collection of derived types to a collection of base types, array covariance is implicitly unsafe and enforced at runtime, whereas generic collection covariance is type-safe, read-only, and enforced at compile time. Understanding these differences is essential for writing robust, error-free C# code.

Understanding Covariance

Covariance preserves assignment compatibility. If type Derived inherits from type Base, covariance allows a collection of Derived objects to be treated as a collection of Base objects. In code, this means you can assign a reference of type Collection<Derived> to a variable of type Collection<Base>.

Array Covariance: Implicit and Runtime-Checked

Array covariance was introduced in C# 1.0, primarily to mirror Java's design and allow generic-like utility methods before generic types existed. In C#, any array of reference types Derived[] can be implicitly converted to Base[].

string[] strings = new string[] { "apple", "banana" };
object[] objects = strings; // Valid at compile time due to array covariance

The major drawback of array covariance is that it breaks compile-time type safety. Because the underlying array remains a string[], attempting to write an incompatible type into objects will cause the runtime to throw an ArrayTypeMismatchException:

objects[0] = 42; // Throws ArrayTypeMismatchException at runtime

Every write operation on a reference-type array incurs a hidden runtime type check to ensure the value matches the actual array element type, which introduces both performance overhead and potential runtime crashes.

Generic Collection Covariance: Safe and Compile-Time Enforced

Generics were introduced in C# 2.0, but generic variance was officially added in C# 4.0. Unlike arrays, generic classes like List<T> are invariant. You cannot assign a List<string> to a List<object> because doing so would allow adding invalid types to the list, which C# prevents at compile time.

Instead, generic covariance is restricted to interfaces and delegates using the out parameter keyword. A prominent example is IEnumerable<out T>:

IEnumerable<string> stringEnumerable = new List<string> { "apple", "banana" };
IEnumerable<object> objectEnumerable = stringEnumerable; // Perfectly safe

The out keyword ensures that T is only used in output positions, such as method return types or property getters. Because covariant interfaces do not allow methods that accept T as an input parameter (like an Add method), it is impossible to introduce an invalid type into the collection. This restriction eliminates the possibility of runtime type mismatch exceptions.

Key Differences Summary

  • Type Safety: Generic covariance is fully checked and guaranteed at compile time. Array covariance is checked at runtime, making it prone to ArrayTypeMismatchException.
  • Mutability: Generic covariance only applies to read-only interfaces (like IEnumerable<T> and IReadOnlyList<T>) and delegates (like Func<T>). Arrays are inherently mutable, which causes safety conflicts when combined with covariance.
  • Performance: Generic covariance introduces no runtime overhead. Arrays incur a runtime type check on every write operation to reference arrays to prevent type corruption.
  • Syntax: Generic covariance is explicitly declared by interface designers using the out modifier. Array covariance is implicit and built into the language for all reference-type arrays.

When designing modern C# applications, favor covariant generic interfaces such as IEnumerable<T> or IReadOnlyCollection<T> over arrays when accepting broader type hierarchies to ensure compile-time safety and prevent runtime errors.