Array covariance in .NET: why string[] converts to object[], the run-time store check and ArrayTypeMismatchException, its hidden performance cost, the Span<T> constructor trap, and safe alternatives through generic variance.
Reference-type arrays in .NET are covariant: a string[] can be treated as an object[]. The array still really holds strings, so the runtime checks every write, and storing 42 fails with ArrayTypeMismatchException. The feature exists for historical reasons (Java had it, and .NET 1.0 had no generics) and it makes array writes type-unsafe at compile time. It also adds a hidden type check to stores into arrays of non-sealed reference types, and new Span<object>(stringArray) throws. The safe alternative is generic variance: IEnumerable<out T> and IReadOnlyList<out T> are covariant because they only return values, while List<T> is invariant, so the compiler catches the mistake.
The code compiles because C# supports array covariance for reference types: a string[] can be treated as an object[]. But the actual object is still a string[], so storing an int (boxed into an object) into it would break type safety. The runtime therefore checks every store into a reference-type array and throws ArrayTypeMismatchException. I'd explain why the feature exists, its runtime cost, and how generics give safe variance instead.
object[] items = new string[3]; // allowed: string[] -> object[] (covariant reference conversion)
items[0] = "ok"; // runtime check: is string assignable to string? yes
items[0] = 42; // 42 is boxed to object; runtime check: is Int32 assignable to string? NO
// -> ArrayTypeMismatchException
object[], so assigning any object looks legal.string. The stelem.ref IL instruction checks the stored value's type against that element type on every write.void PrintAll(object[] items) or Array.Sort(object[]) that work with any reference-type array.int[] cannot be converted to object[] (elements would need boxing, changing the memory layout). int[] can be cast to uint[] at the CLR level, a quirk sometimes used in low-level code.It is widely considered a design mistake (Eric Lippert called it "broken"): it trades compile-time safety for convenience and imposes a cost on every reference array write.
object[] and writes to it, unaware it is really a string[]).StelemRef), unless the JIT can prove the array's exact type. It is fast when the element type matches exactly (a pointer comparison), slower when it has to walk the type hierarchy or check interfaces. In hot loops writing to object[], this check is measurable.Span<T> creation from an array (new Span<T>(array)) checks that the array is exactly T[] and throws ArrayTypeMismatchException otherwise, because a writable span would bypass the per-store check.Generic variance in C# 4 is safe because it is restricted by how the type parameter is used:
| Interface | Variance | Why safe |
|---|---|---|
IEnumerable<out T> |
Covariant | You can only read T |
IReadOnlyList<out T>, IReadOnlyCollection<out T> |
Covariant | Read-only |
IComparer<in T>, IEqualityComparer<in T>, Action<in T> |
Contravariant | You can only pass in T |
Func<out TResult> |
Covariant in result | Output only |
List<T>, IList<T>, ICollection<T> |
Invariant | Both read and write |
IEnumerable<object> objs = new List<string>(); // OK: covariance, read-only
IReadOnlyList<object> ro = new[] { "a", "b" }; // OK
List<object> bad = new List<string>(); // compile error - invariant
IList<object> alsoBad = new List<string>(); // compile error
Because writes aren't possible through a covariant interface, the compiler can allow the conversion safely, and no runtime check is needed.
IEnumerable<T>, IReadOnlyList<T> or ReadOnlySpan<T> instead of object[]/T[] when the method only reads.void Process<T>(T[] items)) rather than object[] parameters.object[] as a general container; if needed, create real object[] arrays (new object[n]), not covariant views of more specific arrays.sealed element types let the JIT remove some covariance checks because no subtypes can exist.A reporting library had void Fill(object[] row) that wrote formatted values into the array it was given. A caller passed string[] row = new string[columns]; to avoid casting later, and everything worked until a new column type wrote a decimal — the job crashed with ArrayTypeMismatchException deep inside the library. The fix was changing the API to void Fill(Span<object?> row) (which throws immediately at the call site if given a string[]) and later to returning a typed record per row. The lesson: covariant arrays hide a contract violation until data changes.
A debugging question that probes type-system depth.
Green flags
out/in.Span<T> constructor check.Red flags
Storing into a reference-type array uses stelem.ref (or stelem with a reference type token). The JIT expands it into a call to a helper (CORINFO_HELP_ARRADDR_ST / StelemRef), which roughly does:
if (value == null) store; return;
elementType = array.MethodTable.ElementType;
if (value.MethodTable == elementType) store; return; // fast path: exact match
if (elementType == typeof(object)) store; return; // object[] accepts anything
if (IsAssignable(value.MethodTable, elementType)) store; // slow path: walk hierarchy / interfaces / cast cache
else throw ArrayTypeMismatchException;
The helper also performs the GC write barrier that every reference store needs. For value-type arrays (int[]), there is no covariance check and no write barrier.
T[] where T is sealed (e.g. string[]), the actual array can only be string[], so any string store is safe; the JIT can skip the type check.new T[n] in the same method, its exact type is known.null.That's why sorting or shuffling a string[] is cheap, but writing arbitrary objects to an object[] that might be a covariant view is not.
Span<T> / Memory<T>object[] arr = new string[3];
var span = new Span<object>(arr); // throws ArrayTypeMismatchException
ReadOnlySpan<object> ro = arr; // OK - read-only, no writes possible
Writable spans write elements with plain memory stores (no per-element check), so the constructor validates up front that arr.GetType() == typeof(object[]). This means some code paths throw just from creating a span over a covariant array, even if you never write.
The CLR allows reinterpretation between same-size integral arrays of different signedness:
object o = new int[] { -1 };
uint[] u = (uint[])o; // allowed by the CLR! u[0] == 4294967295
C# rejects (uint[])new int[1] at compile time, but casting through object succeeds at run time. Enum arrays and their underlying integer arrays are similarly interchangeable. These are rarely used intentionally but can surprise in reflection-heavy code.
out, in) are only allowed on interface and delegate type parameters, not classes or structs.out T parameter may appear only in output positions (return types, get accessors, out T in other covariant interfaces).in T parameter may appear only in input positions.IEnumerable<int> is not convertible to IEnumerable<object>, because that would require boxing each element.The classic example of contravariance: an IComparer<object> can be used where an IComparer<string> is expected, because anything that compares objects can compare strings.
SZ arrays implement IList<T>, IReadOnlyList<T> and friends through runtime magic (SZArrayHelper). Because of array covariance, a string[] also "implements" IList<object>, and writing through that interface triggers the same runtime check. Calling Add on an array via IList<T> throws NotSupportedException, because arrays are fixed-size. These are more reasons not to expose arrays as IList<T>.
object values into a real object[] vs into object[] arr = new string[n] (storing strings) with BenchmarkDotNet.new Span<object>(new string[1]) and ReadOnlySpan<object> r = new string[1];.IProducer<out T> { void Accept(T value); } and read CS1961.namespace Arrays;
public abstract class Animal { public abstract string Sound { get; } }
public sealed class Dog : Animal { public override string Sound => "Woof"; }
public sealed class Cat : Animal { public override string Sound => "Meow"; }
public static class Covariance
{
public static void Main()
{
// 1. Covariant array, checked writes
object[] items = new string[3];
items[0] = "ok";
try
{
items[1] = 42;
}
catch (ArrayTypeMismatchException)
{
Console.WriteLine($"Array is really {items.GetType().Name}"); // String[]
}
// 2. Base-type array that is really a Dog[]
Animal[] animals = new Dog[2];
animals[0] = new Dog();
try { animals[1] = new Cat(); }
catch (ArrayTypeMismatchException) { Console.WriteLine("Cannot store a Cat in a Dog[]"); }
// 3. Span constructor also checks
try { _ = new Span<Animal>(animals); }
catch (ArrayTypeMismatchException) { Console.WriteLine("Writable span refused"); }
ReadOnlySpan<Animal> readOnly = animals; // fine: cannot write
// 4. Safe alternative: generic covariance on read-only interfaces
List<Dog> dogs = [new Dog(), new Dog()];
IReadOnlyList<Animal> view = dogs; // allowed, cannot add a Cat
PrintAll(view);
// List<Animal> wrong = dogs; // compile error: List<T> is invariant
}
private static void PrintAll(IEnumerable<Animal> animals)
{
foreach (var a in animals) Console.WriteLine(a.Sound);
}
}
object[]What happens: a method takes object[] and writes results into it.
Why it is wrong: callers can pass a covariant string[] (or Customer[]), and writes fail at run time with ArrayTypeMismatchException.
Do instead: return new data, use generic parameters (T[]), or accept Span<object?> which fails fast at the call site.
What happens: object[] items = new Order[10]; treated as a general-purpose object array.
Why it is wrong: the compiler allows it, but the runtime enforces the real element type on every write.
Do instead: create the array with the type you intend to store, or use read-only interfaces for covariance.
List<Derived> to convert to List<Base>What happens: surprise at a compile error for List<object> l = new List<string>();.
Why it is wrong: List<T> is invariant because it supports both reads and writes — allowing it would recreate the array problem.
Do instead: use IEnumerable<Base> or IReadOnlyList<Base> for read-only covariance, or create a new list with Cast<Base>().ToList().
What happens: passing IEnumerable<int> where IEnumerable<object> is expected.
Why it is wrong: variance only applies to reference types; value types would need boxing.
Do instead: use .Cast<object>() (boxes each element) or make the consuming method generic.
object[] as a general container in hot pathsWhat happens: object[] arrays filled in tight loops (e.g. parameter arrays, row buffers).
Why it is wrong: each store has a covariance check and write barrier; value types are boxed.
Do instead: use typed arrays, structs, or generic buffers.
What happens: new Span<Base>(arrayOfBase) where the array may actually be Derived[].
Why it is wrong: the constructor throws ArrayTypeMismatchException.
Do instead: use ReadOnlySpan<Base> for reading, or ensure the array's exact type.
object to reinterpret arraysWhat happens: (uint[])(object)intArray used as a trick.
Why it is wrong: confusing, relies on CLR quirks, and breaks with tools that assume C# rules.
Do instead: use MemoryMarshal.Cast<int, uint>(span) explicitly when reinterpretation is intended.
IList<T>What happens: returning a string[] typed as IList<string> or IList<object>.
Why it is wrong: callers may call Add (throws NotSupportedException) or write through covariance (throws ArrayTypeMismatchException).
Do instead: return IReadOnlyList<T> or ImmutableArray<T>.
What happens: arrays of non-sealed classes written frequently. Why it is wrong: the JIT can't skip covariance checks because subclasses might exist. Do instead: seal classes that aren't designed for inheritance (CA1852) — it also helps devirtualisation.
What happens: candidates blame boxing or a cast error rather than covariance.
Why it is wrong: misses the real mechanism — the runtime element-type check on store.
Do instead: explain static vs runtime array type and the stelem.ref check.
Array covariance is a legacy design flaw; seniors avoid relying on it and design APIs with generic variance instead.
| API need | Signature |
|---|---|
| Read a sequence of base types | IEnumerable<Animal> / IReadOnlyList<Animal> |
| Compare or consume base types | IComparer<in T>, Action<in T> |
| Mutate a collection | exact List<T> / T[] |
| High-performance read | ReadOnlySpan<T> |
out and in variance is checked at compile time while array covariance is checked at run time shows real type-system understanding.sealed types and struct wrappers in hot code.object[] or base-class arrays; they invite this bug.An array is a fixed-length, zero-based, contiguous block of elements of one type. T[] is a reference type, and new int[5] gives five zeros. I create them with new T[n], initialisers or collection expressions like int[] ids = [1, 2, 3]. The length never changes: Array.Resize actually allocates a new array and copies. Clone is a shallow copy, arr[1..3] copies a range while arr.AsSpan(1..3) is a free view, and Array.Sort/BinarySearch are the built-in algorithms. I use arrays for fixed-size data, buffers and hot loops, and List<T> when the number of items changes.
A value type holds its data directly, so assigning or passing it copies the data; a reference type variable holds a reference to an object on the managed heap, so copying the variable makes two references to the same object. Value types are stored inline where they are declared: a local usually sits on the stack or in a register, but a struct field of a class lives inside that object on the heap, and captured or boxed values also end up on the heap. By default classes compare by identity and structs by their fields. I use structs for small, immutable values like Money or coordinates, and classes for everything with identity or behaviour.
How to tell it (STAR)
**S:** Our payments service [at company] crashed at start-up for one tenant configuration. **T:** I had to find out why only that configuration failed. **A:** I traced the ArrayTypeMismatchException to a handler array that was really a derived-type array because of covariance, replaced it with a list of the interface type, and changed the module API to read-only interfaces. **R:** The crash was fixed the same day and the configuration test now covers every tenant.
Last updated 30 Oct 2026.