Generic contravariance: in T, input-only positions, BCL comparers and delegates, and the run-time failure when combining variance-converted delegates.
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.
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.
in modifierpublic 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>.
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.
| 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) |
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
Action<object> logging callbacks used where specific Action<T> is expected.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.
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.
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.
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.
An in T may appear in:
set-only properties (rare),Action<T>? No — returning Action<T> puts T in an input-of-output position, which is contravariant, so allowed),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.
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.
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);
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.
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.
IComparer<object> to IComparer<string> and sort a list of strings.Delegate.Combine exception with variant Action<T> delegates and fix it with a wrapper.interface ISink<in T> { T Last { get; } } and read the compiler error.IHandler<IDomainEvent> in MS DI and check whether it's resolved for IHandler<OrderPlaced>.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");
}
}
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.
in T in output positionsWhat happens: returning T from a contravariant interface.
Why it is wrong: compile error — unsafe.
Do instead: split into input and output interfaces.
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.
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.
What happens: Action<object> assigned to Action<int>.
Why it is wrong: variance applies only to reference types.
Do instead: use a lambda wrapper.
IEquatable<T> is contravariantWhat happens: passing IEquatable<Base> where IEquatable<Derived> is expected.
Why it is wrong: IEquatable<T> is invariant.
Do instead: use IEqualityComparer<in T>.
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.
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).
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.
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>.
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.
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.
Last updated 30 Oct 2026.