📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What are generic classes in C#? Why do generics exist, how do you declare and close a generic type, and how do static fields behave for each constructed type such as Cache<int> and Cache<string>?

Generic classes: type parameters, open versus closed types, reification, per-type static state and when a class should be generic.

Junior Theory Beginner csharp generic-classes generics static-fields type-safety

🎯 Key points

  • A generic class is a template with type parameters (`class Box<T>`), closed by supplying type arguments (`Box<int>`)
  • Generics give compile-time type safety and remove casts and boxing compared with object-based classes such as ArrayList
  • Open type `Box<>` versus constructed (closed) type `Box<int>`; each closed type is a distinct runtime type
  • Generics are reified in the CLR: `typeof(T)` and `List<int>` exist at run time, unlike Java type erasure
  • Static fields are per closed type: `Cache<int>.Count` and `Cache<string>.Count` are separate
  • Use `default(T)` and constraints to work with T; you cannot assume operators or constructors without constraints
  • Make a class generic only when the same logic genuinely works for many types

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Why generics exist

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().

2. Declaring and closing a generic type

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.
  • Type parameters can have constraints (where TKey : notnull) that restrict arguments and let you call members on them.
  • Nested types inherit the outer type parameters.
  • You can close a generic at run time: typeof(Cache<,>).MakeGenericType(typeof(int), typeof(string)) — that's how DI closes open generic registrations.

3. Static fields are per constructed type

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.

4. A production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Representation in metadata and runtime

  • The compiler emits the generic type definition once, with type parameters in metadata (Cache`2 — the backtick and arity in the CLR name).
  • At run time, each constructed type gets its own MethodTable (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.

2. Code sharing vs specialisation

  • For reference-type arguments, the JIT compiles the methods once using a canonical placeholder (__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.
  • For value-type arguments, the JIT compiles separate specialised code per type (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).

3. Static fields and static constructors

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

4. Generic type identity and reflection

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.

5. When to make a class generic

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).

Try it yourself

  1. Create Counter<T> and increment for several type arguments; print each count.
  2. Print typeof(Cache<,>), typeof(Cache<string,int>) and their IsGenericTypeDefinition.
  3. Benchmark List<int> vs ArrayList adding and summing 1 million ints with [MemoryDiagnoser].
  4. Close a generic type with MakeGenericType and create an instance with Activator.CreateInstance.

💻 Code

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
    }
}

⚠ Common mistakes

1. Expecting statics to be shared across type arguments

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.

2. Using object instead of generics

What happens: reusable code stores object and casts. Why it is wrong: runtime cast failures and boxing. Do instead: use type parameters.

3. Unnecessary generic classes

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.

4. Missing constraints

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).

5. Unbounded generic static caches

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.

6. Assuming 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>.

7. Heavy work in generic static constructors

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.

8. Ignoring AOT implications of MakeGenericType

What 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.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says a class should be generic only when it stores T. Why: Tests whether the candidate can choose between a generic class and a generic method.
    How to answer

    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.

  2. When: Candidate mentions you cannot call members on T without constraints. Why: Unconstrained T can do very little; constraints are the natural next step.
    How to answer

    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.

  3. When: Candidate mentions reification or per-type MethodTables. Why: Probes how reified generics are compiled and shared at run time.
    How to answer

    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.

More in Generics

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me