C# Variance — Why Array Store Checks Crash Production Loops
ArrayTypeMismatchException crashed a payment pipeline after array widening.
- Covariance (out) lets a generic interface produce derived types; contravariance (in) lets it consume base types
- IEnumerable
and IComparer are the canonical BCL examples - Covariance: IProducer
is assignable to IProducer ; contravariance: IProcessor is assignable to IProcessor - Only interfaces and delegates support variance; classes and structs are always invariant
- Array covariance is legacy and unsound — writes through a widened reference throw ArrayTypeMismatchException at runtime
- Constructed generic delegate types are not directly castable even when variance applies — method group assignment is the safe path
- The compiler fully verifies variance safety when you use
inorouton type parameters
Imagine you have a basket labeled 'Fruit'. You can put apples in it because an apple IS a fruit — that's covariance, flowing in the same direction as inheritance. Now imagine a machine that processes any fruit. You can use that machine to process apples too, even though it was built for fruit in general — that's contravariance, flowing in the opposite direction. Covariance lets you use a more specific type where a more general one is expected (producing values — reading them out). Contravariance lets you use a more general handler where a specific one is expected — because the handler can process any input of the broader type, it can certainly handle the narrower one too. Both are about making type substitution safe and predictable, not about bypassing the type system.
Generic type safety is one of the most quietly powerful features of C#, and covariance and contravariance sit right at its heart. Every time you assign an IEnumerable<string> to an IEnumerable<object>, or pass a Func<Animal> where a Func<Cat> is expected, the CLR is doing something subtle and deliberate on your behalf. Most developers use these features daily without knowing their names — and that gap in knowledge causes real production bugs.
The problem these concepts solve is deceptively simple: how do you make generic types work safely with inheritance hierarchies? Without covariance and contravariance, you'd have to write casting boilerplate everywhere, or worse, end up with runtime InvalidCastExceptions that slip past the compiler. The C# type system gives you a way to declare, at the interface or delegate level, exactly which direction type substitution is safe — and the compiler enforces it.
By the end of this article you'll understand exactly what the 'in' and 'out' keywords do on generic type parameters, why arrays in C# are covariant but carry a runtime cost, how delegate variance works for method group assignments and where it silently breaks for constructed generic delegate types, where variance is intentionally unsupported and why, and how to apply all of this in production code with confidence. You'll also be ready to answer the variance questions that trip up experienced developers in senior-level interviews.
The Type Substitution Problem — Why Variance Exists at All
Here's a question that sounds like it should have an obvious answer: if Dog inherits from Animal, why can't you assign a List<Dog> to a List<Animal>?
The Liskov Substitution Principle says a Dog can stand in anywhere an Animal is expected — that's the whole point of inheritance. So the assumption that List<Dog> IS-A List<Animal> feels natural. But it's wrong, and understanding exactly why is the doorway into variance.
If List<Dog> were assignable to List<Animal>, nothing would stop you from calling Add(new Cat()) on the widened reference. The compiler sees List<Animal> and allows it. The runtime sees a List<Dog> and explodes. That's not a type system — that's a landmine. This is why List<T> is invariant: no substitution in either direction is permitted, full stop.
Variance solves this by being surgical rather than wholesale. Instead of making List<T> magically accept subtypes, C# lets interface and delegate designers mark individual type parameters as safe for covariant use (out — you only return T, never accept it) or safe for contravariant use (in — you only accept T, never return it). The compiler then verifies those contracts are upheld everywhere inside the type, making the variance provably safe at compile time, not a runtime gamble.
This is the key insight most articles skip: variance isn't magic permissiveness. It's a compile-time-verified contract about data flow direction. The 'out' keyword is a promise that T only exits the type — like water flowing out of a pipe. The 'in' keyword is a promise that T only enters the type — like water flowing in. If data needs to flow both ways, the pipe must be invariant, full stop.
out or in after you have confirmed the usage pattern is genuinely read-only or write-only. Premature variance closes the door on future API additions — if you later need to add a method that goes in the opposite direction, removing in or out is a breaking change for every caller. Pay the cost of that decision consciously, not by accident.Covariance With 'out' — Building and Using Covariant Interfaces
The most common question after understanding the type substitution problem is: 'okay, but how do I actually get LINQ and IEnumerable to work across my inheritance hierarchy without casting everywhere?' That's covariance, and it's declared with the 'out' keyword.
Once you mark a type parameter as 'out', the compiler enforces one strict rule: T can only appear in output positions — return types, property getters, and out parameters. It cannot appear as a method parameter type. This restriction is what makes the covariant assignment safe. The compiler verifies this every time you implement the interface too — if you try to put 'out T' in a method parameter, you'll get CS1961 immediately.
The canonical example in the BCL is IEnumerable<out T>. Because the interface only ever produces T values (via MoveNext/Current), it's provably impossible to inject a wrong-typed object through it. This is why every LINQ extension method that takes IEnumerable<T> works with IEnumerable<Dog>, IEnumerable<string>, and so on — covariance is doing that work silently in the background every time.
Where this matters in real code: factory results, read-only projections, producer patterns. Any time you have an interface that returns objects of type T but never accepts T as input, mark that parameter as 'out' and you unlock free assignment compatibility across your entire inheritance hierarchy without a single cast.
One nuance worth burning into memory: covariance only works on interfaces and delegates, never on classes. List<Dog> will never be assignable to List<Animal> regardless of what you do, because List<T> is a class. This is by design — classes have mutable state that makes variance unsound. If you want variance on a class, extract an interface and put the 'out' keyword there. The class stays invariant; the interface carries the variance contract.
Another subtlety: a covariant type parameter inside a covariant wrapper is still covariant, but a covariant type parameter inside a contravariant wrapper flips to contravariant. IEnumerable<out T> inside a return position is fine; IEnumerable<out T> inside a method parameter position would break covariance. The compiler tracks this chain automatically and will tell you exactly where the violation is.
Contravariance With 'in' — When a General Handler Beats a Specific One
Contravariance is the one that makes developers pause and re-read the line three times. Not because it's complicated — once it clicks, it's obvious — but because the assignment direction is backwards from everything inheritance has trained you to expect.
Here's the scenario that makes it concrete: you have an IComparer<Animal> that compares any two animals by name. You need to sort a List<Dog>. List<Dog>.Sort() expects an IComparer<Dog>. Do you need a separate DogComparer? No — and if you've ever written one when you already had a working AnimalComparer, this section is for you.
The 'in' keyword on a type parameter means T can only appear in input positions — method parameters and property setters. It cannot appear in return types. This makes the assignment direction flip: a more general type can be assigned to a more specific one. An IComparer<Animal> IS assignable to IComparer<Dog>. The reason is mechanical and worth saying once clearly: if a handler can process any Animal, it can certainly process a Dog, because Dog IS an Animal. The handler doesn't know or care that it's receiving something more specific — it already handles the more general case.
Where this matters in production: event handlers, comparers, validators, formatters, loggers — anything that consumes a value rather than producing it. If your interface only ever accepts T as input and never returns it, mark T as 'in' and you get assignment compatibility in the useful direction: callers can supply a broader handler and it just works.
The real power shows up when you combine covariance and contravariance in the same pipeline. A function that accepts a broad input type and returns a narrow output type composes beautifully across inheritance boundaries — which is exactly what Func<in TInput, out TOutput> expresses in the BCL. This is not a coincidence; the C# team designed Func and Action this way precisely to support real-world composition patterns.
Delegate Variance, Array Covariance, and the Hidden Runtime Cost
Two features in this section look like variance but behave very differently from each other — and from the interface variance you've seen so far. Getting them confused is how production incidents happen.
Delegates in C# support variance for method group assignments without any in or out annotation. A method that returns a Dog can be assigned to a Func<Animal> delegate variable (covariance). A method that accepts an Animal can be assigned to an Action<Dog> delegate variable (contravariance). The compiler infers compatibility from the method signature directly.
Here is the part that catches experienced engineers off guard: this variance applies to method group assignments specifically, not to delegate instance casting. You cannot cast a Func<Dog> instance directly to Func<Animal> and expect it to work. The compiler may allow the cast syntactically in some contexts, but the CLR will throw InvalidCastException at runtime because the underlying delegate types are different constructed generic types — Func<Dog> and Func<Animal> have no inheritance relationship between them, variance-compatible or not. The safe path is always method group assignment, or wrapping: Func<Animal> f = () => existingDogFunc(). Burn this into your team's coding standards. It surprises engineers with years of C# experience.
Array covariance is a different and older story — and a problematic one. string[] is assignable to object[] in C# because arrays have been covariant since C# 1.0, predating generics. This was a pragmatic decision (Java made the same one), but it is unsound: you can store any object reference in the object[] variable and the compiler will not stop you. The runtime catches it with an ArrayTypeMismatchException, but that is a runtime failure, not a compile-time one — exactly the kind of bug you want the type system to prevent.
The key difference from IEnumerable<out T> is that arrays are mutable. You can write to an array through the widened reference, which is what creates the danger. IEnumerable<out T> avoids this by being read-only by design. This is why sound generic covariance only works on interfaces and delegates, not on classes or arrays.
The performance angle deserves its own paragraph: every write to an array that was assigned to a wider element-type variable goes through a CLR runtime check called the covariant array store check. This is a type identity comparison that happens on every write, not just the ones that could be problematic. In a tight loop writing millions of elements, this overhead is measurable. For performance-sensitive paths, the right tools are strongly typed arrays or Span<T> — but note that Span<T> is a ref struct and cannot participate in generic variance at all. It cannot be used as a type argument to IReadOnlyList<T> or any other generic interface. Its job is stack-allocated, high-performance buffer access, not covariant abstraction. For covariant public APIs, IReadOnlyList<out T> is the correct choice. Here's the pattern in one place:
```csharp // BEFORE: covariant array — unsafe for writes, store-check overhead on every write Dog[] dogs = GetDogs(); Animal[] animals = dogs; // compiles, dangerous
// AFTER: safe covariant view — no writes possible, no store check, clear API intent IReadOnlyList<Animal> animals = new List<Animal>(dogs); // explicit, honest, safe // or, if you genuinely only need a read-only view of the existing array: IReadOnlyList<Animal> view = dogs; // IReadOnlyList<out T> is covariant — this is sound ```
The second form — assigning Dog[] directly to IReadOnlyList<Animal> — works because IReadOnlyList<out T> is covariant and the interface prevents writes. No store check, no exception risk, and the read-only contract is enforced at compile time by the interface itself.
Common Compiler Errors and How to Fix Them — CS1960, CS1961, CS1962
Variance modifiers trigger specific compiler errors when misapplied. Learning to recognise and fix them immediately saves hours of investigation. All three are compile-time errors — the compiler is doing exactly its job, and the right response is always to fix the design, not suppress the error.
CS1960 occurs when you apply in or out to a type parameter on a class or struct. Variance is only allowed on interfaces and delegates. The fix is to extract an interface, declare the variance modifier there, and leave the concrete class invariant. The class can implement the interface without issue.
CS1961 fires when you use a covariant type parameter (out T) in an input position — for example, as a method parameter of type T inside the interface. The compiler is telling you that allowing T to flow in would make the covariant assignment unsafe: a caller could pass in a more derived type than the internal implementation expects. The fix is to remove the input-position usage of T, or to accept that the interface cannot be covariant and remove out.
CS1962 is the mirror: using a contravariant type parameter (in T) in an output position — like returning T from a method. That would let the consumer treat the result as a more specific type than it actually is, breaking type safety in the opposite direction. The fix is the same: remove the output-position usage of T, or remove in.
A subtler form of CS1961 and CS1962 occurs through chains of generic types. A covariant type parameter inside a contravariant wrapper flips direction. For example, if you have interface IProcessor<in T> and you try to use Func<T> as a return type, the compiler will flag it — Func<T> is covariant in T, and using a covariant position inside a contravariant interface violates the in/out contract. The error message will point to the chain, not just the leaf type. Read it carefully and follow the variance direction from the outermost type inward.
All of these errors prevent a category of runtime failures that would be nearly impossible to debug in production. Never suppress them.
Array Covariance Bringing Down a Payment Processing Pipeline
- Never widen an array reference when writes can occur — use strongly typed generic collections instead.
- The CLR covariant store check fires on every write through a widened array reference, not just the bad ones. In tight loops this is measurable overhead even when no exception is ever thrown.
- IReadOnlyList<out T> is safe for covariant reads; arrays are safe only when you guarantee no writes through the widened reference — a guarantee that is impossible to enforce at the call site.
- Compiler silence is not type safety. Array covariance is a hole in the type system that the compiler deliberately does not close. Treat it as a deprecated pattern in any new code you write.
in or out. 2. For CS1961 (out parameter in input position): remove the method parameter that accepts T, or change the interface design so reading and writing are on separate interfaces. 3. For CS1962 (in parameter in output position): remove the return type that produces T, or make the interface invariant by removing in. 4. If both reading and writing are genuinely needed, the type parameter must be invariant — remove in/out entirely and accept that the interface will not support variance. This is the correct answer, not a workaround.new Func<BaseType>(existingMethod) instead of (Func<BaseType>)existingDelegate.Func<Animal> animalFunc = dogFuncMethod where dogFuncMethod is the underlying method, not an existing delegate variable. 3. If you only have a delegate instance and not the method, wrap it: Func<Animal> animalFunc = () => existingDogFunc(). 4. Document this pattern in your team's coding standards — it surprises even experienced engineers.IReadOnlyList<FinancialEvent> safeBuffer = new List<FinancialEvent>(batch);Key takeaways
Common mistakes to avoid
6 patternsAssuming List<Dog> is assignable to List<Animal>
Trying to declare variance on a class instead of an interface
in or out is placed on a class or struct type parameter.Putting an 'out' type parameter in a method parameter position
out T but a method accepts T as input.out. If you need both, split into two interfaces: one producer with out T and one with an invariant T for the accepting methods.Putting an 'in' type parameter in a return position
in T but a method returns T.in and make the interface invariant. If both consuming and producing T are genuinely needed, the type parameter must be invariant.Using array covariance in new API signatures
Casting delegate instances between constructed generic types
Func<Animal> animalFunc = () => existingDogFunc(). This creates a new delegate that is correctly typed without an unsafe cast.Interview Questions on This Topic
Can you explain the difference between covariance and contravariance in C# generics, and give a concrete example of each from the BCL?
Frequently Asked Questions
That's OOP in C#. Mark it forged?
10 min read · try the examples if you haven't