Generics parameterize code by type. List<T> can store any T while the compiler still checks every read and write. That is a stronger contract than accepting object and recovering the real type with casts. For value types, generic collections also avoid the boxing required by older object-based APIs.
Tis a placeholder for a type chosen by the caller.List<T>is an open generic type definition.List<int>is a closed constructed type.- Constraints (
where T : ...) are capability contracts that unlock members safely. - The runtime specializes generic code for value-type arguments and can share code between reference-type arguments.
Use Cases
- Collections preserve the element or key type throughout an API.
- Algorithms can require only the operations they use, such as comparison or numeric operators.
- Generic result and handler types can keep payload types explicit without duplicating infrastructure.
A generic abstraction should still represent one real behavior. IRepository<TEntity> is not useful merely because every entity happens to support create, read, update, and delete operations. Different aggregates often need different contracts.
Constraints
Constraints tell the compiler what operations are valid for T. They also document the smallest capability the algorithm expects.
where T : class-Tmust be a reference type.where T : struct-Tmust be a non-nullable value type.where T : notnull- nullable analysis warns when a nullable type argument is used.where T : unmanaged-Tis a non-nullable unmanaged type whose fields are also unmanaged. This is not identical to every platform’s definition of blittable.where T : new()-Tmust have a public parameterless constructor.where T : BaseType-Tmust inherit from a specific base type.where T : ISomeInterface-Tmust implement a specific interface.
Variance
Variance controls safe assignment between constructed interface or delegate types when their type arguments are related by inheritance.
- Invariance (default):
List<string>is not assignable toList<object>. - Covariance (
out T): allows a more derived type where a base type is expected for producer-only APIs (for example,IEnumerable<string>toIEnumerable<object>). - Contravariance (
in T): allows a less derived type where a more derived type is expected for consumer-only APIs (for example,IComparer<object>asIComparer<string>). - Variance is supported on interfaces and delegates marked with
in/out, and only for reference-type substitutions.
IEnumerable<string> names = new List<string> { "Ada", "Linus" };
IEnumerable<object> objects = names; // covariance
Action<object> printAny = o => Console.WriteLine(o);
Action<string> printString = printAny; // contravariance
List<string> list = new();
// List<object> invalid = list; // does not compile (invariance)Example
public static T CreateAndValidate<T>()
where T : EntityBase, IValidatable, new()
{
var value = new T();
value.Validate();
return value;
}Generic Math (.NET 7+)
Generic code once had no static contract for operators such as +. Static abstract interface members changed that. INumber<T> declares numeric operators and identities, so a where T : INumber<T> constraint makes arithmetic available on the type parameter.
public static T Sum<T>(ReadOnlySpan<T> values) where T : INumber<T>
{
T total = T.Zero;
foreach (var v in values) total += v; // operators resolved via the interface
return total;
}The same static-member mechanism supports contracts such as IParsable<TSelf>, where parsing belongs to the type rather than an instance.
Reflection over Generics
An open generic such as List<> has unassigned type parameters and cannot be instantiated. Reflection can close it at runtime with MakeGenericType. Generic methods use MakeGenericMethod.
Type closed = typeof(List<>).MakeGenericType(itemType); // e.g. List<Order>
var list = Activator.CreateInstance(closed);where T : struct, Enum admits enum value types, while allows ref struct tells the compiler that a type argument may be stack-only. Code using that anti-constraint must obey the ref-safety rules for every possible T.
Pitfalls
- An unconstrained
Texposes only operations valid for every type. Add the smallest constraint that the algorithm actually needs instead of casting internally. - Static state belongs to each closed type.
Cache<int>andCache<string>receive separate copies of astaticfield declared onCache<T>. - Value-type specialization can grow native code. Separate value-type instantiations can produce separate JIT-compiled bodies. The specialization removes boxing and preserves value semantics, but many large instantiations increase startup and code-size costs.
default(T)isnullfor reference types and zero-initialized data for value types. Either result may look valid in a domain model, so absence should have an explicit contract.- A concrete base-class constraint couples the abstraction to one hierarchy. Prefer a behavioral interface when inheritance is not itself part of the requirement.
Tradeoffs
- Generics versus
object. Non-generic collections box value types when storing them asobject.List<int>keeps integers unboxed and removes casts at the read boundary. - Generics versus inheritance. A generic API carries a concrete type argument through compile-time checks. Inheritance allows one base reference to dispatch to different runtime implementations. The choice is about when variation is known, not a blanket performance rule.
- Specialization versus code sharing. The CLR normally specializes code for value-type arguments and shares compatible code for reference-type arguments. This balances value-type performance against native code size.
Questions
Why does
IEnumerable<string>assign toIEnumerable<object>, butList<string>does not assign toList<object>?
IEnumerable<out T>only produces values, so treating a sequence of strings as a sequence of objects is safe.List<T>also accepts values. If aList<string>could masquerade asList<object>, a caller could insert a non-string and break the original list’s contract.
When should a generic interface type parameter be marked as
outorin?Mark a type parameter as
outwhen the interface only produces values of that type. This is why a producer of strings can safely be used where a producer of objects is expected. Mark it asinwhen the interface only consumes the type, so a consumer that accepts any object can also accept strings.If the interface both accepts and returns the type, it must remain invariant because either conversion could make a read or write unsafe. Variance applies only to reference-type substitutions.
A generic method uses
default(T)as a fallback value. Why can this be dangerous in production code?
default(T)may be0,DateTime.MinValue, a zeroed struct, ornull. Those values can be valid domain data, so a failed lookup becomes indistinguishable from a real result. ATry*contract, nullable result, or explicit result type keeps the distinction visible.