← Open in the full interactive course (progress tracking, search & more)

object[] arr = new string[3]; arr[0] = 42; compiles cleanly and throws at runtime. That single line is a 25-year-old wart baked into the CLR — and understanding exactly why it's unsafe is what makes covariant generics finally click.

You know from Intermediate that IEnumerable<Dog> can widen to IEnumerable<Animal>, and IComparer<Animal> can narrow to IComparer<Dog>. You know out and in are the annotations that make each one legal. What that lesson deliberately deferred was the actual proof of why those rules are sound — and a genuinely surprising fact: C# already had a covariance-like feature before generics even existed, and it's unsound. It's called array covariance, it's still in the language today for backward compatibility, and it can throw a runtime exception from code that looks completely unremarkable.

In this lesson: the formal reasoning behind why out/read-only positions are safe and a hypothetical covariant IList<out T> would not be, array covariance as a real (and non-generic) legacy safety hole, writing your own variant interface, and the precise scope of where variance does and doesn't apply in C#.

What Is It?

The Quick Recap

Covariance (out T) lets a more derived type argument substitute for a less derived one — IEnumerable<Dog> is an IEnumerable<Animal>. Contravariance (in T) works the other direction — IComparer<Animal> is an IComparer<Dog>. Both only apply to generic interfaces and delegates, never classes or structs.

The Precise Reason: Positions, Not Vibes

The rule isn't "covariance is for reading, contravariance is for writing" as a rough intuition — it's a provable, structural fact about where T is allowed to appear in a member's signature:

A generic interface can only be marked out T if every single member, across the entire interface, uses T exclusively in output positions. The compiler checks this exhaustively at the interface declaration — not by convention, but as a hard compile-time rule. That's what makes the resulting variance provably safe rather than merely "usually fine."

Why IEnumerable<out T> is sound

Why a covariant IList<out T> would NOT be sound

Why Does It Exist?

The Problem — A Real Precedent for What Happens Without the Rule

C# didn't invent this caution from nothing — it had already shipped an unsafe version of covariance once, for arrays, back in C# 1.0, before generics existed at all (added in C# 2.0). Arrays have always allowed a derived-to-base widening conversion with no restriction on read vs. write positions:

string[] strings = new string[3];
object[] objects = strings;      //  compiles — arrays are covariant, unconditionally

objects[0] = 42;                 //  ALSO compiles — object[] accepts any object, including an int (boxed)
//  ArrayTypeMismatchException at runtime — the actual array is a string[], and 42 isn't a string

This is real, documented, and still true in current .NET — arrays are covariant for every reference-element-type array, with no out/in-style restriction, and the CLR only catches the violation with a runtime type check on every array-element write, not at compile time.

The Need

When generics arrived in C# 2.0, and generic variance in C# 4.0, the language designers had exactly this precedent as a cautionary example. They needed a way to offer the genuine convenience of covariance/contravariance without repeating the array mistake — a rule that could be checked and proven safe entirely at compile time, with zero runtime type-checking overhead and zero possibility of a runtime type-mismatch exception.

The Solution

Restrict variance to generic interfaces and delegates (which have no storage of their own — only a contract), and require the direction-of-use rule (output-only for out, input-only for in) to be checked exhaustively at the interface's declaration. Where arrays trade a compile-time guarantee for a runtime check that can fail, generic interface variance makes the unsafe case simply not compile in the first place — IList<T>, which both reads and writes T, cannot be marked out or in at all, so the array bug's generic equivalent is caught before the program ever runs.

Big Picture

TWO COVARIANCE STORIES, TWO VERY DIFFERENT SAFETY OUTCOMES

Arrays (legacy, C# 1.0)

Covariant unconditionally, no out/in concept
Compiler allows the unsafe write
CLR catches it with a runtime check
Can throw ArrayTypeMismatchException

Generic interfaces (C# 4.0+)

Covariant/contravariant only with out/in, checked per-member
Compiler refuses the unsafe member outright
No runtime check needed — nothing unsafe can compile
Cannot throw a type-mismatch exception from this

How It Works

WRITING YOUR OWN VARIANT GENERIC INTERFACE — STEP BY STEP
1. START WITH THE CONTRACT YOU NEED
// A read-only "factory" abstraction — only ever produces a T
public interface IProducer<T>
{
    T Produce();
}
2. CHECK EVERY MEMBER'S DIRECTION OF T
3. ADD out — THE COMPILER VERIFIES IT FOR YOU
public interface IProducer<out T>
{
    T Produce(); //  compiles — T only ever appears in an output position
}

public class DogProducer : IProducer<Dog> { public Dog Produce() => new Dog(); }

IProducer<Dog> dogProducer = new DogProducer();
IProducer<Animal> animalProducer = dogProducer; //  covariance — widening is safe
4. THE SAME PROCESS, FOR CONTRAVARIANCE
public interface IConsumer<in T>
{
    void Consume(T item); // T only ever an input — legal for 'in'
}

public class AnimalConsumer : IConsumer<Animal> { public void Consume(Animal a) { } }

IConsumer<Animal> animalConsumer = new AnimalConsumer();
IConsumer<Dog> dogConsumer = animalConsumer; //  contravariance — narrowing is safe

Simple Example

Reproducing the array covariance hole exactly, then showing why the generic equivalent can't happen:

//  Arrays: this compiles, and blows up at runtime
public class Animal { }
public class Dog : Animal { }
public class Cat : Animal { }

Animal[] animals = new Dog[3]; // legal — array covariance, no restriction at all
animals[0] = new Cat();        // compiles! Cat IS an Animal, as far as the compiler can see
//  System.ArrayTypeMismatchException at runtime — the real array only holds Dogs

//  Generics: the equivalent mistake simply cannot compile
List<Dog> dogs = new List<Dog>();
// List<Animal> genericAnimals = dogs;  //  compile error — List<T> is invariant, full stop
// IList<Animal> also would not compile — IList<T> has no out/in annotation, for the same reason
IEnumerable<Animal> readOnlyView = dogs; //  fine — IEnumerable<out T> is read-only, provably safe

Meaning: The array version trades a compile-time guarantee for a runtime check that can fail in production. The generic interface version refuses to compile the moment you'd need write access through a widened reference — the mistake is caught before the program ever runs.

Real-World Example

A notification pipeline is a natural home for both directions of variance at once — a read-only source of events (covariant) and a general-purpose handler that should work for more specific event types too (contravariant).

public class Notification { public string Message { get; init; } = ""; }
public class OrderShippedNotification : Notification { public int OrderId { get; init; } }

// Covariant source — only ever hands notifications OUT
public interface INotificationSource<out T> where T : Notification
{
    T GetNext();
}

// Contravariant handler — only ever accepts notifications IN
public interface INotificationHandler<in T> where T : Notification
{
    void Handle(T notification);
}

public class OrderShippedSource : INotificationSource<OrderShippedNotification>
{
    public OrderShippedNotification GetNext() => new() { Message = "Shipped", OrderId = 42 };
}

public class GeneralNotificationHandler : INotificationHandler<Notification>
{
    public void Handle(Notification n) => Console.WriteLine($"Logging: {n.Message}");
}

// Usage — both directions of variance doing real work:
INotificationSource<OrderShippedNotification> shippedSource = new OrderShippedSource();
INotificationSource<Notification> genericSource = shippedSource; //  covariance

INotificationHandler<Notification> generalHandler = new GeneralNotificationHandler();
INotificationHandler<OrderShippedNotification> shippedHandler = generalHandler; //  contravariance

shippedHandler.Handle(shippedSource.GetNext()); // "Logging: Shipped"

The general-purpose handler, written once against the base Notification type, is reusable for any specific notification subtype without writing per-subtype handlers — exactly the kind of design variance is meant to enable.

Analogy

A building inspector who checks every doorway before opening it

Array covariance is like a building where every door is unlocked by default, and a security guard (the runtime type check) stands behind each one, ready to physically block anyone who tries to carry the wrong item through. It mostly works — but only because someone's standing there every single time, and if they're ever not paying close attention (an edge case the CLR's own runtime check doesn't hit for every array operation), something wrong gets through. It's also just wasted effort on every single legitimate pass-through, since most doors are only ever used correctly.

Generic interface variance is like an inspector who, before the building is even opened for business, walks the entire blueprint and certifies: "this door is architecturally impossible to misuse — it physically cannot swing the wrong way." Once that certification is granted (out T/in T compiles successfully), no guard is needed at that door ever again, because the mistake literally cannot occur — not "is checked for," but structurally ruled out.

Under the Hood

WHY ONLY INTERFACES AND DELEGATES CAN BE VARIANT — THE FULL PICTURE
1. VARIANCE IS A COMPILE-TIME CHECK OVER A CONTRACT, NOT OVER STORAGE
2. WHY THE COMPILER CAN PROVE covariant ASSIGNMENT IS SAFE — THE FORMAL ARGUMENT
3. WHY ARRAYS COULDN'T GET THIS SAME TREATMENT RETROACTIVELY

Common Confusion

1. "If arrays can be covariant unconditionally, why can't List<T>?" — arrays chose convenience over safety, once, before the tools to do better existed

Array covariance isn't proof that unconditional covariance is fine — it's a documented, acknowledged design compromise from before C# had a safer alternative, kept today only for backward compatibility. Generic variance was designed after this lesson had already been learned, which is exactly why it's restricted to the provably-safe cases.

2. Variance is about generic interfaces/delegates specifically — not "generics" as a blanket category

A generic class or struct is always invariant, with no way to opt in, regardless of how you use its type parameter internally. Variance is a feature of the interface/delegate contract system specifically, not of generics in general — this is worth restating because it's easy to over-generalize "generics can be variant" into "any generic type can be variant."

3. The runtime ArrayTypeMismatchException check has a real, if small, performance cost

Because array covariance can't be verified at compile time, every write to a reference-type array element goes through an implicit runtime type check (a "covariant array store check") to catch exactly the scenario shown above. It's usually negligible, but it is genuinely extra work the CLR performs on every such write — one more reason the interface-variance design, needing zero such checks, is the better long-term model.

Common Mistakes

Mistake 1 — Widening an array reference and then writing through it

void PopulateWithDefault(object[] items, object defaultValue)
{
    for (int i = 0; i < items.Length; i++) items[i] = defaultValue;
}

string[] names = new string[5];
PopulateWithDefault(names, "N/A"); // fine — "N/A" is a string
PopulateWithDefault(names, 42);    //  ArrayTypeMismatchException — 42 isn't a string

Avoid designing APIs that accept a covariant array reference for writing at all; accept the specific element type, or use a generic method constrained appropriately, so the compiler — not a runtime exception — catches the mistake.

Mistake 2 — Trying to force a mixed read/write interface to be variant

public interface IRepository<out T>   //  compile error
{
    T GetById(int id);
    void Add(T item);   // T as an input — violates 'out'
}

Split responsibilities into a read-only, genuinely covariant interface and a separate read/write (invariant) interface if you need both — don't fight the compiler on this; the error is telling you the design itself is unsafe as a single variant contract, exactly as it should.

Mistake 3 — Assuming a covariant interface reference is somehow "read-only" at the object level

Believing that once you have an IEnumerable<Animal> view over a List<Dog>, the underlying list itself becomes immutable. Variance only restricts what's reachable through that specific reference — if you still hold the original List<Dog> reference elsewhere, it remains fully mutable through that reference; nothing about the object itself changed.

When Should I Use It?

Rule of thumb: If you can honestly say a generic interface member "only ever hands out T," mark it out. If it "only ever takes T in," mark it in. If you're not sure, leave it invariant — the compiler will tell you immediately, and for free, whether your out/in attempt is actually safe.

Mental Model

Array covariance = unconditional, unchecked at compile time, backed by a runtime safety net that can fail — a legacy design, kept only for compatibility.
Generic interface variance = conditional on a strict, compiler-verified "output-only" or "input-only" rule across every member — the unsafe case cannot compile at all.
Only interfaces and delegates can be variant, because only a pure contract (no storage) can be checked this way.

Remember: the safety of covariance/contravariance was never a slogan ("reading is safe, writing isn't") — it's a provable fact about where a type parameter is allowed to appear in a signature, verified by the compiler for every member of the interface, every time.

Key Takeaway


Check Your Understanding

You've seen the formal reasoning behind variance safety, the legacy array covariance hole, and how to write your own variant interfaces. Let's check your understanding.

1. What does object[] arr = new string[3]; arr[0] = 42; actually do?

Show answer

Correct: B

Why B is correct: This is the exact array covariance hole this lesson describes — arrays widen unconditionally at compile time, but the CLR must catch invalid writes with a runtime type check, which throws here.

Why A is incorrect: Unlike generic classes such as List<T>, arrays have always been covariant — this is precisely the legacy design this lesson contrasts with the safer generic model.

Why C is incorrect: There's no automatic conversion of 42 into a string — the runtime check exists specifically to reject this kind of mismatch, and it does so by throwing.

Why D is incorrect: The first line compiles successfully — array covariance is unconditional; the failure happens later, at runtime, on the write.

Reinforcement: Array covariance trades a compile-time guarantee for a runtime safety net that can fail — the exact opposite trade-off generic interface variance makes.

2. Why does the compiler reject public interface IRepository<out T> { T GetById(int id); void Add(T item); }?

Show answer

Correct: B

Why B is correct: The compiler checks every single member of an out T interface for output-only usage of T. Add(T item) accepts T as a parameter — an input position — which alone disqualifies the whole interface from being covariant.

Why A is incorrect: Interfaces can have any number of members; that's not the issue here.

Why C is incorrect: The two method names don't conflict at all — this isn't a naming problem.

Why D is incorrect: There's no such one-method limit — plenty of real covariant interfaces, like IEnumerable<T> together with its enumerator, have multiple members; what matters is that every one of them respects the direction.

Reinforcement: A single input-position use of T anywhere in the interface is enough to disqualify it from being out — the compiler checks exhaustively, not just the "main" method.

3. Why can a generic class like List<T> never be declared out T, even in principle, the way a well-designed interface can?

Show answer

Correct: B

Why B is correct: As explained in "Under the Hood," variance is checkable specifically because an interface is a pure contract with no storage — every use of T appears explicitly in a member signature the compiler can inspect. A class's actual fields inherently support both reading and writing, which structurally can't satisfy an "output-only" requirement.

Why A is incorrect: The restriction isn't an arbitrary syntax ban — it reflects a genuine structural reason, tied to storage, as explained above.

Why C is incorrect: List<T> does implement several interfaces (like IList<T>, IEnumerable<T>); that's unrelated to why the class itself can't be variant.

Why D is incorrect: Interfaces aren't value types — they're reference types just like classes; the class-vs-interface distinction here is about storage and contract, not the value/reference type split.

Reinforcement: Variance is checkable specifically because interfaces have no backing storage — every generic class with real fields is invariant for the same structural reason.

4. A real production API accepts object[] items and writes a supplied default value into every slot. What's the risk with this design, and what's the safer alternative?

Show answer

Correct: B

Why B is correct: This is exactly the "Common Mistakes" scenario — accepting a covariant array reference for writing invites a real runtime exception when the caller's actual array is more specific than object[]. A generic method, checked at compile time, avoids the whole category of bug.

Why A is incorrect: This is precisely wrong — the declared parameter type (object[]) and the actual runtime array type (e.g. string[]) can and do differ, which is the entire source of the risk.

Why C is incorrect: It's a real, documented, and reproducible exception — the example in "Common Mistakes" demonstrates it directly, not as a theoretical curiosity.

Why D is incorrect: readonly on a parameter isn't valid C# for this purpose, and even if some read-only mechanism were used, it wouldn't address the specific write happening inside this method — the fix is to change the API shape, not add a modifier.

Reinforcement: Prefer generic, type-checked APIs over covariant array parameters whenever writing is involved — it moves the safety net from runtime back to compile time.

You now understand not just that variance works, but exactly why it's provably safe — and you've seen the real, historical counter-example that makes the contrast concrete.


dotnetmadeeasy.com — Learn C# and .NET, the right way.