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:
| Constraint | Meaning |
|---|---|
where T : class | T must be a reference type |
where T : struct | T must be a non-nullable value type |
where T : notnull | T must be non-nullable (class or non-nullable struct) |
where T : new() | T must have a public parameterless constructor |
where T : SomeClass | T must be SomeClass or derive from it |
where T : ISomeInterface | T must implement the interface |
where T : unmanaged | T must be an unmanaged type (C# 7.3+) |
where T : default | T 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 bool→false- 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.