Skip to content

.NET Core · Fundamentals

C# Generics: Type Constraints, Covariance, and Contravariance

8 min read Updated 2026-06-22 Share:

Practice Generics interview questions

Why generics come up in senior C# interviews

Generics are everywhere in .NET — List<T>, Dictionary<TKey, TValue>, Task<T>, IEnumerable<T>, Func<T, TResult>. Most developers use them daily without thinking about what the CLR is actually doing. Senior interviews probe whether you understand the mechanics: why does List<int> avoid boxing when ArrayList does not? Why can you assign IEnumerable<string> to IEnumerable<object>? Why does generic type inference sometimes fail? These questions reveal genuine understanding.

C# generics are reified — not erased

The fundamental difference between C# and Java generics is reification. In Java, generic type parameters are erased at compile time — List<Integer> and List<String> are the same class at runtime (just List). In C#, the CLR maintains full type information at runtime and generates distinct JIT code per closed generic type.

// Each closed generic type is a distinct runtime type:
var intList    = new List<int>();
var stringList = new List<string>();

Console.WriteLine(intList.GetType()    == stringList.GetType()); // False
Console.WriteLine(intList.GetType().Name);                        // "List`1"
Console.WriteLine(intList.GetType().GenericTypeArguments[0]);     // System.Int32

// typeof(T) works inside generic methods — impossible in Java without hacks:
void PrintTypeName<T>() => Console.WriteLine(typeof(T).FullName);
PrintTypeName<DateTime>(); // "System.DateTime"
PrintTypeName<string>();   // "System.String"

For reference type arguments (class types), the JIT shares a single native code implementation across all reference-type specialisations (since all references are the same size — one pointer). For value type arguments (structs), the JIT generates a distinct native implementation per value type. This is why List<int> stores int values directly in its internal array without boxing — the JIT produces code that operates on int[] directly, not object[].

Type constraints — unlocking T's members

Without constraints, T is opaque — you can only call methods on System.Object. Constraints narrow T to a set of types, unlocking their members inside the generic.

// No constraint — only Object members available:
T Echo<T>(T value)
{
    Console.WriteLine(value.ToString()); // ToString() comes from object — fine
    // value.CompareTo(value);           // not on object
    return value;
}

// Constrained — CompareTo() now available:
T Max<T>(T a, T b) where T : IComparable<T>
    => a.CompareTo(b) >= 0 ? a : b;

Console.WriteLine(Max(3, 7));         // 7
Console.WriteLine(Max("apple", "fig")); // "fig"

The full menu of constraints:

ConstraintMeaning
where T : classT must be a reference type
where T : structT must be a non-nullable value type
where T : notnullT must be non-nullable (class or non-nullable struct)
where T : new()T must have a public parameterless constructor
where T : SomeClassT must be SomeClass or derive from it
where T : ISomeInterfaceT must implement the interface
where T : unmanagedT must be an unmanaged type (C# 7.3+)
where T : defaultT can be both nullable and non-nullable (C# 9+, for method overrides)

Multiple constraints on one parameter are comma-separated; class or struct must come first:

class Repository<T> where T : class, new()
{
    public T CreateEmpty() => new T(); // new() enables this
}

// Multiple type parameters, each independently constrained:
class Pair<TFirst, TSecond>
    where TFirst  : IComparable<TFirst>
    where TSecond : class, new()
{ /* ... */ }

Covariance — safe upcasting for producers

Covariance means a Generic<Derived> is assignable to Generic<Base> when the type parameter only appears in output (producer) positions. In C# this is expressed with the out keyword on interface or delegate type parameters.

// IEnumerable<T> is declared covariant: interface IEnumerable<out T>
IEnumerable<string>  strings = new List<string> { "a", "b" };
IEnumerable<object>  objects = strings;  // covariance — compiles fine

// Safe because you can only READ from IEnumerable<T>:
foreach (object obj in objects)
    Console.WriteLine(obj.GetType().Name); // String, String

// IReadOnlyList<T> is also covariant:
IReadOnlyList<string> strList = new[] { "x", "y" };
IReadOnlyList<object> objList = strList; // valid

Why is it safe? When you read from an IEnumerable<string> through the IEnumerable<object> reference, every value you get out is guaranteed to be a string, which is a valid object. You cannot put anything in — there is no Add method on IEnumerable<T> — so there is no way to introduce a non-string into the collection.

Covariance would be unsafe for IList<T> (which has Add and this[int] = value), which is why IList<T> is invariant. The compiler enforces this:

IList<string> strList2 = new List<string> { "a" };
// IList<object> objList2 = strList2; // compile error — IList<T> is invariant
// Without this restriction:
// objList2.Add(42); // would add an int to a List<string> → runtime catastrophe

Defining your own covariant interface:

interface IProducer<out T>  // 'out' = covariant
{
    T Produce();             // T only in output (return) position 
    // void Consume(T item); // T in input position — compiler rejects
}

class StringProducer : IProducer<string>
{
    public string Produce() => "hello";
}

IProducer<string> sp = new StringProducer();
IProducer<object> op = sp; // covariance — safe

Contravariance — safe downcasting for consumers

Contravariance is the reverse: a Generic<Base> is assignable to Generic<Derived> when the type parameter only appears in input (consumer) positions. Expressed with the in keyword.

The classic example is Action<T>:

// Action<T> is declared contravariant: delegate void Action<in T>(T obj)
Action<object> handleObject = obj => Console.WriteLine(obj);
Action<string> handleString = handleObject; // contravariance — compiles fine

handleString("hello"); // calls handleObject with "hello" — string is-a object 

// Why is this safe?
// A method that can handle ANY object can certainly handle a string.
// Contravariance lets a "general" handler substitute for a "specific" one.

The intuition: for consumers (things that accept T), a handler for the base type is always compatible with a handler for the derived type. If you can process object, you can process string — but not necessarily the other way around.

// IComparer<T> is contravariant:
IComparer<object> objCmp = Comparer<object>.Default;
IComparer<string> strCmp = objCmp; // valid — comparer for object works for string

// Your own contravariant interface:
interface IConsumer<in T>
{
    void Consume(T item);    // T only in input (parameter) position 
    // T Produce();          // T in output position — compiler rejects
}

Generic delegates — Action, Func, Predicate

The BCL provides built-in generic delegate types that cover the vast majority of callback scenarios without requiring custom delegate declarations.

// Action<T...> — 0–16 parameters, void return
Action          doSomething = () => Console.WriteLine("go");
Action<string>  log         = msg => Console.WriteLine($"[LOG] {msg}");
Action<int,int> add         = (a, b) => Console.WriteLine(a + b);

// Func<T..., TResult> — 0–16 input params, returns TResult
Func<int>          getZero  = () => 0;
Func<int, string>  intToStr = n => n.ToString();
Func<int,int,int>  multiply = (a, b) => a * b;
Console.WriteLine(multiply(6, 7)); // 42

// Predicate<T> — exactly Func<T, bool>; used in List<T>.Find etc.
Predicate<int> isEven = n => n % 2 == 0;
var list = new List<int> { 1, 2, 3, 4 };
int found = list.Find(isEven); // 2

// Covariance in Func — Func<out TResult> is covariant in TResult:
Func<string> getStr = () => "hello";
Func<object> getObj = getStr; // covariance — string is-a object

// Contravariance in Action — Action<in T> is contravariant in T:
Action<object> printObj = obj => Console.WriteLine(obj);
Action<string> printStr = printObj; // contravariance

Never define a custom delegate type for the standard cases — Action and Func compose well with LINQ, TPL, and every .NET API. Only define a named delegate when you need a more descriptive type name for a public API or when the parameter count exceeds 16 (extremely rare).

default(T) and typeof(T)

Two operators are invaluable inside generic methods:

default(T) — returns the zero/null value for any type parameter:

  • Reference types → null
  • Numeric value types → 0
  • boolfalse
  • Any struct → zero-initialised instance
T[] Fill<T>(int count, T value = default!)
{
    var arr = new T[count];
    if (!EqualityComparer<T>.Default.Equals(value, default))
        Array.Fill(arr, value);
    return arr;
}

typeof(T) — returns the System.Type object for the static type parameter:

bool IsNullable<T>()
{
    // Nullable<T> is a value type, and typeof(T) is available at runtime:
    return Nullable.GetUnderlyingType(typeof(T)) != null;
}

Console.WriteLine(IsNullable<int?>());  // True
Console.WriteLine(IsNullable<int>());   // False
Console.WriteLine(IsNullable<string>()); // False (string is a ref type, not Nullable<T>)

Both default(T) and typeof(T) work because C# generics are reified — the type parameter is a real type at runtime, not erased. Neither has an equivalent in Java without reflection hacks.

IEnumerable<T> vs ICollection<T> vs IList<T>

The generic collection interface hierarchy is worth knowing cold for interviews:

IEnumerable<T>       — iterate only (foreach)
    └─ ICollection<T>  — + Count, Add, Remove, Contains, Clear
           └─ IList<T>   — + indexer this[int], Insert, RemoveAt
// Accept the narrowest interface your implementation needs:
void Print<T>(IEnumerable<T> items)    // widest — accepts arrays, List<T>, HashSet<T>, LINQ queries
void AddItem<T>(ICollection<T> col)    // needs Add
void SwapFirst<T>(IList<T> list)       // needs index access

// Return concrete types so callers get the full API:
List<string> GetNames() => new List<string> { "Alice", "Bob" };

IEnumerable<out T> is covariant; ICollection<T> and IList<T> are invariant (because they have Add(T) — an input position).

Recap

C# generics are reified at runtime — each closed generic type (List<int>, List<string>) is a distinct CLR type with its own JIT-compiled code and full type information available via reflection. This eliminates boxing for value-type specialisations and makes typeof(T) work inside generic methods. Type constraints (where T : ...) unlock members of T inside the generic and communicate requirements to callers. Covariance (out T) allows Generic<Derived> to be used as Generic<Base> when T is only produced (output); contravariance (in T) allows Generic<Base> to be used as Generic<Derived> when T is only consumed (input). The built-in Action<T>, Func<T,TResult>, and Predicate<T> delegates cover nearly all callback needs without requiring custom delegate declarations. default(T) and typeof(T) are the two generic-specific operators you reach for when the compiler cannot infer from context.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel