📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

A colleague cannot pass a List<Dog> to a method that takes List<Animal>, cannot assign Dictionary<string, List<string>> to IDictionary<string, IEnumerable<string>>, and cannot return Task<Dog> where Task<Animal> is expected. Explain invariance and how you would fix each case.

Generic invariance: why most generic types are invariant, how to diagnose CS1503/CS0266 conversion errors, and the fixes (covariant interfaces, generic methods with constraints, projection).

Mid-Level Debugging Intermediate csharp debugging generics invariance variance

🎯 Key points

  • Invariance: `G<Derived>` and `G<Base>` are unrelated types, with no conversion either way
  • All generic classes and structs are invariant (`List<T>`, `Dictionary<,>`, `Task<T>`); only interfaces and delegates marked `in`/`out` are variant
  • Invariance is required for type safety whenever T is both read and written (`List<T>.Add`)
  • Fix 1: accept a covariant interface (`IEnumerable<Animal>`, `IReadOnlyList<Animal>`)
  • Fix 2: make the method generic with a constraint (`void Feed<T>(List<T> animals) where T : Animal`)
  • Fix 3: project or wrap explicitly (`Select`, `ToDictionary`, `await` and return)
  • Nested types multiply the problem: `IDictionary<string, IEnumerable<string>>` is invariant in TValue

⏱ 30-second answer

These all fail because the types are invariant: List<Dog> and List<Animal> are unrelated even though Dog is an Animal. Classes like List<T>, Dictionary<,> and Task<T> are always invariant, and IDictionary<,> is invariant because it reads and writes values. I fix it by accepting a covariant interface like IReadOnlyList<Animal>, by making the method generic with where T : Animal, or by projecting explicitly, for example awaiting the task and returning the result.

🗣 Full answer (senior)

How I would answer this in an interview

All three failures have the same root cause: invariance. A generic type G<T> is invariant unless it's an interface or delegate whose type parameter is explicitly marked out (covariant) or in (contravariant). Invariant means G<Dog> and G<Animal> are unrelated types, even though Dog : Animal. I'd explain why each case is invariant for good reasons, then fix each one.

1. Why invariance exists

Variance is only safe when a type parameter is used in one direction. If T is both read and written, allowing conversion would let you put the wrong type in:

List<Animal> animals = new List<Dog>();   // imagine allowed
animals.Add(new Cat());                   // a Cat inside a List<Dog> - broken

So List<T>, IList<T>, ICollection<T>, IDictionary<K,V> are invariant, and classes (including Task<T>) can never be variant in .NET.

2. Case 1: List<Dog> to a method taking List<Animal>

Fix depends on what the method does:

  • Only reads → change the parameter to a covariant interface:
void Feed(IEnumerable<Animal> animals) { ... }          // or IReadOnlyList<Animal> / IReadOnlyCollection<Animal>
Feed(dogs);                                              // List<Dog> converts via covariance
  • Needs to add items → make it generic so the list keeps its real type:
void AddRescued<T>(List<T> animals, Func<T> create) where T : Animal => animals.Add(create());
  • Can't change the method (third-party) → convert explicitly, accepting a copy: Feed(dogs.Cast<Animal>().ToList()) or dogs.ConvertAll<Animal>(d => d). Note the method then modifies the copy, not dogs.

3. Case 2: Dictionary<string, List<string>> to IDictionary<string, IEnumerable<string>>

IDictionary<TKey, TValue> is invariant in both parameters (you can read and write values). Even IReadOnlyDictionary<TKey, TValue> is invariant in TValue, because TryGetValue(TKey, out TValue) uses TValue in an out parameter (which is invariant).

Fixes:

  • Change the declaration to the type you need from the start:
var index = new Dictionary<string, IEnumerable<string>>();
index["au"] = new List<string> { "Sydney", "Perth" };   // List<string> converts to IEnumerable<string> per value
  • Project into a new dictionary when you only need a read-only view:
IReadOnlyDictionary<string, IEnumerable<string>> view = source.ToDictionary(kv => kv.Key, kv => (IEnumerable<string>)kv.Value);
  • Change the consuming method to be generic over the value type: void Print<TValues>(IReadOnlyDictionary<string, TValues> map) where TValues : IEnumerable<string>.
  • Or accept IEnumerable<KeyValuePair<string, List<string>>> / a custom read-only interface that can be covariant.

4. Case 3: return Task<Dog> where Task<Animal> is expected

Task<T> is a class → invariant.

public Task<Animal> GetAnimalAsync() => GetDogAsync();          // CS0029: cannot convert Task<Dog> to Task<Animal>

Fixes:

public async Task<Animal> GetAnimalAsync() => await GetDogAsync();            // simplest; small state machine cost

public Task<Animal> GetAnimalAsync() => GetDogAsync().ContinueWith(t => (Animal)t.Result,
    TaskContinuationOptions.ExecuteSynchronously);                             // avoid: exception/cancellation handling is subtle

In practice, async/await is the right answer. If you control the API, consider whether the method should return Task<Dog> itself, or whether a generic method Task<T> GetAsync<T>() where T : Animal fits.

5. How I'd explain it to the colleague

"Generic types don't inherit from each other based on their arguments. Only interfaces/delegates marked out or in support conversion, and only in the safe direction. For read-only use, accept IEnumerable<T>/IReadOnlyList<T>; for tasks, await; for dictionaries, declare the value type you need or project."

6. A production example

In a pricing API, a method ApplyRules(List<PricingRule> rules) forced callers with List<VolumeDiscountRule> to copy the list for every request. Changing the parameter to IReadOnlyList<PricingRule> removed the copies. Another method returned Task<Product> from a cache lookup typed Task<CachedProduct>; we made it async and awaited, which was clearer than conversion helpers.

🔍 What the interviewer is checking

A practical debugging question that checks variance understanding through real compiler errors.

Green flags: explains the Add-a-Cat argument; distinguishes read-only from read-write callees; offers generic-method fixes; knows Task<T> is a class.

Red flags: proposes casts; thinks IDictionary or IList is covariant; copies collections as the only answer.

📚 Learn in depth

1. Variance summary table

Type Variance
List<T>, Dictionary<K,V>, HashSet<T>, Task<T>, Lazy<T>, any class/struct Invariant (classes and structs can't be variant)
IList<T>, ICollection<T>, IDictionary<K,V>, ISet<T> Invariant (read + write)
IReadOnlyDictionary<K,V> Invariant (uses out TValue parameter)
IEnumerable<out T>, IReadOnlyList<out T>, IReadOnlyCollection<out T>, IAsyncEnumerable<out T> Covariant
IComparer<in T>, IEqualityComparer<in T>, Action<in T> Contravariant
Func<in T, out TResult> Mixed

2. Why out parameters are invariant

An out TValue parameter is written by the callee (output) but also represents a storage location of the caller's type. The CLR implements out as ref — a managed pointer to caller storage — so the callee could also read it, and the caller's variable must be exactly TValue. That makes it invariant, which is why IReadOnlyDictionary<K, out V> isn't possible with TryGetValue. Some proposals suggest a covariant IReadOnlyDictionary view without TryGetValue; you can define your own.

3. Conversions that copy vs those that don't

  • Variance conversions (IEnumerable<Dog> → IEnumerable<Animal>) are reference conversions: same object, no copy, O(1).
  • Cast<T>(), ConvertAll, ToDictionary create new collections: O(n) time and memory, and changes to the copy aren't reflected in the original.

4. Generic methods as the escape hatch

Making the consumer generic preserves the exact type, so no conversion is needed and writes remain type-safe:

public static void Normalise<T>(IList<T> items) where T : Animal { foreach (var i in items) i.Name = i.Name.Trim(); }

This works for List<Dog>, List<Cat> and List<Animal> alike.

5. Tasks and covariance alternatives

  • async/await re-wraps the result in a new Task<Animal> (a small allocation and state machine).
  • ValueTask<T> is also invariant (struct).
  • Some libraries define covariant awaitable interfaces, but they don't interoperate with the BCL as smoothly. Prefer await.

6. Arrays as an exception

Arrays are covariant for reference types (Dog[] converts to Animal[]) but unsafe: writing a Cat into an Animal[] that's really Dog[] throws ArrayTypeMismatchException. Don't use arrays to work around generic invariance.

Try it yourself

  1. Reproduce each of the three compile errors.
  2. Fix case 1 with IReadOnlyList<Animal> and with a generic method; confirm no copy happens in the first.
  3. Fix case 2 by changing the declaration and by projecting.
  4. Fix case 3 with async/await and measure the allocation with BenchmarkDotNet.

💻 Code

using System.Collections.Immutable;

namespace Generics.Invariance;

public abstract class Animal { public abstract string Name { get; } }
public sealed class Dog(string name) : Animal { public override string Name => name; }

public static class Kennel
{
    // Read only: covariant interface accepts List<Dog>
    public static void PrintAll(IReadOnlyList<Animal> animals)
    {
        foreach (var a in animals) Console.WriteLine(a.Name);
    }

    // Writes the caller's element type: generic with constraint
    public static void AddRescued<T>(List<T> animals, Func<string, T> create) where T : Animal
        => animals.Add(create("Rescued"));

    // Dictionary values: generic over the value type
    public static int CountErrors<TValues>(IReadOnlyDictionary<string, TValues> errors)
        where TValues : IEnumerable<string>
        => errors.Values.Sum(v => v.Count());

    public static Task<Dog> FetchDogAsync() => Task.FromResult(new Dog("Rex"));

    // Task<T> is invariant: await and return
    public static async Task<Animal> FetchAnimalAsync() => await FetchDogAsync();
}

public static class Program
{
    public static async Task Main()
    {
        List<Dog> dogs = [new("Bluey"), new("Bingo")];

        Kennel.PrintAll(dogs);
        Kennel.AddRescued(dogs, name => new Dog(name));

        var errors = new Dictionary<string, List<string>> { ["email"] = ["Required", "Invalid format"] };
        Console.WriteLine(Kennel.CountErrors(errors));

        Animal animal = await Kennel.FetchAnimalAsync();
        Console.WriteLine(animal.Name);

        ImmutableArray<Dog> immutableDogs = [.. dogs];
        ImmutableArray<Animal> immutableAnimals = ImmutableArray<Animal>.CastUp(immutableDogs); // zero copy
        Console.WriteLine(immutableAnimals.Length);
    }
}

⚠ Common mistakes

1. Casting through object

What happens: (List<Animal>)(object)dogs. Why it is wrong: throws InvalidCastException — they're unrelated types. Do instead: use covariant interfaces or generic methods.

2. Copying silently and expecting updates

What happens: Feed(dogs.Cast<Animal>().ToList()) where Feed adds items. Why it is wrong: additions go to the copy, not the original list. Do instead: make the method generic, or return the new items.

3. Using arrays to dodge invariance

What happens: passing Dog[] as Animal[]. Why it is wrong: unsafe array covariance can throw on writes. Do instead: use read-only generic interfaces.

4. ContinueWith to convert tasks

What happens: task.ContinueWith(t => (Animal)t.Result). Why it is wrong: wraps exceptions in AggregateException, mishandles cancellation, and may schedule on unexpected schedulers. Do instead: await in an async method.

5. Assuming IReadOnlyDictionary is covariant

What happens: expecting IReadOnlyDictionary<string, List<string>> to convert to IReadOnlyDictionary<string, IEnumerable<string>>. Why it is wrong: TValue is invariant because of TryGetValue(out TValue). Do instead: declare the needed value type or project.

6. Accepting concrete collection types in APIs

What happens: parameters typed List<T> or Dictionary<K,V>. Why it is wrong: blocks variance and forces copies. Do instead: accept the least capable interface needed.

7. Over-using object

What happens: changing parameters to IEnumerable<object> to accept anything. Why it is wrong: loses type information and requires casts. Do instead: use a common base type or generics.

8. Not explaining the safety reason

What happens: treating invariance as an arbitrary limitation. Why it is wrong: leads to unsafe workarounds. Do instead: understand that invariance prevents writing the wrong type into a collection.

🏛 Senior insight: trade-offs

Invariance errors are a design signal: the method signature asks for more capability than it uses. When a method only reads, a read-only covariant interface is the honest contract and spares every caller a copy. When it writes, a constrained generic method keeps the caller's element type. I also know the handful of structural escape hatches (ImmutableArray.CastUp, return await) so I never resort to run-time casts.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate suggests accepting IReadOnlyList<Animal>. Why: The main fix relies on covariant read-only interfaces.
    How to answer

    Covariance means a generic type keeps the direction of inheritance: because string is an object, IEnumerable<string> can be used as IEnumerable<object>. You declare it with out T, only on interfaces and delegates, and T may only be returned, never accepted. That is why IReadOnlyList<T> is covariant but List<T> is not: List<object>.Add(42) would break a list of strings. It only works with reference types, so IEnumerable<int> does not convert to IEnumerable<object>.

  2. When: Candidate says arrays behave differently. Why: Arrays are the one covariant collection; tests awareness of its run-time cost.
    How to answer

    Reference-type arrays in .NET are covariant: a string[] can be treated as an object[]. The array still really holds strings, so the runtime checks every write, and storing 42 fails with ArrayTypeMismatchException. The feature exists for historical reasons (Java had it, and .NET 1.0 had no generics) and it makes array writes type-unsafe at compile time. It also adds a hidden type check to stores into arrays of non-sealed reference types, and new Span<object>(stringArray) throws. The safe alternative is generic variance: IEnumerable<out T> and IReadOnlyList<out T> are covariant because they only return values, while List<T> is invariant, so the compiler catches the mistake.

  3. When: Candidate proposes Feed<T>(List<T>) where T : Animal. Why: The constrained generic method fix needs fluent generic method design.
    How to answer

    A generic method has its own type parameters, for example static T Max<T>(T a, T b) where T : IComparable<T>. The compiler usually infers T from the arguments, so you call Max(3, 7) without writing <int>. Inference only looks at arguments, never the return type, and it is all or nothing, so for something like Deserialize<T>(string json) you must write the type. I make a method generic when only that operation depends on the type, and the class generic when the object holds typed state.

🛠 Real-world problems: how you handle them

Validation helper forced conversions in every endpoint
Situation
An ASP.NET Core API had a shared `ValidationHelper.Add(IDictionary<string, IEnumerable<string>> errors)`. Endpoints held `Dictionary<string, string[]>` from FluentValidation and converted them on every request.
Signals
Repeated `ToDictionary(k => k.Key, v => (IEnumerable<string>)v.Value)` in PRs, plus a bug where errors added by the helper never reached the response because they were added to the converted copy.
Investigation
Reproduced the missing-errors bug in a test; saw that the helper mutated the converted dictionary while the endpoint returned the original.
Resolution
Changed the helper to accept `IDictionary<string, string[]>` (the type `ValidationProblemDetails.Errors` uses) and removed every conversion.
Prevention
Added an API review checklist item on parameter types and a test that asserts helper-added errors appear in the ProblemDetails response.

How to tell it (STAR)

**S:** Some validation errors silently disappeared from API responses. **T:** Find and fix the root cause. **A:** I traced it to invariance: callers copied their dictionaries to satisfy the helper's parameter type, so the helper wrote to a copy. I aligned the helper with the real type and removed the conversions. **R:** The bug was fixed for all [number] endpoints and the code got simpler.

↩ Usually asked after

More in Generics

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me