C# Variance — Why Array Store Checks Crash Production Loops
ArrayTypeMismatchException crashed a payment pipeline after array widening.
20+ years shipping production .NET services in enterprise systems. Notes here come from systems that actually shipped.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- 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
Variance in C# controls whether a type parameter can be substituted with a more derived type (covariance) or a more base type (contravariance) in generic interfaces, delegates, and arrays. It exists because the type system needs to reconcile the Liskov substitution principle with type safety at runtime.
Without explicit variance markers (out for covariance, in for contravariance), the compiler prevents assignments that look natural but could break memory safety — like putting a string into an object[] that was actually created as a string[], which is exactly why array store checks exist and can throw ArrayTypeMismatchException in production loops. The runtime pays for this safety with a hidden type check on every write to a covariant array reference, which is why List<T> avoids the problem entirely by being invariant.
In practice, you encounter variance most often with IEnumerable<T> (covariant — you can pass a List<string> to a method expecting IEnumerable<object>) and IComparer<T> (contravariant — a single IComparer<object> can sort any type). The compiler enforces these rules with error codes CS1960 (invalid variance modifier), CS1961 (invalid variance in a type parameter), and CS1962 (invalid variance in a delegate).
Understanding variance is critical when designing public APIs: misuse leads to either overly restrictive interfaces that force callers to cast, or unsound code that compiles but crashes at runtime. The key insight is that covariance is safe for output-only positions (return types), contravariance for input-only positions (parameters), and any attempt to use both on the same type parameter forces invariance — which is why mutable collections like List<T> can never be safely covariant.
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.
Why Array Store Checks Crash Production Loops
Covariance and contravariance describe how type relationships flow through generic parameters and array element types. Covariance lets you use a more derived type where a base type is expected (e.g., IEnumerable<string> as IEnumerable<object>). Contravariance does the opposite — you can pass a base type where a derived type is expected (e.g., IComparer<object> as IComparer<string>). In C#, only reference types participate; value types break variance entirely.
Arrays are covariant in C# — string[] can be assigned to object[]. But this is a leaky abstraction: the runtime inserts a store check on every write. If you write an int into an object[] that is actually a string[], you get an ArrayTypeMismatchException at runtime, not a compile error. This is O(1) per write, but the failure is deferred and often surfaces far from the root cause.
Use variance when you need to pass collections or comparers across abstraction boundaries without forcing consumers to know concrete types. It matters in real systems because it enables reusable APIs — like passing a List<Cat> to a method expecting IEnumerable<Animal> — without boxing or copying. But never rely on array covariance for writes; use IEnumerable<T> for read-only covariance and IReadOnlyCollection<T> for safety.
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.
namespace io.thecodeforge.covariance; using System; using System.Collections.Generic; public class Animal { public string Name { get; init; } public Animal(string name) => Name = name; public override string ToString() => $"Animal({Name})"; } public class Dog : Animal { public Dog(string name) : base(name) { } public override string ToString() => $"Dog({Name})"; } public class Cat : Animal { public Cat(string name) : base(name) { } public override string ToString() => $"Cat({Name})"; } class VarianceMotivation { static void Main() { // --- WHY List<Dog> cannot be List<Animal> --- List<Dog> dogPack = new() { new Dog("Rex"), new Dog("Buddy") }; // This line does NOT compile — List<T> is INVARIANT. // If it did compile, the next line would corrupt the list at runtime. // List<Animal> animals = dogPack; // CS0029 — cannot implicitly convert // animals.Add(new Cat("Whiskers")); // Rex and Buddy would be very upset // --- BUT IEnumerable<Dog> CAN be used as IEnumerable<Animal> --- // IEnumerable<T> is COVARIANT (out T), so this is safe and legal. // We can only READ from IEnumerable — we can never Add to it. // Therefore the Cat-corruption scenario is impossible. IEnumerable<Animal> safeAnimals = dogPack; // compiles just fine Console.WriteLine("Safe covariant assignment succeeded."); foreach (Animal animal in safeAnimals) { // Each element is still a Dog at runtime — covariance preserves identity. // The variable type is Animal, but GetType() proves the real type survived. Console.WriteLine($" {animal} — runtime type: {animal.GetType().Name}"); } } }
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.
namespace io.thecodeforge.covariance; using System; using System.Collections.Generic; // --- Covariant interface: T only flows OUT --- // The 'out' keyword tells the compiler: this interface only produces T values. // It can never accept a T as an argument, so widening is provably safe. public interface IAnimalProducer<out TAnimal> where TAnimal : Animal { TAnimal Produce(); // Legal: T in return position (output) IEnumerable<TAnimal> ProduceBatch(int count); // Legal: T inside a covariant wrapper // void Accept(TAnimal animal); // ILLEGAL — CS1961, T in input position } public class DogBreeder : IAnimalProducer<Dog> { private readonly string[] _names = { "Apollo", "Bella", "Caesar" }; private int _index = 0; public Dog Produce() => new Dog(_names[_index++ % _names.Length]); public IEnumerable<Dog> ProduceBatch(int count) { for (int i = 0; i < count; i++) yield return Produce(); } } public class RescueShelter : IAnimalProducer<Cat> { public Cat Produce() => new Cat("Rescue-" + Guid.NewGuid().ToString()[..4]); public IEnumerable<Cat> ProduceBatch(int count) { for (int i = 0; i < count; i++) yield return Produce(); } } class CovariantProducer { // This method accepts any IAnimalProducer<Animal>. // Because of covariance, we can pass an IAnimalProducer<Dog> or IAnimalProducer<Cat>. static void DisplayThreeAnimals(IAnimalProducer<Animal> producer) { Console.WriteLine($"Producer type: {producer.GetType().Name}"); foreach (Animal animal in producer.ProduceBatch(3)) { // Runtime type is preserved — covariance does not erase the concrete type. // The variable is typed as Animal, but GetType() returns the real class. Console.WriteLine($" Got: {animal} [{animal.GetType().Name}]"); } } static void Main() { IAnimalProducer<Dog> dogBreeder = new DogBreeder(); IAnimalProducer<Cat> rescueShelter = new RescueShelter(); // Covariant assignment: IAnimalProducer<Dog> → IAnimalProducer<Animal> // This compiles ONLY because TAnimal is marked 'out'. IAnimalProducer<Animal> animalSource = dogBreeder; DisplayThreeAnimals(dogBreeder); // passes directly DisplayThreeAnimals(rescueShelter); // Cat producer accepted as Animal producer DisplayThreeAnimals(animalSource); // the explicitly widened reference } }
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.
namespace io.thecodeforge.covariance; using System; using System.Collections.Generic; // --- Contravariant interface: T only flows IN --- // The 'in' keyword means this interface only CONSUMES T values. // Therefore a handler of Animal can safely act as a handler of Dog. public interface IAnimalProcessor<in TAnimal> where TAnimal : Animal { void Process(TAnimal animal); // Legal: T in parameter (input position) void ProcessBatch(IEnumerable<TAnimal> batch); // Legal: T inside an input parameter // TAnimal Retrieve(); // ILLEGAL — CS1962, T in output position } // A general processor that handles ANY animal public class AnimalHealthChecker : IAnimalProcessor<Animal> { public void Process(Animal animal) { // Works on any Animal — so it works on Dog and Cat too. // The concrete type is visible at runtime even through the contravariant interface. Console.WriteLine($" Health check passed for {animal} [{animal.GetType().Name}]"); } public void ProcessBatch(IEnumerable<Animal> batch) { foreach (Animal animal in batch) Process(animal); } } // A specific processor that only handles Dogs public class DogTrainer : IAnimalProcessor<Dog> { public void Process(Dog dog) { Console.WriteLine($" Training session for {dog}"); } public void ProcessBatch(IEnumerable<Dog> batch) { foreach (Dog dog in batch) Process(dog); } } class ContravariantProcessor { // This method expects something that processes Dogs specifically. // Because IAnimalProcessor<in TAnimal> is contravariant, the general // AnimalHealthChecker is accepted here — it handles any Animal, so Dogs are fine. static void RunDogPipeline(IAnimalProcessor<Dog> dogProcessor, IEnumerable<Dog> dogs) { Console.WriteLine($" Using processor: {dogProcessor.GetType().Name}"); dogProcessor.ProcessBatch(dogs); } static void Main() { var dogPack = new List<Dog> { new Dog("Max"), new Dog("Luna") }; IAnimalProcessor<Animal> generalChecker = new AnimalHealthChecker(); IAnimalProcessor<Dog> specificTrainer = new DogTrainer(); // Contravariant assignment: IAnimalProcessor<Animal> → IAnimalProcessor<Dog> // This flows OPPOSITE to inheritance: Animal is broader than Dog, // yet the Animal processor is assignable to the Dog processor slot. // Safe because: anything a Dog processor is asked to handle IS an Animal. IAnimalProcessor<Dog> checkerAsDogProcessor = generalChecker; // compiles cleanly Console.WriteLine("Running with specific DogTrainer:"); RunDogPipeline(specificTrainer, dogPack); Console.WriteLine("Running with general AnimalHealthChecker (contravariant):"); RunDogPipeline(generalChecker, dogPack); // general processor passed directly RunDogPipeline(checkerAsDogProcessor, dogPack); // explicit contravariant reference } }
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.
namespace io.thecodeforge.covariance; using System; class DelegateAndArrayVariance { // --- Method group covariance --- // Returns Dog (more specific), compatible with Func<Animal> (more general). // This works because Dog IS-AN Animal — covariance flows with inheritance. static Dog CreateDog() => new Dog("Scout"); // --- Method group contravariance --- // Accepts Animal (more general), compatible with Action<Dog> (more specific). // This works because Dog IS-AN Animal — the method can handle any Animal, // so it can certainly handle a Dog. static void LogAnimal(Animal animal) => Console.WriteLine($" [LOG] {animal} ({animal.GetType().Name})"); static void Main() { // DELEGATE COVARIANCE — method group assignment // CreateDog returns Dog; Dog IS-AN Animal; so Func<Animal> can hold it. Func<Animal> animalFactory = CreateDog; Animal produced = animalFactory(); Console.WriteLine($"Delegate covariance produced: {produced}"); // DELEGATE CONTRAVARIANCE — method group assignment // LogAnimal accepts Animal; Dog IS-AN Animal; so a method handling any Animal // can handle Dogs specifically. The assignment goes against inheritance direction. Action<Dog> dogLogger = LogAnimal; dogLogger(new Dog("Ranger")); // THE DELEGATE VARIANCE TRAP — do not cast delegate instances directly Func<Dog> dogFactory = CreateDog; // Func<Animal> wrongWay = (Func<Animal>)dogFactory; // InvalidCastException at runtime! // The compiler may not always catch this. The CLR will. // The correct approach is to wrap the existing delegate: Func<Animal> rightWay = () => dogFactory(); Console.WriteLine($"Wrapped delegate covariance: {rightWay()}"); // --- ARRAY COVARIANCE — legal but dangerous --- Dog[] dogArray = { new Dog("Fido"), new Dog("Spot") }; // Compiles fine — arrays have been covariant since C# 1.0. // animalArray and dogArray point to the SAME memory. Animal[] animalArray = dogArray; Console.WriteLine("\nReading through widened array reference (safe):"); foreach (Animal a in animalArray) Console.WriteLine($" {a}"); // WRITING through the widened reference — runtime ArrayTypeMismatchException! // The compiler sees Animal[] and allows the assignment syntactically. // The CLR sees Dog[] at runtime and rejects Cat — covariant array store check. Console.WriteLine("\nAttempting unsafe array write..."); try { animalArray[0] = new Cat("Mittens"); // ArrayTypeMismatchException here } catch (ArrayTypeMismatchException ex) { Console.WriteLine($" Runtime caught it: {ex.GetType().Name}"); Console.WriteLine(" The CLR store check fires on every write, not just bad ones."); Console.WriteLine(" In a tight loop this overhead is measurable."); } // The safe alternative: IReadOnlyList<out T> is genuinely covariant // and prevents write access through the interface entirely. // No store check, no exception risk, no performance overhead after construction. // Note: Span<T> is NOT usable here — it's a ref struct and cannot be a generic // type argument. Use Span<T> for stack-allocated buffer performance, not for // covariant abstractions. System.Collections.Generic.IReadOnlyList<Animal> safeView = dogArray; Console.WriteLine($"\nSafe covariant view via IReadOnlyList: {safeView[0]}"); // safeView[0] = new Cat("Mittens"); // CS0200 — property or indexer is read-only } }
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.
namespace io.thecodeforge.varianceerrors; using System; // Shared types for this file — kept in a separate namespace to avoid // collision with the Animal/Dog/Cat types in other examples. public class Shape { public string Kind { get; init; } public Shape(string kind) => Kind = kind; public override string ToString() => $"Shape({Kind})"; } public class Circle : Shape { public double Radius { get; init; } public Circle(double radius) : base("Circle") => Radius = radius; public override string ToString() => $"Circle(r={Radius})"; } // CS1960 — variance keyword on a CLASS (not allowed) // public class BadClass<out T> { } // error CS1960 // Correct: declare variance on an INTERFACE, implement with an invariant class public interface IShapeProducer<out TShape> where TShape : Shape { TShape Produce(); // Legal: T in return position // void Accept(TShape s); // ILLEGAL — would cause CS1961 } public class CircleFactory : IShapeProducer<Circle> { private readonly double _radius; public CircleFactory(double radius) => _radius = radius; public Circle Produce() => new Circle(_radius); } // CS1961 — 'out' type parameter used in input position // interface IBrokenCovariant<out T> // { // void Set(T value); // error CS1961 — T in parameter, violates 'out' // } // CS1962 — 'in' type parameter used in output position // interface IBrokenContravariant<in T> // { // T Get(); // error CS1962 — T in return, violates 'in' // } // CS1961 via generic chain — covariant T inside a contravariant wrapper flips direction // interface IProcessorChain<in T> // { // Func<T> GetProducer(); // error CS1961 — Func<T> is covariant in T, // // so T appears in covariant position inside a contravariant interface // } class VarianceErrors { static void Main() { // Covariant interface: CircleFactory produces Circle, // which is assignable to IShapeProducer<Shape> because TShape is 'out' IShapeProducer<Circle> circleFactory = new CircleFactory(5.0); IShapeProducer<Shape> shapeFactory = circleFactory; // covariant assignment Shape produced = shapeFactory.Produce(); Console.WriteLine($"Produced via covariant interface: {produced}"); Console.WriteLine($"Runtime type preserved: {produced.GetType().Name}"); // Delegate variance — covariance via method group Func<Circle> circleFunc = () => new Circle(3.14); Func<Shape> shapeFunc = circleFunc.Method.CreateDelegate<Func<Shape>>(circleFunc.Target); // Simpler equivalent when you control the source: // Func<Shape> shapeFunc = () => circleFunc(); Console.WriteLine($"Delegate covariance: {shapeFunc()}"); // Delegate contravariance — method group assignment Action<Shape> shapeLogger = s => Console.WriteLine($" [LOG] {s}"); Action<Circle> circleLogger = shapeLogger; // contravariant: Action<in T> circleLogger(new Circle(2.71)); } }
Invariance — The Default That Saves Your Ass (and Why You Can't Mix IList with IEnumerable)
Most generic interfaces are invariant by default. IList<T>, ICollection<T>, IReadOnlyList<T> — none of them have the in or out keyword on their type parameters. This isn't an oversight. It's a deliberate safety rail.
Invariance means List<Customer> is not a List<ICustomer>, period. The compiler won't let you pass a List<Subscription> where a List<Payment> is expected, even if Subscription inherits from Payment`. The moment you allow that, you create a write path that can shove a non-Compliant type into your collection.
Covariance (out) only works when the type parameter appears in output positions — return values, not method parameters. Contravariance (in) only works for input positions. Invariant types can appear in both. The compiler enforces this at compile time, not at runtime when your production pipeline is streaming millions of records and someone slams an int into a string[].
Most teams never need to write a covariant or contravariant interface. The ones that do — event pipelines, message buses, command handlers — treat the decision as a compile-time contract review. You don't add out because it's cool. You add it because you've proven every consumer only reads, never writes.
// io.thecodeforge — csharp tutorial // IList<T> is invariant — this will NOT compile // List<Subscription> subs = GetSubscriptions(); // IList<Payment> payments = subs; // ERROR: CS0266 // Why? Because IList<T> has both read AND write methods: public interface IList<T> : ICollection<T> { T this[int index] { get; set; } // T in output AND input position void Add(T item); // T in input position } // Covariance requires T ONLY in output positions: public interface IReadOnlyList<out T> { T this[int index] { get; } // T in output only int Count { get; } } // This works — read-only is safe: IReadOnlyList<Subscription> subs = GetSubscriptions(); IReadOnlyList<Payment> payments = subs; // OK: covariant static List<Subscription> GetSubscriptions() => new() { new Subscription(Guid.NewGuid(), "Pro") }; public record Payment(decimal Amount); public record Subscription(Guid Id, string Plan) : Payment(29.99m);
Real World Use Cases — Event Buses, Command Handlers, and the Pipeline That Paid for Itself
Variance isn't academic. It's the difference between a generic pipeline that works across ten event types and a copy-paste disaster with eleven overloads.
Consider an event bus. You have a base IntegrationEvent and derived events like OrderPlaced, PaymentProcessed. Your handler interface is IEventHandler<in TEvent> — contravariant because every handler processes the event, not returns it. This lets a single IEventHandler<IntegrationEvent> handle ALL derived events. Write it once, register it once, and the type system routes every OrderPlaced to Handle(IntegrationEvent) automatically.
Now flip to the query side. You need IQueryHandler<out TResult> — covariant. A handler that returns OrderDetails can be assigned to a variable typed as IQueryHandler<object> because the caller only reads the result. You build a dispatcher that routes queries to handlers without caring about the exact return type until runtime.
This pattern is how message buses like MediatR, Brighter, and MassTransit handle polymorphism without drowning in generics. The compiler enforces the read/write boundaries. Your pipeline stays lean. And when a junior adds a new event type, they don't have to touch the dispatcher — the variance contract handles it.
// io.thecodeforge — csharp tutorial // Contravariant event handler — accepts base type, works for all derived public interface IEventHandler<in TEvent> { Task Handle(TEvent @event, CancellationToken ct); } public record IntegrationEvent(Guid Id, DateTime OccurredAt); public record OrderPlaced(int OrderId) : IntegrationEvent(Guid.NewGuid(), DateTime.UtcNow); // Single handler for all IntegrationEvents public class AuditLogger : IEventHandler<IntegrationEvent> { public Task Handle(IntegrationEvent @event, CancellationToken ct) { Console.WriteLine($"Audit: {@event.Id} at {@event.OccurredAt}"); return Task.CompletedTask; } } // Runtime usage — no explicit cast needed var handlers = new Dictionary<Type, object> { [typeof(OrderPlaced)] = new AuditLogger() }; // The contravariant assignment is implicit var handler = (IEventHandler<OrderPlaced>)handlers[typeof(OrderPlaced)]; await handler.Handle(new OrderPlaced(42), CancellationToken.None);
Variance in Generic Constraints — When `where T : ISomething` Breaks at Runtime
Generic constraints look safe. You write where T : IComparable<T> and think the compiler has your back. It doesn't. Variance can turn that contract into a landmine when the actual type argument is a more derived or more base type than you expected.
The root cause is that generic constraints are evaluated at compile time against what the compiler knows. If a covariant interface returns a T, and you constrain to a base type, a subclass assignment works fine. But if you try to pass that subclass into a method that expects the base — and that method writes to T — you crash. The constraint didn't forbid it; variance did.
Here's the hard rule: covariance (out) lets you return more specific types. Contravariance (in) lets you accept more general types. But constraints that involve T on both sides (e.g., where T : IComparable<T>) are invariant by default. You cannot mix in/out with constraints that constrain both input and output positions. The compiler error CS1961 will tell you exactly that. Listen to it.
// io.thecodeforge — csharp tutorial interface IProducer<out T> { T Produce(); } interface IConsumer<in T> { void Consume(T item); } // CS1961: Invalid variance: The type parameter 'T' must be invariantly valid // interface IWeird<T> where T : IComparable<T> // { // T Produce(); // void Consume(T item); // } class Animal { } class Dog : Animal { } class DogProducer : IProducer<Dog> { public Dog Produce() => new Dog(); } static void UseProducer(IProducer<Animal> producer) { Animal a = producer.Produce(); // fine } // UseProducer(new DogProducer()); // compiles Console.WriteLine("Constraint variance example — no crash");
in and out if a constraint references it on both sides. The compiler will reject it with CS1961, saving you from a runtime disaster that would take hours to debug.Variance in Nested Generics — Why `IEnumerable>` Doesn't Cast
You have IEnumerable<IEnumerable<Derived>> and want to assign it to IEnumerable<IEnumerable<Base>>. Looks like double covariance should work, right? Wrong. The outer IEnumerable is covariant, but the inner IEnumerable<Derived> is not implicitly convertible to IEnumerable<Base> unless the compiler can prove the inner type parameter is in a covariant position. It can't, because variance doesn't compose automatically across nested generic types.
Here's the reality: covariance only applies to the immediate type parameter. For nested generics, each level must be independently variant. The outer IEnumerable gives you covariance over its T, but T here is IEnumerable<Derived>, not Derived. The inner IEnumerable<Derived> is invariant by default unless the inner generic is also declared covariant. So you get a compile error about implicit conversion.
Production fix: Either make the inner type explicitly covariant (e.g., IEnumerable<out T>), or change your design to avoid nested variance. We see this in event streaming pipelines: a list of lists of events. The outer list is fine, but the inner list must be read-only. Use IReadOnlyCollection<T> or IEnumerable<T> at both levels.
// io.thecodeforge — csharp tutorial using System.Collections.Generic; class Animal { } class Dog : Animal { } class Program { static void Main() { var dogList = new List<Dog>(); var outerList = new List<IEnumerable<Dog>> { dogList }; // CS0266: Cannot implicitly convert type 'List<IEnumerable<Dog>>' to 'IEnumerable<IEnumerable<Animal>>' // IEnumerable<IEnumerable<Animal>> outerAnimals = outerList; // Fix: Use read-only collections at both levels IEnumerable<IEnumerable<Dog>> dogs = outerList; // Also works: IEnumerable<IEnumerable<Animal>> animals = outerList.Select(x => x as IEnumerable<Animal>); Console.WriteLine($"Inner type: {outerList[0].GetType().Name}"); } }
IEnumerable<IReadOnlyCollection<T>> — both levels are safe for covariance.out or in at its own level.Best Practices for Using Variance
Variance in C# is a powerful tool, but misusing it creates runtime crashes that compile successfully. The first rule: default to invariance. Only add out or in when you have a proven need for type substitution in generic interfaces. Use covariance (out) when the type parameter appears only in output positions — return types, not method arguments. Use contravariance (in) when the type appears only in input positions — method parameters, not return types. Violating these rules causes compile errors CS1961 or CS1962. Never apply variance to mutable collections like IList<T>; the runtime checks for array variance exist because of historical design mistakes. For delegate types, prefer Func<out TResult> over custom delegates when possible. Test variance boundaries with unit tests that assign a Dog to an IAnimal slot; verify the runtime behavior matches compile-time expectations.
// io.thecodeforge — csharp tutorial interface IProducer<out T> { T Produce(); } interface IConsumer<in T> { void Consume(T item); } class AnimalProducer : IProducer<Animal> { public Animal Produce() => new Dog(); } class AnimalConsumer : IConsumer<Dog> { public void Consume(Dog d) { /* safe */ } } // Usage — safe substitution IProducer<Dog> producer = new AnimalProducer(); // covariance IConsumer<Animal> consumer = new AnimalConsumer(); // contravariance
out or in to an interface with bidirectional type usage hides bugs until runtime. Always validate positions before decorating.Related Topics — Variance in Async, LINQ, and Tuples
Variance doesn't stop at interfaces and delegates. LINQ's IEnumerable<out T> is covariant, letting you pass List<Dog> to a method expecting IEnumerable<Animal>. That works because IEnumerable only returns values. Tuples in C# 7+ are value types and fully invariant — an (Dog, int) cannot substitute (Animal, int) even with implicit conversions. Async methods using Task<T> are invariant because Task is a class, not a generic interface; you cannot assign Task<Dog> to Task<Animal> without explicit wrapping. Generic methods that accept IEnumerable<T> must match T exactly. The Func and Action delegate hierarchy is the primary variance vehicle for LINQ expressions: Func<Dog, bool> can assign to Func<Animal, bool> only if contravariant on input. Understand these boundaries to avoid subtle mismatches in async pipelines or projection chains.
// io.thecodeforge — csharp tutorial IEnumerable<Dog> dogs = new List<Dog>(); IEnumerable<Animal> animals = dogs; // covariance, safe // Tuple — invariant (Dog, int) dogTuple = (new Dog(), 1); // (Animal, int) animalTuple = dogTuple; // compile error // Task — invariant Task<Dog> dogTask = Task.FromResult(new Dog()); // Task<Animal> animalTask = dogTask; // compile error // LINQ expression — contravariant input Func<Dog, bool> isBigDog = d => d.Weight > 30; Func<Animal, bool> isBigAnimal = isBigDog; // contravariance
in/out support variance—check before casting in async code.out/in; tuples and Task<T> are always invariant.Real-World Scenarios
Covariance and contravariance shine in layered architectures where flexibility is critical. Consider an event bus: you define IEvent as a base marker, then CreateUserEvent : IEvent. Your IEventHandler<in TEvent> interface uses contravariance (in) so a single IEventHandler<IEvent> can handle any derived event type — perfect for logging or audit pipelines without registering handlers per concrete event. Conversely, a query pipeline returns IEnumerable<out T> — covariance lets you cast IEnumerable<CustomerQuery> to IEnumerable<IQuery> when returning from a facade. In dependency injection, contravariance lets you bind a general IValidator<object> to validate Customer models at runtime without per-type registrations. These patterns eliminate boilerplate and reduce breaking changes when domain models evolve. Ignoring variance here forces brittle casts or endless generic overloads — real-world codebases pay that tax daily.
// io.thecodeforge — csharp tutorial public interface IEvent { } public record CreateUserEvent(int Id) : IEvent; // Contravariant handler: a general handler can process any IEvent public interface IEventHandler<in TEvent> where TEvent : IEvent { void Handle(TEvent evt); } public class LoggingHandler : IEventHandler<IEvent> { public void Handle(IEvent evt) => Console.WriteLine($"Logging: {evt.GetType().Name}"); } // Usage var bus = new EventBus(); bus.Register<IEvent>(new LoggingHandler()); bus.Publish(new CreateUserEvent(1)); // Output: Logging: CreateUserEvent
IEventHandler<IEvent> instance may process multiple events concurrently, so avoid shared mutable state in Handle.Conclusion
Variance in C# is not academic theory — it's a practical tool for writing type-safe, flexible code that adapts to change. Covariance (out) lets you treat derived types as their base in read-only scenarios, enabling safer collection interfaces like IEnumerable<T>. Contravariance (in) flips the script: a general input handler can replace many specific ones, reducing duplication in pipelines and buses. Yet invariance remains your safety net — IList<T> forbids variance because write operations break type safety. The hidden costs are real: array covariance burns runtime checks, nested variance requires explicit casting, and constraints can fail unexpectedly with where T : ISomething in covariant positions. Master these rules to write libraries that scale without runtime surprises. Use variance to honor the Liskov Substitution Principle: let derived types replace base types where the contract permits — but never where mutation is involved. Variance is the compiler's way of saying 'I trust you, but I'll verify.'
// io.thecodeforge — csharp tutorial // Final sanity check: variance rules at a glance interface ICovariant<out T> { T Get(); } interface IContravariant<in T> { void Set(T val); } // Covariant: IEnumerable<string> → IEnumerable<object> IEnumerable<string> strings = new[] { "a" }; IEnumerable<object> objects = strings; // OK // Contravariant: Action<object> → Action<string> Action<object> broadAction = o => Console.WriteLine(o); Action<string> specificAction = broadAction; // OK // Invariant: IList<object> source can't be IList<string> // IList<string> strings2 = new List<object>(); // COMPILE ERROR
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.PowerShell: dotnet run --project <project> 2>&1 | Select-String -Pattern "ArrayTypeMismatchException" | bash: dotnet run --project <project> 2>&1 | grep "ArrayTypeMismatchException"In Visual Studio Immediate Window: `? array.GetType().GetElementType()` — compare the result against the runtime type of the element you are assigningIReadOnlyList<FinancialEvent> safeBuffer = new List<FinancialEvent>(batch);PowerShell: dotnet build /p:TreatWarningsAsErrors=false 2>&1 | Select-String -Pattern "CS1961" | bash: dotnet build /p:TreatWarningsAsErrors=false 2>&1 | grep "CS1961"PowerShell: Get-ChildItem -Recurse *.cs | Select-String -Pattern "out T" | bash: grep -rn "out T" --include="*.cs" .PowerShell: dotnet --version | bash: dotnet --version — .NET Framework 4.5+ and all .NET Core/.NET 5+ versions have variance on IComparer<T>In Immediate Window: `typeof(System.Collections.Generic.IComparer<>).GetGenericArguments()[0].GenericParameterAttributes` — look for Contravariant in the flagsinterface IMyComparer<in T> { int Compare(T x, T y); }PowerShell: Get-ChildItem -Recurse *.cs | Select-String -Pattern "\(Func<" | bash: grep -rn "(Func<" --include="*.cs" . — find all explicit delegate casts and audit each oneIn Immediate Window on the failing line: `? existingDelegate.Method.Name` — then reassign via method group: `Func<Animal> safe = existingDelegate.Method.CreateDelegate<Func<Animal>>(existingDelegate.Target)`(Func<Animal>)dogFunc with Func<Animal> f = dogMethod where dogMethod is the original method group, or wrap: Func<Animal> f = () => dogFunc()| Aspect | Covariance (out) | Contravariance (in) |
|---|---|---|
| Keyword | out | in |
| Assignment direction | Derived → Base (e.g. IProducer<Dog> → IProducer<Animal>) | Base → Derived (e.g. IProcessor<Animal> → IProcessor<Dog>) |
| T allowed in return types? | Yes — only in output positions | No — CS1962 compile error |
| T allowed in method parameters? | No — CS1961 compile error | Yes — only in input positions |
| Real-world role | Producers, factories, read-only sequences | Consumers, comparers, handlers, validators |
| BCL examples | IEnumerable<out T>, IReadOnlyList<out T>, Func<out TResult> | IComparer<in T>, Action<in T>, IEqualityComparer<in T> |
| Works on classes? | No — only interfaces and delegates; CS1960 on classes | No — only interfaces and delegates; CS1960 on classes |
| Array support? | Yes (unsound, CLR store check on every write — treat as deprecated) | Arrays are covariant only — contravariance does not apply to arrays |
| Compile-time safe? | Yes, fully verified by compiler | Yes, fully verified by compiler |
| Mutability requirement | Type must be read-only with respect to T | Type must be write-only (consume-only) with respect to T |
| Delegate instance casting | Not safe — cast Func<Dog> to Func<Animal> throws InvalidCastException at runtime | Not safe — cast Action<Animal> to Action<Dog> throws InvalidCastException at runtime |
| File | Command / Code | Purpose |
|---|---|---|
| VarianceMotivation.cs | namespace io.thecodeforge.covariance; | The Type Substitution Problem |
| CovariantProducer.cs | namespace io.thecodeforge.covariance; | Covariance With 'out' |
| ContravariantProcessor.cs | namespace io.thecodeforge.covariance; | Contravariance With 'in' |
| DelegateAndArrayVariance.cs | namespace io.thecodeforge.covariance; | Delegate Variance, Array Covariance, and the Hidden Runtime |
| VarianceErrors.cs | namespace io.thecodeforge.varianceerrors; | Common Compiler Errors and How to Fix Them |
| InvarianceTrap.cs | public interface IList | Invariance |
| EventBusVariance.cs | public interface IEventHandler | Real World Use Cases |
| ConstraintVariance.cs | interface IProducer | Variance in Generic Constraints |
| NestedVariance.cs | using System.Collections.Generic; | Variance in Nested Generics |
| VarianceBestPractices.cs | interface IProducer | Best Practices for Using Variance |
| VarianceRelatedTopics.cs | IEnumerable | Related Topics |
| EventBusExample.cs | public interface IEvent { } | Real-World Scenarios |
| VarianceCheck.cs | interface ICovariant | Conclusion |
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?
Why is List
Cat()) on the List<Animal> reference. The compiler sees List<Animal> and permits it. The runtime sees List<Dog> and has a problem — you've put a Cat into a list that can only hold Dogs. You'd get a runtime failure, which is exactly what the type system is supposed to prevent.
IEnumerable<T> sidesteps this entirely by being read-only. It only exposes a way to iterate — there is no Add, no Set, no mutation of any kind. The compiler can prove that no wrong-typed object can enter the collection through IEnumerable<T>, so covariance is provably safe.
This is also the distinction between IReadOnlyList<out T> (covariant, safe) and IList<T> (invariant, because it includes Add and the indexer setter). The read-only interface can be covariant precisely because the write operations do not exist on it.Array covariance has been in C# since version 1.0. What is the problem with it, what runtime exception can it cause, and what is the modern type-safe alternative?
Can you explain how delegate variance works in C#? Give an example of both covariance and contravariance with Func and Action. Where does it break?
in or out annotation needed. A method that returns Dog can be assigned to a Func<Animal> (covariance, return type flows with inheritance). A method that accepts Animal can be assigned to an Action<Dog> (contravariance, parameter type flows against inheritance). The compiler figures out compatibility from the method signature.
Where it breaks is the part that trips up even experienced engineers: variance applies to method group assignments, not to casting between delegate instances. If you have a Func<Dog> variable and try to cast it to Func<Animal>, the compiler may not stop you — but the CLR will throw InvalidCastException at runtime. Func<Dog> and Func<Animal> are different constructed generic types. There is no inheritance relationship between them. The cast simply doesn't work.
The fix is to always assign from a method group, not from an existing delegate variable. If you only have a delegate instance and not the original method, wrap it: Func<Animal> f = () => existingDogFunc(). One lambda, zero runtime exceptions. This pattern is worth adding explicitly to your team's coding standards — it's the kind of thing that bites once in production and then gets cargo-culted incorrectly for years afterward.Frequently Asked Questions
The clearest way to see the difference is through the direction of assignment. Covariance (the out keyword) lets you use a more derived type where a more general one is expected — IEnumerable<Dog> to IEnumerable<Animal>, for example. Contravariance (the in keyword) runs the other way: a more general handler can stand in for a more specific one — IComparer<Animal> to IComparer<Dog>. The rule that makes both safe is about data flow. out means T only ever exits the type (producers, factories, read-only sequences). in means T only ever enters the type (consumers, comparers, handlers). Both are verified at compile time. If the data needs to flow both ways, the type parameter has to be invariant — no keyword, no substitution in either direction.
Because List<T> supports both reading and writing, and covariance on a mutable type is unsound. If that assignment were allowed, you could call Add(new Cat()) through the List<Animal> reference — the compiler would permit it, but the runtime would be looking at a List<Dog>. The result is a corrupted collection with no compile-time warning. If you only need to read from the collection, use IEnumerable<Dog> or IReadOnlyList<Dog> instead — both are covariant (out T) and the assignment to their Animal counterparts works cleanly without any cast. If you need a mutable collection that genuinely accepts multiple animal types, declare it as List<Animal> from the start.
Only interfaces and delegates — never classes or structs. If you try to add in or out to a class type parameter, the compiler gives you CS1960. The reason is that classes can hold mutable state, which makes variance unsound for the same reason List<T> can't be covariant. The solution is to extract an interface from your class, apply the variance keyword to the interface, and leave the concrete class invariant. The class implements the interface without needing a variance annotation of its own.
They look similar but have very different safety profiles. Array covariance — Dog[] to Animal[] — is a legacy feature that allows the assignment but gives you no protection against writes. If you write an incompatible type through the widened reference, the CLR throws ArrayTypeMismatchException at runtime. Worse, the store check that catches this fires on every write through a widened array reference, even the safe ones — so you pay a performance cost whether or not anything bad ever happens. Generic covariance through IReadOnlyList<out T> is sound by design: the interface has no write operations, so the risk is structurally eliminated. Dog[] is directly assignable to IReadOnlyList<Animal>, no unsafe cast needed. In any new code you write, IReadOnlyList<T> is the correct choice for covariant read-only access. Treat array covariance as a deprecated pattern.
Add out before the type parameter for covariance or in for contravariance in the interface declaration. For example: interface IProducer<out T> { T or Produce(); }interface IConsumer<in T> { void Consume(T item); }. The compiler then enforces the contract: with out, T can only appear in return types and property getters; with in, T can only appear in method parameters. CS1961 fires if you violate covariance, CS1962 if you violate contravariance. You cannot use these modifiers on classes or structs — CS1960. One practical note: only add out or in when you're confident the interface will stay purely a producer or purely a consumer. Removing a variance modifier later is a breaking change for every caller who relied on it.
This is probably the most common variance mistake in production C# code — and the compiler doesn't always save you from it. Delegate variance applies to method group assignments, not to casting between delegate instances. A direct cast from Func<Dog> to Func<Animal> will throw InvalidCastException at runtime, even though the types are variance-compatible. Func<Dog> and Func<Animal> are different constructed generic types with no inheritance relationship between them. The correct approach is to assign from a method group: Func<Animal> f = MyDogMethod where MyDogMethod returns Dog. If you only have an existing delegate variable and not the original method, wrap it: Func<Animal> f = () => existingDogFunc(). One extra lambda allocation is infinitely cheaper than an InvalidCastException in production.
20+ years shipping production .NET services in enterprise systems. Notes here come from systems that actually shipped.
That's OOP in C#. Mark it forged?
14 min read · try the examples if you haven't