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

📚 Interview question

How do generic methods work in C#? Explain how the compiler infers type arguments, when inference fails, and when you would make a method generic instead of the whole class.

Generic methods: declaration, type inference rules and limits, generic methods inside non-generic types, and design guidance.

Junior Coding Intermediate api-design csharp generic-methods generics type-inference

🎯 Key points

  • A generic method declares its own type parameters: `T Max<T>(T a, T b) where T : IComparable<T>`
  • Type arguments are inferred from the method arguments, not from the return type or the assignment target
  • Inference is all or nothing: you cannot specify some type arguments and infer the rest
  • Inference fails with only a `null` argument, lambdas without enough type information, or conflicting arguments; then you specify `<T>` explicitly
  • Make the method generic when only that operation varies by type; make the class generic when state is typed
  • Generic methods in non-generic static classes are the basis of LINQ and extension methods
  • Constraints on the method let you call members of T safely

⏱ 30-second 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.

🗣 Full answer (senior)

How I would answer this in an interview

A generic method has its own type parameters, independent of whether its containing class is generic: T Max<T>(T a, T b) where T : IComparable<T>. Callers usually don't specify T — the compiler infers it from the arguments. I'd explain how inference works, when it fails, and when to make a method generic rather than the whole class.

1. Declaring and calling

public static class Guard
{
    public static T NotNull<T>(T? value, [CallerArgumentExpression(nameof(value))] string? name = null) where T : class
        => value ?? throw new ArgumentNullException(name);
}

public static class Collections
{
    public static TValue GetOrAdd<TKey, TValue>(this IDictionary<TKey, TValue> dict, TKey key, Func<TKey, TValue> factory)
    {
        if (!dict.TryGetValue(key, out var value)) dict[key] = value = factory(key);
        return value;
    }
}

var customer = Guard.NotNull(maybeCustomer);           // T inferred as Customer
var name = cache.GetOrAdd(id, k => LoadName(k));        // TKey, TValue inferred from cache's type
var max = Math.Max<long>(a, b);                         // explicit type argument

2. How inference works

The compiler infers type arguments from arguments only, in phases:

  1. Phase 1: for each argument with a known type, collect bounds: Max(3, 4) → T has candidates int, int.
  2. Lambdas are handled later: input types are fixed from other arguments first, then the lambda body is bound to infer output types (that's how Select(x => x.Name) infers TResult).
  3. Phase 2: fix each type parameter to the best common type among its bounds that all candidates convert to. Max(3, 4.5) → T = double.

So inference uses argument types, lambda parameter/return types, and generic structure (e.g. inferring T from IEnumerable<T>).

3. When inference fails

  • No arguments mention T: T Create<T>() — you must write Create<Order>().
  • Return type only: inference never looks at the assignment target: Order o = Deserialize(json); fails (CS0411).
  • Conflicting candidates with no best type: Pick("a", 5) — no single type both convert to (it won't choose object automatically).
  • Lambdas without enough information: Run(x => x) where nothing fixes x's type.
  • Method groups that are ambiguous (Apply(Console.WriteLine) — many overloads).
  • Null literals: Use(null) gives no type information.

Fixes: specify type arguments explicitly, add typed parameters, cast an argument, or give lambdas explicit parameter types.

4. Generic method vs generic class

Make the method generic when:

  • The type parameter is relevant to one operation, not the object's state: JsonSerializer.Deserialize<T>(), ToDictionary<TKey,TValue>(), Enumerable.Select<TSource,TResult>().
  • The class is a service you'd otherwise have to register once per type in DI: IMessageBus.PublishAsync<TMessage>(TMessage msg) rather than IMessageBus<TMessage>.
  • You want inference at call sites (generic types can't infer from constructor arguments, except via factory methods).

Make the class generic when the type parameter shapes state or multiple members: List<T>, Cache<TKey,TValue>, Result<T>, Repository<TEntity>.

A common pattern combines both: a non-generic static factory with a generic method to get inference, returning a generic type: Result.Ok(value) → Result<T>, Tuple.Create(a, b).

5. A production example

A messaging layer started as IPublisher<TMessage> registered for each of 40 message types, so every new message required a DI registration and constructor changes in the publishing services. Changing to a single IMessagePublisher with Task PublishAsync<TMessage>(TMessage message, CancellationToken ct) where TMessage : IMessage removed the per-type registrations, kept type safety (serialiser and topic chosen from TMessage), and made call sites simpler through inference: await publisher.PublishAsync(new OrderPlaced(...), ct);.

🔍 What the interviewer is checking

Checks that the candidate can write generic methods fluently and understands inference rules well enough to design callable APIs.

Green flags: knows inference ignores return types; knows the all-or-nothing rule; can explain why LINQ methods infer from lambdas; makes a reasoned choice between generic method and generic class.

Red flags: cannot explain CS0411; makes every class generic; does not know how constraints let you call members on T.

📚 Learn in depth

1. The inference algorithm in more detail

The C# specification's algorithm:

  • Exact, lower-bound and upper-bound inferences: for parameter type IEnumerable<T> and argument List<string>, the compiler finds IEnumerable<string> via the interface and infers a lower bound T :> string (thanks to covariance, IEnumerable<object> would also accept it).
  • Fixing: each type variable is fixed to a type from its bound set such that every bound is satisfied, choosing the "best" one.
  • Dependent type parameters: for Select<TSource, TResult>(IEnumerable<TSource>, Func<TSource, TResult>), TSource is fixed first from the source; then the lambda is bound with TSource known, and its body's type gives TResult.

C# 10 improved inference for lambdas with natural types and C# 12/13 refined collection expression and params span inference, but the core remains argument-based.

2. Generic methods and code generation

Each distinct instantiation of a generic method with value-type arguments gets its own JIT-compiled code; reference-type instantiations share code with a generic dictionary parameter. Generic virtual methods (virtual T Get<T>()) are more expensive to dispatch: the runtime must find the instantiation per call (via a lookup), which is slower than a normal virtual call — avoid them on hot paths.

3. Constraints on method type parameters

Constraints are part of the method's contract but not part of the signature for overloading: you can't overload M<T>(T x) where T : class with M<T>(T x) where T : struct (same signature). Overload resolution ignores constraints when choosing candidates, then reports an error if the chosen one's constraints aren't satisfied — a source of confusing errors. (C# 7.3 improved this: candidates with unsatisfied constraints are removed in more cases.)

4. Explicit type arguments and partial inference

C# doesn't support partial type argument inference: if a method has two type parameters and you want to specify one, you must specify both (Map<Order, OrderDto>(order)). A workaround is splitting into two calls (Map(order).To<OrderDto>()) where the first infers and the second is explicit.

5. Generic extension methods

Most LINQ is generic extension methods over IEnumerable<T>. Constraint design affects discoverability: public static T Clamp<T>(this T value, T min, T max) where T : IComparable<T> appears on every comparable type in IntelliSense — keep such extensions in specific namespaces.

Try it yourself

  1. Call Max(3, 4.5) with a generic Max<T> and inspect the inferred type in the IDE.
  2. Write T Create<T>() and try calling it without type arguments.
  3. Write TResult Apply<T, TResult>(T value, Func<T, TResult> f) and call it with a lambda; then with a method group that has overloads.
  4. Refactor a per-type generic service interface into a single interface with a generic method.

💻 Code

namespace Generics.Methods;

public static class Algorithms
{
    public static T Max<T>(T a, T b) where T : IComparable<T>
        => a.CompareTo(b) >= 0 ? a : b;

    public static T Clamp<T>(T value, T min, T max) where T : IComparable<T>
        => value.CompareTo(min) < 0 ? min : value.CompareTo(max) > 0 ? max : value;

    public static TResult[] MapAll<TSource, TResult>(IReadOnlyList<TSource> source, Func<TSource, TResult> map)
    {
        var result = new TResult[source.Count];
        for (var i = 0; i < source.Count; i++)
        {
            result[i] = map(source[i]);
        }
        return result;
    }
}

public sealed class RetryPolicy(int maxAttempts)
{
    public async Task<T> ExecuteAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct)
    {
        for (var attempt = 1; ; attempt++)
        {
            try
            {
                return await action(ct);
            }
            catch (HttpRequestException) when (attempt < maxAttempts)
            {
                await Task.Delay(TimeSpan.FromMilliseconds(200 * attempt), ct);
            }
        }
    }
}

public static class Program
{
    public static async Task Main()
    {
        Console.WriteLine(Algorithms.Max(3, 7));          // T = int
        Console.WriteLine(Algorithms.Max(3, 7L));         // T = long
        Console.WriteLine(Algorithms.Clamp(new DateOnly(2030, 1, 1), DateOnly.MinValue, new DateOnly(2026, 12, 31)));

        string[] lengths = Algorithms.MapAll([1, 22, 333], n => n.ToString()); // TSource = int, TResult = string
        Console.WriteLine(string.Join(",", lengths));

        var retry = new RetryPolicy(maxAttempts: 3);
        var answer = await retry.ExecuteAsync(_ => Task.FromResult(42), CancellationToken.None); // T = int inferred
        Console.WriteLine(answer);
    }
}

⚠ Common mistakes

1. Making the whole class generic for one method

What happens: Serializer<T> with a single Serialize(T) method. Why it is wrong: callers need one instance per type and DI registrations multiply. Do instead: make the method generic on a non-generic class.

2. Expecting inference from return types

What happens: Order o = Deserialize(json); doesn't compile. Why it is wrong: inference only uses arguments. Do instead: pass type arguments explicitly.

3. Overloading on constraints

What happens: two overloads differing only by where T : class and where T : struct. Why it is wrong: same signature — compile error. Do instead: use different method names or parameter types.

4. Generic virtual methods on hot paths

What happens: frequently called virtual T Get<T>(). Why it is wrong: more expensive dispatch than normal virtual calls. Do instead: use non-virtual generic methods or generic interfaces.

5. Too many type parameters

What happens: methods with four or five type parameters that callers must specify. Why it is wrong: hard to read and use; no partial inference. Do instead: simplify or split into fluent steps that infer.

6. Missing constraints, then runtime type checks

What happens: if (value is IComparable c) inside generic code. Why it is wrong: boxing and runtime failures. Do instead: add where T : IComparable<T>.

7. Passing null to generic parameters

What happens: Use(null) can't infer T. Why it is wrong: compile error or wrong inferred type with casts. Do instead: specify the type or use default(T) with explicit type.

8. Polluting IntelliSense with generic extensions on T

What happens: public static void Log<T>(this T value) in a common namespace. Why it is wrong: appears on every type everywhere. Do instead: constrain the extension or put it in a dedicated namespace.

🏛 Senior insight: trade-offs

I design generic methods around how they read at the call site. If T can be inferred, callers never see it and the API feels natural; if it cannot, T should come first and be obvious (Get<T>(key)). I avoid more than two type parameters unless it is infrastructure code, because inference failures produce confusing CS0411 errors for colleagues. For Native AOT and trimming, generic methods called with types only known at run time (MakeGenericMethod) are a warning sign, so I prefer compile-time-known instantiations.

Situation Choice
Stateless helper over many types Generic method in a static or non-generic class
Typed state (cache, repository) Generic class
T only known at run time Non-generic overload taking Type, or reflection with AOT annotations

🔗 Follow-ups the interviewer may ask

  1. When: Candidate explains inference from arguments. Why: Generic inference is one part of the wider type-inference rules (var, target-typed new, lambdas).
    How to answer

    Type inference lets the compiler work out a type from context. var takes the static type of the initialiser, so the variable is still strongly typed; it is required for anonymous types. Target-typed new() and collection expressions (List<int> xs = [1, 2]) go the other way, taking the type from the declaration. Generic arguments are inferred from method arguments, and since C# 10 lambdas have a natural type. I use var when the type is obvious from the right-hand side and write the type when it is not, or when the exact type changes behaviour, like IQueryable<T> versus IEnumerable<T> in EF Core.

  2. When: Candidate writes where T : IComparable<T>. Why: Generic methods need constraints to call CompareTo or arithmetic on T.
    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 DI registrations per closed type. Why: Moving T from class to method changes DI registrations; open generics are the alternative.
    How to answer

    You register the open types, services.AddScoped(typeof(IRepository<>), typeof(Repository<>)), and when someone asks for IRepository<Order> the container closes the implementation with MakeGenericType and builds it, caching that plan. An exact closed registration for IRepository<Order> wins over the open one, while IEnumerable<IRepository<Order>> returns both. Lifetimes are per closed type. Microsoft DI has no decorator support, so I use Scrutor's Decorate for open generics or write it by hand. ValidateOnBuild cannot check open generics, so I add tests that resolve real closed types.

🛠 Real-world problems: how you handle them

Generic service class multiplied DI registrations
Situation
A shared library exposed `CacheService<T>` and every API had to register `CacheService<Order>`, `CacheService<Customer>` and so on. New entity types regularly broke start-up with missing registrations.
Signals
InvalidOperationException 'Unable to resolve service for type CacheService`1[...]' after deployments; PRs touching Program.cs for every new entity.
Investigation
Read the class and found that no field depended on T: it wrapped `IDistributedCache` and only `GetOrCreateAsync` used T for serialisation. Checked call sites with a solution-wide search.
Resolution
Changed it to a non-generic `CacheService` with `Task<T> GetOrCreateAsync<T>(string key, Func<CancellationToken, Task<T>> factory, ...)`. One registration; call sites unchanged apart from the type name because T was inferred from the factory.
Prevention
Added a review guideline: a class is generic only if it stores T. Added a start-up test that builds the provider with ValidateOnBuild.

How to tell it (STAR)

**S:** Our shared caching library forced every service to register a cache class per entity type, and we had [number] start-up failures in [period]. **T:** I was asked to make it harder to misuse. **A:** I noticed no state depended on the type, so I moved the type parameter from the class to the method, kept the same behaviour, and added a ValidateOnBuild test. **R:** One registration replaced [number], and we had no more missing-registration incidents.

↩ Usually asked after

More in Generics

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me