Generic classes: type parameters, open versus closed types, reification, per-type static state and when a class should be generic.
A generic class declares type parameters, like Repository<T>, so one implementation works for many types with full compile-time type safety and no casts or boxing. Repository<> is the open type; Repository<Order> is a closed, constructed type, and the CLR treats every closed type as a separate runtime type, so static fields exist once per closed type. Unlike Java, .NET generics are reified: the type argument is known at run time.
A generic class is a class parameterised by one or more type parameters (Cache<TKey, TValue>), so one implementation works for many types while staying type-safe and avoiding boxing. I'd explain why generics exist, how you declare and close a generic type, and the subtle point about static fields per constructed type.
Before .NET 2.0, reusable code used object:
ArrayList list = new();
list.Add(42); // boxed
list.Add("oops"); // compiles
int x = (int)list[1]; // InvalidCastException at run time
Problems: no compile-time type safety, casts everywhere, and boxing of value types (allocation + copy). Generics solve all three:
List<int> list = [42];
// list.Add("oops"); // compile error
int x = list[0]; // no cast, no boxing
.NET generics are reified: the runtime knows List<int> and List<string> are different types (unlike Java's erasure), which enables specialised code for value types, typeof(T) at run time, and new T().
public sealed class Cache<TKey, TValue> where TKey : notnull
{
private readonly Dictionary<TKey, (TValue Value, DateTimeOffset Expires)> _items = new();
public void Set(TKey key, TValue value, TimeSpan ttl, DateTimeOffset now) => _items[key] = (value, now + ttl);
public bool TryGet(TKey key, DateTimeOffset now, [MaybeNullWhen(false)] out TValue value)
{
if (_items.TryGetValue(key, out var e) && e.Expires > now) { value = e.Value; return true; }
value = default; return false;
}
}
Cache<TKey, TValue> is a generic type definition (an open type): typeof(Cache<,>).Cache<string, Product> is a constructed (closed) type, created when you supply all type arguments.where TKey : notnull) that restrict arguments and let you call members on them.typeof(Cache<,>).MakeGenericType(typeof(int), typeof(string)) — that's how DI closes open generic registrations.public static class Counter<T>
{
public static int Count;
}
Counter<int>.Count++;
Counter<int>.Count++;
Counter<string>.Count++;
Console.WriteLine(Counter<int>.Count); // 2
Console.WriteLine(Counter<string>.Count); // 1
Each closed type (Counter<int>, Counter<string>) has its own static fields and its own static constructor run. So Cache<int> and Cache<string> never share statics.
This is useful: a generic static cache keyed by type is faster than a dictionary lookup:
internal static class EmptyArray<T> { public static readonly T[] Value = new T[0]; } // the idea behind Array.Empty<T>()
internal static class TypeName<T> { public static readonly string Value = typeof(T).Name; }
But it's a trap if you expect sharing: a static "global" counter or registry in a generic class is actually one per type argument, which can cause bugs (e.g. separate locks per closed type that don't protect shared state) and memory growth if type arguments are many.
A logging helper declared public static class Throttle<T> { private static DateTime _last; private static readonly object _lock = new(); } to throttle error logs. It was used as Throttle<OrderService>, Throttle<PaymentService>... The intent was a single global throttle, but each closed type had its own _last and _lock, so under an outage every service logged at full rate and log ingestion costs spiked. Moving the state to a non-generic class fixed it — and the per-type behaviour was later reused deliberately for per-component throttling.
The interviewer checks that the candidate knows why generics exist (type safety, no boxing), the vocabulary (open, closed, arity), and one non-obvious behaviour such as per-type statics or reification.
Green flags: mentions reification versus Java erasure; explains per-closed-type statics with an example; knows when not to make something generic.
Red flags: "generics are just templates like C++" with no runtime knowledge; thinks statics are shared; uses typeof(T) switches as a design.
Cache`2 — the backtick and arity in the CLR name).Cache<string, Product> and Cache<int, Product> are distinct types with distinct statics and type identity).typeof(Cache<,>) is the open definition; IsGenericTypeDefinition is true. GetGenericArguments() returns the type arguments of a closed type.__Canon) and shares that code across Cache<string,...>, Cache<Customer,...> etc. Type-specific information (for casts, typeof(T), statics) comes from a runtime dictionary passed implicitly.Cache<int,...>, Cache<Guid,...>), with T known exactly — enabling inlining and no boxing.This is why generic collections of value types are fast, and why many value-type instantiations increase code size (relevant to NativeAOT).
Statics live in per-closed-type storage. The static constructor runs once per closed type, on first use. With shared code for reference types, accessing a static requires looking up the closed type's static base via the generic dictionary — slightly slower than non-generic statics, though the JIT optimises common cases.
Generic static caches are a well-known performance trick in the BCL:
EqualityComparer<T>.Default // cached per T, with special handling for IEquatable<T>, enums, nullable
Array.Empty<T>() // one empty array per T
typeof(List<int>) == typeof(List<int>) is true (same constructed type). List<int> and List<long> are unrelated types — no conversion between them (see variance/invariance). Type.MakeGenericType closes types at run time; under NativeAOT that requires the instantiation to be known at compile time (or annotated), otherwise it fails.
Make a class generic when the type parameter is used across multiple members and state (containers, caches, result types Result<T>, repositories over entities). If only one method needs flexibility, make just the method generic. Avoid generics that add parameters without real type-safety benefit (e.g. Service<T> where T is never used meaningfully).
Counter<T> and increment for several type arguments; print each count.typeof(Cache<,>), typeof(Cache<string,int>) and their IsGenericTypeDefinition.List<int> vs ArrayList adding and summing 1 million ints with [MemoryDiagnoser].MakeGenericType and create an instance with Activator.CreateInstance.namespace Generics.Demo;
public sealed record PagedResult<T>(IReadOnlyList<T> Items, int Page, int PageSize, int TotalCount)
{
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
public bool HasNext => Page < TotalPages;
}
public static class Counter<T>
{
public static int Value { get; private set; }
static Counter() => Console.WriteLine($"Static ctor for {typeof(T).Name}");
public static void Increment() => Value++;
}
public static class Program
{
public static void Main()
{
var page = new PagedResult<string>(["a", "b"], Page: 1, PageSize: 2, TotalCount: 5);
Console.WriteLine($"{page.TotalPages} pages, has next: {page.HasNext}");
Counter<int>.Increment();
Counter<int>.Increment();
Counter<string>.Increment();
Console.WriteLine($"int: {Counter<int>.Value}, string: {Counter<string>.Value}"); // 2, 1
Console.WriteLine(typeof(Counter<>).IsGenericTypeDefinition); // True
Console.WriteLine(typeof(Counter<int>).GetGenericTypeDefinition() == typeof(Counter<>)); // True
}
}
What happens: a static lock, counter or registry in a generic class used as global state. Why it is wrong: each closed type has its own statics. Do instead: put shared state in a non-generic class; use per-type statics deliberately.
object instead of genericsWhat happens: reusable code stores object and casts.
Why it is wrong: runtime cast failures and boxing.
Do instead: use type parameters.
What happens: OrderService<T> where T isn't used meaningfully.
Why it is wrong: complexity without benefit; confusing DI registrations.
Do instead: make only the methods that need it generic, or remove the parameter.
What happens: generic code casts T to an interface or checks types at run time.
Why it is wrong: loses compile-time safety and may box.
Do instead: add constraints (where T : IEntity).
What happens: generic statics keyed by many runtime-generated types. Why it is wrong: each closed type keeps statics forever — memory grows. Do instead: use bounded caches for dynamic types.
List<Derived> converts to List<Base>What happens: expecting generic types to follow inheritance of arguments.
Why it is wrong: generic classes are invariant.
Do instead: use covariant interfaces like IEnumerable<out T> / IReadOnlyList<out T>.
What happens: static constructors in generic types do reflection or I/O. Why it is wrong: runs once per closed type and failures break that closed type permanently. Do instead: keep static initialisation light and safe.
MakeGenericTypeWhat happens: runtime type closing in NativeAOT apps. Why it is wrong: instantiations not known at compile time may fail. Do instead: use known instantiations or annotations and test AOT builds.
Seniors use generics to remove duplication and push errors to compile time, but they also watch the cost side: every value-type instantiation produces its own native code, which matters for Native AOT size and start-up, and per-T statics can hide memory growth when T is created dynamically. A good rule is "generic over data, interface over behaviour". I also keep generic public APIs small and predictable, because type parameters leak into every caller.
| Choice | Use when | Watch out for |
|---|---|---|
| Generic class | Same logic for many data types | Per-T statics, code size with many value types |
| Interface parameter | Behaviour differs per type | Boxing if structs pass through the interface |
| object / dynamic | Truly unknown types at compile time | No type safety, slower |
A generic method has its own type parameters, for example static T Max<T>(T a, T b) where T : IComparable<T>. The compiler usually infers T from the arguments, so you call Max(3, 7) without writing <int>. Inference only looks at arguments, never the return type, and it is all or nothing, so for something like Deserialize<T>(string json) you must write the type. I make a method generic when only that operation depends on the type, and the class generic when the object holds typed state.
Constraints tell the compiler what T can do so you can use it safely. C# has class and class? for reference types, struct for non-nullable value types, unmanaged for blittable structs, notnull, new() for a public parameterless constructor, base-class and interface constraints, where T : U, and Enum and Delegate constraints. C# 13 added the allows ref struct anti-constraint so T can be a Span<T>-like type. Interface constraints on struct T also avoid boxing because the JIT emits constrained calls.
Generics are usually zero-cost, but how depends on T. For value types the JIT generates separate, fully specialised code, so there is no boxing and typeof(T) == typeof(int) folds to a constant. For reference types all instantiations share one canonical body, which looks up type-specific data such as statics and typeof(T) through a runtime dictionary. new T() and generic virtual methods take slower paths. The cost of specialisation is code size and JIT time, which matters for Native AOT, where every instantiation must be known at build time.
Last updated 30 Oct 2026.