Every C# developer uses List<T> and Task<T> daily, but interviews at the senior, lead and architect level probe much further: how the CLR actually compiles a generic method for a value type versus a reference type, why List<string> cannot be treated as List<object>, and what static abstract interface members and generic math actually buy you. These questions separate engineers who memorized where T : class from engineers who can reason about code size, JIT behavior, Native AOT constraints and API design under constraints. The ten questions below cover constraints and their combination rules, variance and type safety, CLR implementation details for value and reference types, performance trade-offs, generic math, per-type static caching, and reflection over open generic types. Expect to justify design choices, not just define terms.

Q1 What are the different kinds of generic constraints in C#, and what rules govern combining them?#

Short answer: Constraints restrict which type arguments are legal in exchange for telling the compiler which operations are safe on T. They range from class, struct and notnull to unmanaged, Enum, Delegate, a base type or interface, another type parameter, new(), and, since C# 13, the anti-constraint allows ref struct. Combination order is fixed: at most one of struct, class, class?, notnull or unmanaged comes first, then base type and interface constraints, then other type parameters, then new() last, and allows ref struct follows everything else.

Each constraint is a public promise: adding one later is a breaking change for existing callers, while removing one is safe (though it may silently allow arguments the implementation was never tested with). unmanaged is the strictest common constraint, guaranteeing no reference anywhere in the type's layout, which is what makes reinterpreting the memory as bytes sound. allows ref struct is different in kind from the others: it does not restrict T, it lifts a restriction, letting Span<T> and other ref struct types be used as a type argument as long as the generic code obeys ref-safety rules.

C#
public static T CreateConfigured<T>(Action<T> configure) where T : class, new()
{
    var instance = new T();
    configure(instance);
    return instance;
}

public static ReadOnlySpan<byte> AsBytes<T>(ReadOnlySpan<T> values) where T : unmanaged =>
    MemoryMarshal.AsBytes(values);

What interviewers look for: fluency with the combination rules (order, and that new() must be last), and understanding that constraints are part of the API contract, not an implementation detail you can freely tighten.

  • Common mistakes: using where T : struct to mean "no nulls" when notnull is the correct constraint for code that also accepts non-nullable reference types.
  • Follow-up questions: Why can't a covariant type parameter (out T) appear in a method's own generic constraints? (It would let a use-site substitution violate the variance safety proof.)

Q2 Why is List<string> not a List<object>, and what makes IEnumerable<out T> safe to convert where List<T> is not?#

Short answer: Variance in C# applies only to interfaces and delegates, and only for reference-type arguments, because it relies on reference conversions rather than any runtime representation change. IEnumerable<T> only ever hands T out, so a producer of string can safely stand in for a producer of object. List<T> both produces (the indexer) and consumes (Add) T, so allowing the conversion would let code add an int to what is really a List<string> through the List<object> view.

Arrays are the historical exception: object[] array = new string[1]; compiles because arrays are covariant, but it is unsafe, and the runtime inserts a type check on every store, throwing ArrayTypeMismatchException if you write an incompatible element. That check is a real, measurable cost avoided by generic collections, which is one more reason to prefer List<T> over arrays for mutable general-purpose storage. When designing your own interfaces, split a read-only producer interface (mark it out T) from a full read-write interface, which is often what makes variance possible in application code, not just in the base class library.

C#
IEnumerable<string> names = ["Ada", "Grace"];
IEnumerable<object> items = names;              // covariance: producer of T

IComparer<Shape> byArea = Comparer<Shape>.Create((a, b) => a.Area.CompareTo(b.Area));
List<Circle> circles = [new Circle(2), new Circle(1)];
circles.Sort(byArea);                           // contravariance: consumer of a base type works

// List<object> boxAlias = new List<string>();  // error: List<T> is invariant

What interviewers look for: the producer/consumer mental model rather than memorized keyword mappings, and awareness that array covariance is a real, checked-at-runtime exception to the rule.

  • Common mistakes: believing variance works for value types (IEnumerable<int> never converts to IEnumerable<object> without boxing via Cast<object>()).
  • Follow-up questions: Why can in T and out T never appear as a ref, in or out parameter type on a member of that interface? (The safety proof only covers input and output positions of that specific kind.)

Q3 How does the CLR implement generics differently for value types and reference types?#

Short answer: The C# compiler emits one generic definition into IL with type parameters left open; the CLR decides at JIT time how to produce native code per instantiation. Every value-type instantiation, such as Stack<int> and Stack<Guid>, gets its own specialized method bodies because their sizes and layouts differ. Every reference-type instantiation shares one canonical body, because all references are the same size, and that shared code looks up type-specific information, such as typeof(T) or new T(), through a per-instantiation "generic dictionary" at run time.

This is why .NET generics are described as reified rather than erased: unlike Java, a Stack<int> genuinely knows it holds int at run time, with no boxing and no cast on read. Each closed constructed type, whether its code is shared or specialized, still has its own type identity and its own static fields; a static int in a generic class is really one field per closed type, not one field shared across all instantiations.

C#
static class InstanceCounter<T>
{
    private static int s_count;
    public static int Next() => Interlocked.Increment(ref s_count);
}

Console.WriteLine(InstanceCounter<int>.Next());     // 1
Console.WriteLine(InstanceCounter<int>.Next());     // 2
Console.WriteLine(InstanceCounter<string>.Next());  // 1: a different closed type, its own field

What interviewers look for: the specialization-versus-sharing distinction stated precisely, plus the follow-on fact that static fields are per closed type, which trips people up when they expect a single shared counter.

  • Common mistakes: assuming reference-type instantiations are free; the dictionary lookup for typeof(T) or interface calls is real, if small, overhead compared to non-generic code.
  • Follow-up questions: Why does typeof(T) == typeof(int) inside a generic method over value types often compile away entirely? (Each value-type instantiation is specialized, so the JIT can constant-fold the comparison and delete the untaken branch.)

Q4 What are the performance implications of writing generic code, both for the JIT and at run time?#

Short answer: Generic code over value types runs as fast as hand-specialized code, because each instantiation is a distinct compiled method with no boxing and no casts, but many value-type instantiations of a widely used generic type increase the amount of native code the JIT must produce, which affects startup time and working set. Generic code over reference types is compact, sharing one body across every instantiation, but pays a small, consistent cost for the generic dictionary lookups that recover type-specific information.

In practice this means a generic collection or algorithm over int, long or your own structs is not a compromise; it is exactly as fast as a version you would hand-write for that one type, which is why the base class library leans on typeof(T) == typeof(...) branches inside vectorized helpers rather than shipping separate types per primitive. The trade-off shows up on the other axis: a library with dozens of generic value-type instantiations across many call sites can bloat binary size and increase JIT compilation time at startup, which matters more for Native AOT and for services optimizing cold-start latency than for long-running servers where steady-state throughput dominates.

What interviewers look for: a balanced answer that generics are not inherently a performance tax, paired with the specific place the cost actually lands (code size for value types, dictionary indirection for reference types), rather than a vague "generics are slower because of type erasure," which is a Java answer, not a .NET one.

  • Common mistakes: conflating .NET's reified generics with Java's erasure-based ones, or claiming generic code always boxes.
  • Follow-up questions: How would dynamic PGO change the calculus for a reference-type generic method with one dominant call-site type? (The JIT can guard-devirtualize the dominant case, narrowing the gap with specialized code.)

Q5 What are static abstract interface members, and how do they make generic math with INumber<T> possible?#

Short answer: C# 11 lets an interface declare static abstract and static virtual members, which implementing types must provide as static members rather than instance members. .NET 7 used this to define System.Numerics interfaces such as INumber<T>, so generic code can write T.Zero, left + right or T.CreateChecked(value) for any T that implements the interface, something no earlier version of C# could express.

The built-in numeric types all implement the fine-grained interfaces (IAdditionOperators, IAdditiveIdentity and friends) as well as the broader INumber<T>, and your own types can implement just the pieces they need. Conversions between numeric types matter for correctness: CreateChecked throws OverflowException when a value does not fit, CreateSaturating clamps to the target range, and CreateTruncating keeps the low bits and wraps. The compiler emits a static abstract member call as a constrained call through the type parameter, and for specialized value-type instantiations the JIT binds it directly to the concrete operator with no virtual dispatch.

C#
public readonly record struct Money(decimal Amount)
    : IAdditionOperators<Money, Money, Money>, IAdditiveIdentity<Money, Money>
{
    public static Money AdditiveIdentity => new(0m);
    public static Money operator +(Money left, Money right) => new(left.Amount + right.Amount);
}

static T Sum<T>(IEnumerable<T> items) where T : IAdditionOperators<T, T, T>, IAdditiveIdentity<T, T>
{
    T total = T.AdditiveIdentity;
    foreach (var item in items) total += item;
    return total;
}

What interviewers look for: clarity that generic math is primarily a library-author tool that removes one overload per numeric type, and correct choice among the three Create* conversion families for a given correctness requirement.

  • Common mistakes: assuming an extension operator (C# 14) satisfies a generic math interface constraint; it does not, because the type still has to declare the interface itself.
  • Follow-up questions: Why must TSelf appear as a type parameter on interfaces like IAdditionOperators<TSelf, TOther, TResult>? (So the static members are strongly typed to the concrete implementing type rather than the interface itself.)

Q6 How would you build a lock-free, per-type cache with a static generic class, and what can go wrong?#

Short answer: Because every closed generic type gets its own static fields, a static generic class is a natural, allocation-free, per-type cache: the CLR runs its type initializer exactly once, thread-safely, and every read after the first is an ordinary field load with no lock. This is the same pattern the base class library uses for EqualityComparer<T>.Default and Comparer<T>.Default.

The pitfalls are all about what you put in the initializer. It never gets evicted, so keying it on unbounded or dynamically generated types leaks memory for the life of the process. An exception thrown from a type initializer is wrapped in TypeInitializationException, and that closed type becomes permanently unusable afterward, so keep the initializer simple and side-effect free. Reflection-heavy initializers, such as one that inspects T's properties, also need trimming annotations to keep working under Native AOT and assembly trimming.

C#
public static class DisplayNameCache<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>
{
    public static readonly IReadOnlyDictionary<string, string> Names =
        typeof(T).GetProperties()
            .ToDictionary(p => p.Name, p => p.GetCustomAttribute<DisplayAttribute>()?.Name ?? p.Name);
}

What interviewers look for: the "lock-free because the CLR guarantees the initializer runs once" reasoning stated explicitly, plus the two concrete failure modes (unbounded key space, poisoned type after a failed initializer).

  • Common mistakes: assuming a static generic cache is automatically bounded; nothing evicts it unless you build eviction yourself.
  • Follow-up questions: Why is a source generator often preferable to this pattern when the cached data could be known at compile time? (No run-time reflection, no trimming annotations needed, and it works unmodified under Native AOT.)

Q7 How do you use reflection to build and invoke a closed generic type from an open generic type definition at run time?#

Short answer: typeof(Repository<>) gives you the open generic type definition; calling MakeGenericType on it with concrete type arguments produces the closed type, which you can then activate with Activator.CreateInstance or inspect with ordinary reflection. This is exactly how dependency injection containers support registering one open generic implementation for every closed type a consumer asks for.

Type.IsGenericTypeDefinition tells you whether a Type instance is the open definition versus an already-closed generic type, and GetGenericArguments() returns the type parameters on a definition or the type arguments on a closed type, depending on which one you called it on. Because MakeGenericType generates a new type at run time, it requires dynamic code generation and is annotated with RequiresDynamicCode, so it fails, or must be avoided, in Native AOT applications that need every instantiation to be knowable ahead of time.

C#
var openType = typeof(Repository<>);
var closedType = openType.MakeGenericType(typeof(Order));   // Repository<Order>
var repository = Activator.CreateInstance(closedType);

// The same mechanism underlies open-generic DI registration:
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
// The container calls MakeGenericType(typeof(Order)) the first time IRepository<Order> is requested.

What interviewers look for: the open-versus-closed vocabulary used correctly, and the ability to connect the reflection mechanism to something concrete, such as DI container internals, rather than reciting the API in isolation.

  • Common mistakes: calling MakeGenericType with type arguments that violate the definition's constraints, which throws ArgumentException at run time instead of failing at compile time.
  • Follow-up questions: How would you make a class that relies on MakeGenericType compatible with Native AOT? (Replace it with a compile-time mechanism, such as a source generator that emits the needed closed types explicitly, or a switch over a known, finite set of types.)

Q8 How does variance work for delegates such as Func and Action, and why doesn't it help with value types?#

Short answer: Func<out TResult> is covariant in its result and Action<in T> is contravariant in its parameter, following the same producer/consumer rule as interfaces, so a Func<string> converts to a Func<object> and an Action<object> converts to an Action<string>. It is invisible in everyday code because the compiler applies it automatically at the assignment or argument-passing site, but it explains why you can pass a more specific producer or a more general consumer than the delegate type nominally asks for.

Value types break the pattern for the same reason they break interface variance: converting int to object requires boxing, which is a representation change, not a reference conversion, so Func<int> never implicitly converts to Func<object>. This matters in method-group and lambda scenarios, where a mismatch shows up as a compile error that looks like it should work "because inheritance," when the real blocker is that one of the types involved is a value type.

C#
Action<object> log = o => Console.WriteLine(o);
Action<string> logName = log;              // contravariance: consumer of a base type

Func<string> makeName = () => "Ada";
Func<object> makeObject = makeName;        // covariance: producer of a derived type

// Func<int> makeInt = () => 1;
// Func<object> boxed = makeInt;           // error: no implicit reference conversion

What interviewers look for: recognizing that delegate variance and interface variance are the same rule applied to a different shape, and correctly predicting where a value type breaks it.

  • Common mistakes: trying to "fix" a failed delegate conversion involving a value type with a cast instead of an explicit conversion or wrapper lambda.
  • Follow-up questions: How would you adapt a Func<int> to a place that expects Func<object>? (Wrap it: Func<object> boxed = () => makeInt();.)

Q9 What breaks when you try to use MakeGenericType or heavy reflection over generics in a Native AOT application?#

Short answer: Native AOT compiles every code path ahead of time, so it must know every value-type generic instantiation your program can reach at compile time; there is no JIT available at run time to produce a new one on demand. APIs like Type.MakeGenericType and Array.CreateInstance over an unknown type are annotated RequiresDynamicCode, and calling them with a value-type argument the trimmer could not see statically either throws at run time or is flagged by the AOT compatibility analyzer during publish.

Reference-type instantiations are less fragile, since the shared generic code for reference types can sometimes still be produced, but the trimmer also needs to know which members of T are reachable, which is what DynamicallyAccessedMembersAttribute communicates for reflection-based code. The practical takeaway for a service that must publish as Native AOT is to audit dependency injection containers, serializers and mapping libraries for open-generic reflection early, because the failure mode is a run-time exception in an environment where you cannot fall back to JIT compilation.

What interviewers look for: understanding that this is a "no JIT to fall back to" problem specific to Native AOT, not a general reflection performance complaint, and familiarity with the RequiresDynamicCode and DynamicallyAccessedMembers annotations as the compiler's way of surfacing the risk before deployment. See Native AOT and trimming for the broader picture.

  • Common mistakes: assuming any use of reflection is unsafe under Native AOT; many reflection patterns over closed, statically known types work fine.
  • Follow-up questions: How would you unit test that a library stays Native-AOT compatible? (Publish a small AOT test project referencing it as part of CI and inspect the trimmer/AOT analyzer warnings.)

Q10 When would you deliberately avoid making an API generic, even though a generic version is possible?#

Short answer: Prefer a non-generic design when the added type parameter would not change behavior, only spare a cast, or when the constraint set required to make the generic version useful would itself become an unstable public contract. A generic API is a promise about every future type argument someone might supply, and loosening or tightening constraints later is a breaking change either way.

A concrete example: a plugin-loading API that only ever calls Initialize() and Dispose() on a plugin gains little from a LoadPlugin<TPlugin>() where TPlugin : IPlugin, new() signature over a simple LoadPlugin(Type pluginType) that validates the type implements IPlugin at run time; the generic version forces every caller to know the concrete type at compile time, which defeats a plugin architecture's entire purpose. Conversely, a serialization or comparison utility that touches the value's actual type benefits enormously from generics, because it avoids boxing and gives callers compile-time type safety. The judgment call is whether the type parameter carries real information the compiler can use, or is only there because "generic" sounds more reusable.

What interviewers look for: a design-trade-off answer grounded in a concrete scenario, showing you weigh API stability and caller ergonomics against the theoretical flexibility of generics, which is exactly the judgment architects are hired for.

  • Common mistakes: defaulting to generics everywhere as a matter of style, which increases constraint churn and makes simple call sites harder to read for no behavioral gain.
  • Follow-up questions: How would you evolve a non-generic API to a generic one later without breaking existing callers? (Add a new generic overload or method alongside the original, and let the non-generic form delegate to it internally if useful.)

Quick-Fire Round#

QuestionAnswer
Which constraint must always come last in a constraint list?new()
Which C# version added allows ref struct?C# 13
Is List<T> covariant, contravariant or invariant?Invariant
Does array covariance get checked at compile time or run time?Run time, via a store-time check that can throw ArrayTypeMismatchException
Do all closed generic types share static fields?No, each closed type has its own static fields
Which .NET version introduced INumber<T> and generic math?.NET 7, built on C# 11 static abstract members
What attribute flags a member that needs run-time code generation?RequiresDynamicCode
What exception wraps a failed static type initializer?TypeInitializationException
Can Func<int> implicitly convert to Func<object>?No; it would require boxing, not a reference conversion

How to Prepare#

  • Memorize the constraint combination order and be ready to write a multi-constraint generic method live.
  • Practice the producer/consumer framing for variance until you can classify any interface as covariant, contravariant or invariant on sight.
  • Be able to sketch, on a whiteboard, what the JIT produces for a value-type instantiation versus a reference-type instantiation, including where the generic dictionary sits.
  • Implement a small generic math type yourself so IAdditionOperators and the Create* conversion family are not abstract to you.
  • Know exactly which reflection-over-generics patterns fail under Native AOT and why, since this is an increasingly common senior-level question in 2026.