Skip to content

Generics Interview Questions & Answers

15 questions Updated 2026-06-22 Share:

C# generics interview questions — type constraints, covariance and contravariance, reification, generic delegates, and performance implications.

Read the in-depth guideC# Generics: Type Constraints, Covariance, and Contravariance(opens in new tab)
15 of 15

Generics let you write type-safe, reusable code where the concrete type is specified by the caller, not the author. They eliminate the need for casting and prevent boxing/unboxing for value types.

// Pre-generics (C# 1): must cast, no type safety, boxes value types
ArrayList list = new ArrayList();
list.Add(42);           // boxes int → object
int x = (int)list[0];  // unboxes + cast — runtime error if wrong type

// With generics (C# 2+): type-safe, no boxing, no cast
List<int> nums = new List<int>();
nums.Add(42);           // no boxing
int y = nums[0];        // no cast, compiler guarantees the type

// Generic method:
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("cat", "dog")); // "dog"

The CLR reifies generics — List<int> and List<string> are genuinely distinct types at runtime, each with their own JIT-compiled native code. This means value-type specialisations get zero-overhead native implementations.

Rule of thumb: Prefer generics over object parameters, ArrayList, or casting. One generic implementation beats N typed duplicates every time.

Type constraints restrict what types a caller can substitute for a type parameter, unlocking members of that type inside the generic implementation.

// where T : class     — T must be a reference type
// where T : struct    — T must be a non-nullable value type
// where T : new()     — T must have a public parameterless constructor
// where T : SomeBase  — T must derive from SomeBase
// where T : ISomething — T must implement ISomething
// where T : notnull   — T must be non-nullable (C# 8+)
// where T : unmanaged — T must be an unmanaged type (C# 7.3+)

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

T Clamp<T>(T value, T min, T max) where T : IComparable<T>
{
    if (value.CompareTo(min) < 0) return min;  // CompareTo available via constraint
    if (value.CompareTo(max) > 0) return max;
    return value;
}

Console.WriteLine(Clamp(15, 1, 10)); // 10

Multiple constraints combine with commas: where T : class, ISomething, new(). You can constrain multiple type parameters independently.

Rule of thumb: Add constraints only when you need to call members on T. Each constraint narrows the caller's type choices — start unconstrained and add constraints as the implementation demands them.

Covariance means a generic type with a more derived type argument is assignable to a generic type with a less derived type argument. In C#, covariance is expressed with the out keyword on an interface or delegate type parameter.

// IEnumerable<T> is covariant (out T):
IEnumerable<string> strings = new List<string> { "a", "b" };
IEnumerable<object> objects = strings;  // valid — covariance!
// A string IS-A object, so reading strings as objects is safe

// IReadOnlyList<T> is covariant:
IReadOnlyList<string> strList = new List<string> { "x" };
IReadOnlyList<object> objList = strList; // valid

// Defining a covariant interface:
interface IProducer<out T>   // 'out' = covariant
{
    T Produce();             // T only appears in output position — safe
    // void Consume(T item); // would break type safety — input position forbidden
}

Covariance is safe because you are only reading values out of the container — the container will always give you a string, and a string is always a valid object. Writing would be unsafe (you could insert a non-string into a List<string> disguised as List<object>).

Rule of thumb: out T = covariance = the type flows out of the API (producer). Safe for read-only interfaces. Familiar examples: IEnumerable<out T>, IReadOnlyList<out T>, Func<out TResult>.

Contravariance is the reverse: a generic with a less derived type argument is assignable to one with a more derived type argument. Expressed with the in keyword — the type parameter only appears in input (consumer) positions.

// Action<T> is contravariant (in T):
Action<object> printObject = obj => Console.WriteLine(obj);
Action<string> printString = printObject;  // valid — contravariance!
// If you can handle any object, you can certainly handle a string

printString("hello"); // works — string is passed to printObject handler

// IComparer<T> is contravariant:
IComparer<object> objComparer = Comparer<object>.Default;
IComparer<string> strComparer = objComparer; // valid

// Defining a contravariant interface:
interface IConsumer<in T>   // 'in' = contravariant
{
    void Consume(T item);   // T only appears in input position — safe
    // T Produce();         // would break type safety — output position forbidden
}

The logic: if a method can handle any object, it can certainly handle a string. Contravariance is about consuming values — a handler for the base type is always compatible with a handler for the derived type.

Rule of thumb: in T = contravariance = the type flows into the API (consumer). Familiar examples: Action<in T>, IComparer<in T>, IEqualityComparer<in T>.

IEnumerable<T> is the minimal read/iterate interface. ICollection<T> extends it to add count and mutation operations. Choosing the right abstraction in method signatures controls what callers can pass and what you can do with the argument.

// IEnumerable<T> — iterate only, no count, no add/remove
void PrintAll<T>(IEnumerable<T> items)
{
    foreach (var item in items) Console.WriteLine(item); // fine
    // items.Count — not available
    // items.Add(...)  — not available
}

// ICollection<T> — adds Count, Add, Remove, Contains, Clear
void AddRange<T>(ICollection<T> target, IEnumerable<T> source)
{
    foreach (var item in source) target.Add(item); // Add available
    Console.WriteLine(target.Count);               // Count available
}

// IList<T> extends ICollection<T> — adds index access
void SwapFirst<T>(IList<T> list) { (list[0], list[1]) = (list[1], list[0]); }

The interface hierarchy: IEnumerable<T>ICollection<T>IList<T>. Use the narrowest interface that satisfies your needs in method parameters — this maximises what callers can pass (arrays, LINQ queries, custom enumerables).

Rule of thumb: Accept IEnumerable<T> if you only iterate, ICollection<T> if you need Count or mutation, IList<T> if you need index access. Return concrete types (List<T>) so callers have the full API.

C# generics are reified — the type parameter exists at runtime and each closed generic type (e.g., List<int>, List<string>) is a distinct CLR type. Java generics use type erasure — type parameters are removed at compile time and replaced with Object (or the bound type), leaving only one class at runtime.

// C# — type parameters survive to runtime:
var list = new List<int>();
Console.WriteLine(list.GetType().Name);           // "List`1"
Console.WriteLine(list.GetType().GenericTypeArguments[0]); // System.Int32

// C# — value type specialisation: List<int> uses int[] internally, no boxing
var ints = new List<int> { 1, 2, 3 }; // zero boxing

// C# — can use typeof on generic params:
void Print<T>() => Console.WriteLine(typeof(T).Name);
Print<DateTime>(); // "DateTime"

// Java equivalent would erase T → Object at runtime
// Java: new ArrayList<Integer>() has no Integer info at runtime
// Java: no specialised ArrayList<int> — autoboxing to Integer always required

Consequences of C# reification: typeof(T) works inside generics, is T checks are valid, value-type specialisations avoid boxing, and you get distinct JIT code per value-type argument. The cost is larger compiled output for many value-type specialisations.

Rule of thumb: C# generics are more powerful and more efficient for value types than Java generics. Use typeof(T) and default(T) freely in C# — these have no Java equivalent without reflection hacks.

default(T) returns the default value for type T: 0 for numeric types, false for bool, null for reference types, and zero-initialised for structs. It is the only way to get a valid zero/null for an unknown type parameter.

T GetValueOrDefault<T>(Dictionary<string, T> dict, string key)
{
    return dict.TryGetValue(key, out T? value) ? value : default(T);
    // If T is int  → returns 0
    // If T is bool → returns false
    // If T is string → returns null
}

// C# 7.1+: 'default' literal (type inferred from context)
T[] CreateArray<T>(int length)
{
    var arr = new T[length]; // already zero/default initialised
    return arr;
}

// Useful in generic algorithms:
T Min<T>(T[] arr) where T : IComparable<T>
{
    T min = default!; // non-null assertion needed with nullable ref types
    bool first = true;
    foreach (var item in arr)
    {
        if (first || item.CompareTo(min) < 0) { min = item; first = false; }
    }
    return min;
}

Rule of thumb: Use default(T) or the default literal when you need a "zero" starting value for a type parameter — it is the generic equivalent of writing 0, false, or null for concrete types.

These BCL delegates cover the vast majority of generic callback needs without requiring custom delegate declarations.

// Action<T...> — takes up to 16 params, returns void
Action<string> print = s => Console.WriteLine(s);
Action<int, int> add = (a, b) => Console.WriteLine(a + b);
print("hello");   // "hello"
add(2, 3);        // 5

// Func<T..., TResult> — takes up to 16 params, returns TResult
Func<int, int, int> multiply = (a, b) => a * b;
Func<string, int>   parse    = int.Parse;
Console.WriteLine(multiply(3, 4)); // 12

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

// LINQ uses Func throughout — these three delegate types compose naturally:
var evens = nums.Where(isEven.Invoke).Select(n => n * 10).ToList();

Predicate<T> is literally Func<T, bool> — they are separate types only for historical API compatibility (List<T> predates LINQ). You can convert with .Invoke or a lambda wrapper.

Rule of thumb: Use Func<> for functions, Action<> for side effects, and Predicate<T> only when an API specifically requires it. Never define a custom single-method delegate unless you need a more descriptive name for API clarity.

Yes, a class can implement IInterface<T> for multiple closed types, but only through the explicit interface implementation syntax when the method signatures would conflict.

interface IConverter<T>
{
    T Convert(string input);
}

class MultiConverter : IConverter<int>, IConverter<double>
{
    // Explicit implementations avoid member name collision:
    int IConverter<int>.Convert(string input) => int.Parse(input);
    double IConverter<double>.Convert(string input) => double.Parse(input);
}

var mc = new MultiConverter();
int i = ((IConverter<int>)mc).Convert("42");
double d = ((IConverter<double>)mc).Convert("3.14");
Console.WriteLine($"{i}, {d}"); // 42, 3.14

// Common real-world example — IEquatable<T> and IComparable<T>:
struct Temperature : IComparable<Temperature>, IEquatable<Temperature>
{
    public double Celsius { get; init; }
    public int CompareTo(Temperature other) => Celsius.CompareTo(other.Celsius);
    public bool Equals(Temperature other) => Celsius == other.Celsius;
}

Rule of thumb: A class implementing the same interface for multiple type args is valid but rare. It's most common in adapters or type-bridge scenarios. When signatures collide, use explicit interface implementation to disambiguate.

typeof(T) is a compile-time operator that returns the Type object for the static type parameter T. t.GetType() is a runtime call on an instance t that returns its actual runtime type — which may be a subclass of T.

void Inspect<T>(T value)
{
    Console.WriteLine(typeof(T).Name);   // static type parameter
    Console.WriteLine(value.GetType().Name); // actual runtime type of the instance
}

Inspect<object>("hello");
// typeof(T)      → "Object"  (T is declared as 'object')
// GetType()      → "String"  (the actual object is a string)

Inspect<string>("hello");
// typeof(T)      → "String"
// GetType()      → "String"   (same in this case)

// Use typeof(T) when you need the declared type:
bool IsValueType<T>() => typeof(T).IsValueType;
Console.WriteLine(IsValueType<int>());    // True
Console.WriteLine(IsValueType<string>()); // False

In most generic code you want typeof(T) — it is a constant, avoids a virtual dispatch, and works even when value is null. Use GetType() only when you specifically need to know the runtime subtype of the instance.

Rule of thumb: typeof(T) = the type the author declared. GetType() = the type the caller actually passed. They differ when T is a base class or interface and the instance is a derived type.

ArrayList stores items as object — no type safety, every value type is boxed. List<T> is generic — type-safe, no boxing, and produces helpful compile-time errors rather than runtime InvalidCastException.

// ArrayList — pre-.NET 2 approach, avoid in new code
var old = new ArrayList();
old.Add(42);        // boxes int → object (heap allocation)
old.Add("oops");    // compiles fine — runtime disaster waiting
int x = (int)old[1]; // InvalidCastException at runtime!

// List<T> — correct approach
var list = new List<int>();
list.Add(42);       // no boxing
// list.Add("oops"); // compile-time error — caught immediately
int y = list[0];    // no cast needed
Console.WriteLine(list.Count); // 1

ArrayList still exists in .NET for legacy compatibility but is effectively deprecated for new code. The only remaining use case is interoperating with old COM APIs or reflection scenarios where the type is unknown at compile time (in which case List<object> is still cleaner than ArrayList).

Rule of thumb: Never use ArrayList, Hashtable, or other non-generic collections in new C# code. Always use their generic equivalents: List<T>, Dictionary<TKey, TValue>, HashSet<T>.

No. In C#, each closed generic type (List<int>, List<string>) is a distinct CLR type with its own set of static members. A static field on MyClass<T> is not shared between MyClass<int> and MyClass<string>.

class Counter<T>
{
    public static int Count = 0;
    public Counter() => Count++;
}

_ = new Counter<int>();
_ = new Counter<int>();
_ = new Counter<string>();

Console.WriteLine(Counter<int>.Count);    // 2 — own counter
Console.WriteLine(Counter<string>.Count); // 1 — separate counter
Console.WriteLine(Counter<double>.Count); // 0 — never instantiated

// Contrast with a non-generic class — one shared static field:
class NonGenericCounter
{
    public static int Count = 0;
    public NonGenericCounter() => Count++;
}
// All instances share NonGenericCounter.Count

This is a direct consequence of CLR reification — each generic instantiation is a separate type. It enables type-specific caches (e.g., EqualityComparer<T>.Default caches one comparer per T) but can be a source of bugs when developers expect sharing.

Rule of thumb: Never expect a static field on a generic type to be shared across type arguments. If you need global shared state, put it in a non-generic outer class or a static class.

An open generic type still has unbound type parameters (e.g., List<T>). A closed generic type has all type parameters substituted with concrete types (e.g., List<int>). You can only create instances of closed generic types.

// Closed type — concrete, instantiable:
var list = new List<int>(); // List<int> is a closed type

// Open type — abstract, used with typeof for reflection:
Type open   = typeof(List<>);          // open generic — note the <>
Type closed = typeof(List<int>);       // closed generic

Console.WriteLine(open.IsGenericTypeDefinition);  // True
Console.WriteLine(closed.IsGenericTypeDefinition); // False
Console.WriteLine(closed.IsConstructedGenericType); // True

// Construct a closed type from an open one at runtime:
Type constructed = open.MakeGenericType(typeof(string));
object instance  = Activator.CreateInstance(constructed)!; // List<string>

// Useful for generic plugin/factory systems:
var repoType    = typeof(Repository<>).MakeGenericType(entityType);
var repoInstance = Activator.CreateInstance(repoType);

Open generic types appear in reflection-based DI containers and ORMs. For example, registering typeof(IRepository<>)typeof(Repository<>) lets the container resolve IRepository<User> as Repository<User> automatically.

Rule of thumb: You work with open generic types mostly in DI registration and reflection. In application code, you always deal with closed types. Use MakeGenericType when you need to construct a generic type from a runtime Type object.

.NET 7 introduced a set of interfaces in System.NumericsINumber<T>, IAdditionOperators<TSelf, TOther, TResult>, IComparable<T>, etc. — that allow you to write generic algorithms over numeric types without reflection or casting.

using System.Numerics;

// Before .NET 7: had to duplicate Sum<int>, Sum<double>, Sum<decimal> ...
// .NET 7+: one generic method works for all numeric types
T Sum<T>(IEnumerable<T> source) where T : INumber<T>
{
    T total = T.Zero;                  // INumber<T> provides Zero
    foreach (T item in source)
        total += item;                 // += via IAdditionOperators
    return total;
}

Console.WriteLine(Sum(new[] { 1, 2, 3 }));          // 6    (int)
Console.WriteLine(Sum(new[] { 1.1, 2.2, 3.3 }));    // 6.6  (double)
Console.WriteLine(Sum(new[] { 1m, 2m, 3m }));       // 6    (decimal)

// Parse any number type generically:
T Parse<T>(string s) where T : IParsable<T>
    => T.Parse(s, null);

int i = Parse<int>("42");
double d = Parse<double>("3.14");

This capability, called static abstract interface members (C# 11), allows interfaces to declare static operators and factory methods that must be implemented by conforming types.

Rule of thumb: Use INumber<T> or the narrower arithmetic interfaces (IAdditionOperators, IMultiplyOperators) when writing library-level numeric algorithms. For application code, concrete types are clearer and simpler.

The C# compiler infers generic type arguments from the types of the method arguments passed at the call site. Inference works only for method type parameters — class type parameters always require explicit specification.

// Compiler infers T from the argument type:
T Identity<T>(T value) => value;

var s = Identity("hello");  // T inferred as string
var n = Identity(42);       // T inferred as int
// No need to write Identity<string>("hello")

// Inference fails when T appears only in the return type:
T Create<T>() where T : new() => new T();
// var x = Create();        // error — cannot infer T from no arguments
var x = Create<StringBuilder>(); // must specify explicitly

// Partial inference is not allowed — specify all or none:
void Combine<T1, T2>(T1 a, T2 b) { }
Combine(1, "hi");           // both inferred: T1=int, T2=string
// Combine<int>("hi");      // compile error — cannot partially specify

// Return-type inference for delegates/lambdas (C# 10):
var square = (int x) => x * x; // inferred as Func<int, int>

Type inference uses unification — it matches each argument's type against the corresponding parameter's declared type to deduce T. When multiple arguments constrain the same T to different types and neither is convertible to the other, inference fails and you must specify explicitly.

Rule of thumb: Let the compiler infer when arguments supply all the type information needed. Specify explicitly when T is in the return type only, when inference would choose the wrong type, or when you need to override the inferred type for clarity.

More ways to practice

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

or
Join our WhatsApp Channel