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

📚 Interview question

What is contravariance in C# generics? Explain the in modifier, why an IComparer<object> or Action<object> can be used where IComparer<string> or Action<string> is expected, which BCL types are contravariant, and the gotcha with combining variant delegates.

Generic contravariance: in T, input-only positions, BCL comparers and delegates, and the run-time failure when combining variance-converted delegates.

Mid-Level Theory Advanced contravariance csharp delegates generics variance

🎯 Key points

  • Contravariance reverses assignment direction: `IComparer<Base>` is assignable to `IComparer<Derived>`
  • Declared with `in T` on interfaces and delegates; T may appear only in input positions
  • Intuition: something that can handle any Animal can handle a Dog
  • BCL examples: `IComparer<in T>`, `IEqualityComparer<in T>`, `IComparable<in T>`, `Action<in T>`, `Predicate<in T>`, `Comparison<in T>`, `IObserver<in T>`, the inputs of `Func<in T, out TResult>`
  • Reference-type arguments only, like covariance
  • `out` and `ref` parameters cannot use a variant T, because they are by-reference both ways
  • Combining delegates whose run-time types differ (after a variance conversion) throws ArgumentException in Delegate.Combine

⏱ 30-second answer

Contravariance is the opposite of covariance: a consumer of a base type can stand in for a consumer of a derived type. You declare it with in T on interfaces and delegates, and T may only be accepted, never returned. So an IComparer<object> can sort a List<string>, and an Action<Animal> can be used as an Action<Dog>. The BCL comparers, Action, Predicate and the parameters of Func are contravariant. One trap: combining variance-converted delegates with += can throw at run time because their actual types differ.

🗣 Full answer (senior)

How I would answer this in an interview

Contravariance is the mirror of covariance: you can use a generic type with a less derived type argument where a more derived one is expected. It's marked with in and is allowed only when the type parameter appears in input positions. Intuition: something that can handle any object can certainly handle a string.

1. The in modifier

public interface IValidator<in T>
{
    bool IsValid(T item);           // T only as input
}

IValidator<Animal> animalValidator = new AnimalValidator();
IValidator<Dog> dogValidator = animalValidator;   // allowed: a validator for any Animal can validate Dogs

The direction feels "backwards" compared with inheritance: Dog : Animal, but IValidator<Animal> converts to IValidator<Dog>.

2. IComparer<object> and Action<object>

IComparer<object> byHash = Comparer<object>.Create((a, b) => a.GetHashCode().CompareTo(b.GetHashCode()));
IComparer<string> forStrings = byHash;    // IComparer<in T>

Action<object> log = o => Console.WriteLine(o);
Action<string> logString = log;           // Action<in T>
names.ForEach(logString);

A comparer that can compare any two objects can compare two strings; an action that accepts any object accepts strings. Nothing can go wrong because the consumer only receives values.

3. Contravariant BCL types

Type Contravariant parameter(s)
Action<in T1, ...> all parameters
Func<in T1, ..., out TResult> input parameters (and covariant result)
Predicate<in T>, Comparison<in T>, Converter<in TInput, out TOutput> inputs
IComparer<in T>, IEqualityComparer<in T> T
IComparable<in T> T
IObserver<in T> T
IEquatable<T> Not contravariant (invariant)

4. Real-world uses

  • Sorting/comparison: reuse a general comparer for specific types.
  • Event and message handlers: an IHandler<in TEvent> registered for a base event type can handle derived events:
public interface IHandler<in TEvent> { Task HandleAsync(TEvent e, CancellationToken ct); }
IHandler<IDomainEvent> auditHandler = new AuditAllEventsHandler();
IHandler<OrderPlaced> forOrders = auditHandler;   // audits every event, including OrderPlaced
  • Callbacks: Action<object> logging callbacks used where specific Action<T> is expected.

5. The gotcha: combining variant delegates

Variance gives reference conversions between delegate types, but Delegate.Combine (what + and += use) requires delegates of exactly the same runtime type:

Action<object> a = o => Console.WriteLine("object");
Action<string> b = s => Console.WriteLine("string");
Action<string> fromA = a;          // OK via contravariance - but fromA's runtime type is still Action<object>

Action<string> combined = fromA + b;   // throws ArgumentException at run time: Delegates must be of the same type

The compile-time types match (Action<string>), but the runtime types differ (Action<object> vs Action<string>), so combining fails. This bites events: subscribing a contravariantly converted handler to an event that already has handlers can throw.

Fixes: wrap in a new delegate of the exact type (Action<string> wrapped = s => a(s); or new Action<string>(a)), or avoid mixing variant conversions with multicast delegates.

6. A production example

A notification system had event Action<OrderEvent> OrderChanged; and a generic audit module subscribed with an Action<IDomainEvent> converted via contravariance. It worked in tests (single subscriber) and threw ArgumentException in production when a second subscriber was added, breaking start-up. We changed the audit module to subscribe with a lambda of the exact type (OrderChanged += e => audit(e);) and added a test registering multiple subscribers.

🔍 What the interviewer is checking

Checks that the candidate understands the direction of contravariance and can apply it to comparers and handlers.

Green flags: correct direction with a concrete example; names BCL contravariant types; knows the Delegate.Combine trap or the DI limitation.

Red flags: mixes up covariance and contravariance; thinks variance works with value types; cannot explain why output positions are disallowed.

📚 Learn in depth

1. Why input positions allow contravariance

Think of a consumer of T as a function T → void. If you can accept any Animal, you can accept a Dog because every Dog is an Animal — the substitution is safe in the input direction. Formally, function types are contravariant in parameter types and covariant in return types, matching Liskov's rule: an override may accept more general parameters and return more specific results.

2. Validity rules

An in T may appear in:

  • method parameter types,
  • set-only properties (rare),
  • as a type argument to a contravariant parameter of an output (e.g. returning Action<T>? No — returning Action<T> puts T in an input-of-output position, which is contravariant, so allowed),
  • as a type argument to a covariant parameter of an input (e.g. parameter IEnumerable<T> — input of output-only type: contravariant position — allowed).

It may not appear as a return type, in ref/out parameters, or in invariant generic types.

The compiler reports CS1961 for violations.

3. Runtime representation

As with covariance, the CLR performs no conversion: IComparer<object> and IComparer<string> reference the same object; the runtime's cast logic allows the assignment because IComparer<in T> is contravariant and string is assignable to object. Only reference types participate.

4. The multicast delegate issue in detail

Delegate.Combine(a, b) checks a.GetType() == b.GetType(). With variance, a variable typed Action<string> can hold an object whose runtime type is Action<object>. Events using field-like syntax call Delegate.Combine in add, so the exception happens on +=. It's documented behaviour and a known design wart of delegate variance (variance was added in C# 4, after multicast semantics were fixed).

Workaround helper:

static Action<T> Exact<T>(Action<T> handler) => handler.GetType() == typeof(Action<T>) ? handler : new Action<T>(handler.Invoke);

5. DI and contravariant handlers

Microsoft.Extensions.DependencyInjection does not resolve services using variance: requesting IEnumerable<IHandler<OrderPlaced>> won't include an IHandler<IDomainEvent> registration automatically. Some containers (Autofac with ContravariantRegistrationSource) support it. With MS DI, register the general handler for each concrete event, or write a dispatcher that also resolves handlers for base types/interfaces.

6. Comparers as a design example

Comparer<T>.Create(Comparison<T>) and IComparer<in T> let you write one comparer for a base type and reuse it for derived collections. StringComparer implements IComparer<string?> and IEqualityComparer<string?> — because those interfaces are contravariant, StringComparer.Ordinal can be used for collections of any... well, only string-related usage, since string is sealed.

Try it yourself

  1. Assign IComparer<object> to IComparer<string> and sort a list of strings.
  2. Reproduce the Delegate.Combine exception with variant Action<T> delegates and fix it with a wrapper.
  3. Declare interface ISink<in T> { T Last { get; } } and read the compiler error.
  4. Register an IHandler<IDomainEvent> in MS DI and check whether it's resolved for IHandler<OrderPlaced>.

💻 Code

namespace Generics.Contravariance;

public interface IDomainEvent { DateTimeOffset OccurredAt { get; } }
public sealed record OrderPlaced(Guid OrderId, DateTimeOffset OccurredAt) : IDomainEvent;
public sealed record OrderShipped(Guid OrderId, DateTimeOffset OccurredAt) : IDomainEvent;

public interface IEventHandler<in TEvent>
{
    void Handle(TEvent @event);
}

public sealed class AuditHandler : IEventHandler<IDomainEvent>
{
    public void Handle(IDomainEvent @event) => Console.WriteLine($"AUDIT {@event.GetType().Name} at {@event.OccurredAt:O}");
}

public sealed class ByOccurredAt : IComparer<IDomainEvent>
{
    public int Compare(IDomainEvent? x, IDomainEvent? y) => Nullable.Compare(x?.OccurredAt, y?.OccurredAt);
}

public static class Program
{
    public static void Main()
    {
        IEventHandler<OrderPlaced> placedHandler = new AuditHandler(); // contravariant conversion
        placedHandler.Handle(new OrderPlaced(Guid.NewGuid(), DateTimeOffset.UtcNow));

        List<OrderShipped> shipped =
        [
            new(Guid.NewGuid(), DateTimeOffset.UtcNow),
            new(Guid.NewGuid(), DateTimeOffset.UtcNow.AddMinutes(-5)),
        ];
        shipped.Sort(new ByOccurredAt()); // IComparer<IDomainEvent> used as IComparer<OrderShipped>

        Action<object> logAnything = o => Console.WriteLine($"log: {o}");
        Action<string> logText = logAnything;              // contravariance
        logText("hello");

        try
        {
            logText += s => Console.WriteLine(s.Length);  // run-time types differ
        }
        catch (ArgumentException ex)
        {
            Console.WriteLine($"Combine failed: {ex.Message}");
        }

        Action<string> safe = s => logAnything(s);         // adapter has run-time type Action<string>
        safe += s => Console.WriteLine(s.Length);
        safe("works");
    }
}

⚠ Common mistakes

1. Getting the direction backwards

What happens: expecting IComparer<string> to convert to IComparer<object>. Why it is wrong: a string comparer can't compare arbitrary objects. Do instead: remember: contravariance goes from general to specific.

2. Using in T in output positions

What happens: returning T from a contravariant interface. Why it is wrong: compile error — unsafe. Do instead: split into input and output interfaces.

3. Combining variant delegates

What happens: subscribing a contravariantly converted delegate to an event or combining with +. Why it is wrong: ArgumentException at run time because runtime delegate types differ. Do instead: wrap handlers in delegates of the exact type.

4. Expecting DI to apply variance

What happens: assuming a base-event handler is resolved for derived events in MS DI. Why it is wrong: the default container doesn't resolve via variance. Do instead: register explicitly or implement variance-aware dispatch.

5. Expecting variance with value types

What happens: Action<object> assigned to Action<int>. Why it is wrong: variance applies only to reference types. Do instead: use a lambda wrapper.

6. Assuming IEquatable<T> is contravariant

What happens: passing IEquatable<Base> where IEquatable<Derived> is expected. Why it is wrong: IEquatable<T> is invariant. Do instead: use IEqualityComparer<in T>.

7. Forgetting variance when designing handler interfaces

What happens: IHandler<TEvent> declared invariant. Why it is wrong: general handlers can't be used for specific events. Do instead: declare IHandler<in TEvent> where it only consumes events.

8. Over-generalising handlers

What happens: one IHandler<object> handles everything with type checks. Why it is wrong: loses type safety and clarity. Do instead: use specific handlers, with general ones only for genuinely cross-cutting concerns (auditing).

🏛 Senior insight: trade-offs

Contravariance shows up most in comparers, callbacks and handlers. In real systems it lets one cross-cutting handler (auditing, logging, metrics) apply to a whole family of messages. I am careful with two edges: delegates combined after a variance conversion, which fail only at run time, and DI containers, which do not apply variance for you. Being able to explain the "flipping" rule for higher-order members is a strong signal in a senior interview.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate explains in T. Why: Checks the candidate can explain both directions and the position rules.
    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 mentions types that are neither covariant nor contravariant. Why: Applies variance to everyday conversion errors.
    How to 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.

🛠 Real-world problems: how you handle them

Event subscription crashed after adding a generic logger
Situation
A desktop sync client exposed `event Action<SyncProgress>? Progress`. A new diagnostics module subscribed a shared `Action<object>` logger through a variance conversion.
Signals
ArgumentException 'Delegates must be of the same type' at start-up, only when diagnostics were enabled.
Investigation
The stack trace pointed at Delegate.Combine inside the event add accessor. Inspected the delegate's run-time type in the debugger: Action<Object>, while the existing subscriber was Action<SyncProgress>.
Resolution
Subscribed with an adapter lambda `p => logger(p)` so the delegate type matched exactly.
Prevention
Added a unit test that subscribes multiple handlers including the diagnostics one, and a code review note about variance with events.

How to tell it (STAR)

**S:** Enabling diagnostics crashed our sync client at start-up. **T:** Find the cause quickly. **A:** I traced it to Delegate.Combine refusing two delegates of different run-time types, a side effect of contravariance, and fixed it with a lambda adapter plus a regression test. **R:** Diagnostics shipped in [timeframe] and the team learned the variance edge case.

↩ Usually asked after

More in Generics

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me