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#.
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 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:
out parameter. The member produces a T for the caller.in) parameter. The member consumes a T supplied by the caller.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."
T GetEnumerator().Current { get; } — output onlyT as inputIEnumerable<Animal> can only ever receive values out — and every value really is an Animal (it's a Dog, which is one)void Add(T item) — T in an INPUT positionout T illegalIList<Animal> view over a real List<Dog> would let you Add(new Cat()) — corrupting the underlying Dog listC# 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.
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.
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.
out/in conceptout/in, checked per-member// A read-only "factory" abstraction — only ever produces a T
public interface IProducer<T>
{
T Produce();
}
Produce() returns T — an output position, and the only member. Every use of T in this interface is output-only.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
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
T Peek(); to IConsumer<in T> and it fails to compile immediately — the compiler catches the direction violation the instant you write it, exactly the guarantee that makes this whole system trustworthy.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.
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.
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.
T is physically stored. Checking "does T only appear in output positions across every member" is a purely syntactic, structural question the compiler can answer just by reading the interface declaration.T — real storage that must support both being written into (during construction, mutation) and read back out. There's no member-signature-level place to even ask "is this field only ever an input or only ever an output" — storage, by its nature, supports both directions simultaneously.I<out T> is valid, every member that could observably interact with T only produces values of type T — never consumes one supplied by the caller. Widening a reference from I<Dog> to I<Animal> then changes nothing about what values could ever come out: a Dog genuinely is an Animal, so every value the interface could ever produce is still valid to hand back as an Animal. No operation reachable through the widened reference could ever put a non-Dog into the underlying object, because no member accepts T as input at all.out/in concept) by years, and removing it would be a breaking change to decades of existing code that relies on it compiling (even if that code is careful never to actually write the "wrong" element type through a widened array reference). The CLR instead keeps the runtime ArrayTypeMismatchException check as the safety net array covariance has always needed — a pragmatic trade-off for backward compatibility, not evidence that unrestricted covariance is actually a good design.List<T>?" — arrays chose convenience over safety, once, before the tools to do better existedArray 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.
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."
ArrayTypeMismatchException check has a real, if small, performance costBecause 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.
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.
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.
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.
out T) when it's genuinely read-only shaped — a source, a factory, a producer that only ever hands back T.in T) when it's genuinely a sink or handler — something that only ever consumes a T supplied to it, like a comparer, a validator, or an event handler.List<T>/generic collections and methods where the compiler, not ArrayTypeMismatchException, catches a type mistake.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.
out T is sound because the compiler proves T is used exclusively in output positions across every member — nothing can write an incompatible value in through a widened reference.ArrayTypeMismatchException at runtime; it predates the safer, compile-time-checked generic model.T against the direction you want — the compiler enforces it exhaustively, and rejects the interface declaration outright if any member violates it.T (like IList<T>), it stays invariant by necessity, not by oversight.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?
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); }?
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?
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?
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.