Generic methods: declaration, type inference rules and limits, generic methods inside non-generic types, and design guidance.
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.
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.
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
The compiler infers type arguments from arguments only, in phases:
Max(3, 4) → T has candidates int, int.Select(x => x.Name) infers TResult).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>).
T Create<T>() — you must write Create<Order>().Order o = Deserialize(json); fails (CS0411).Pick("a", 5) — no single type both convert to (it won't choose object automatically).Run(x => x) where nothing fixes x's type.Apply(Console.WriteLine) — many overloads).Use(null) gives no type information.Fixes: specify type arguments explicitly, add typed parameters, cast an argument, or give lambdas explicit parameter types.
Make the method generic when:
JsonSerializer.Deserialize<T>(), ToDictionary<TKey,TValue>(), Enumerable.Select<TSource,TResult>().IMessageBus.PublishAsync<TMessage>(TMessage msg) rather than IMessageBus<TMessage>.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).
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);.
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.
The C# specification's algorithm:
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).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.
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.
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.)
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.
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.
Max(3, 4.5) with a generic Max<T> and inspect the inferred type in the IDE.T Create<T>() and try calling it without type arguments.TResult Apply<T, TResult>(T value, Func<T, TResult> f) and call it with a lambda; then with a method group that has overloads.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);
}
}
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.
What happens: Order o = Deserialize(json); doesn't compile.
Why it is wrong: inference only uses arguments.
Do instead: pass type arguments explicitly.
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.
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.
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.
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>.
null to generic parametersWhat 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.
TWhat 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.
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 |
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.
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.
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.
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.
Last updated 30 Oct 2026.