C# generics let you write a type or method once and use it safely with many types, without casts, boxing or duplicated code. Most developers use List<T> and Task<T> every day, but the details of generics decide whether an API is flexible, fast and safe. This guide goes deep on the parts senior engineers are expected to know: every kind of constraint up to C# 13's allows ref struct, covariance and contravariance with their safety rules, how the CLR shares and specializes generic code, static abstract interface members and generic math, and generic caching patterns, with notes for .NET 8, .NET 10 and the upcoming .NET 11.
Why Generics? What They Solve#
Before C# 2.0 introduced generics in 2005, reusable collections stored object. Every read needed a cast that could fail at run time, and every value type was boxed onto the heap on the way in. Generics fixed three problems at once:
- Type safety.
List<Order>rejects aCustomerat compile time instead of throwingInvalidCastExceptionlater. - Performance.
List<int>stores integers inline, with no boxing and no casts. - Reuse with intent. One implementation serves every type, and constraints document exactly what the implementation needs from its type arguments.
Unlike Java, where generic type information is erased at compile time, .NET generics are reified: the runtime knows that a List<int> is a List<int>. That is what makes value-type specialization, per-type static fields and reflection over closed generic types possible.
How Generics Work in the CLR#
The C# compiler emits one generic definition with type parameters into IL. The runtime then decides how to produce native code for each instantiation, and the rules differ by kind of type argument:
- Value types get specialized code.
List<int>,List<long>andList<Point>each receive their own compiled method bodies, because their sizes and layouts differ. Inside such a body,Tis a known type, so the JIT can inline, avoid boxing and fold type checks away. - Reference types share code. All reference-type instantiations, such as
List<string>andList<Order>, use one canonical body, because every reference has the same size. When the shared code needs something specific to the actual type argument, such astypeof(T), a cast ornew T(), it looks the answer up in a generic dictionary attached to the instantiation. - Every closed type is still distinct. Each closed constructed type has its own type identity and its own set of static fields, even when its code is shared.
using System.Runtime.CompilerServices;
Console.WriteLine(Counter<int>.Next()); // 1
Console.WriteLine(Counter<int>.Next()); // 2
Console.WriteLine(Counter<string>.Next()); // 1: another closed type, another static field
Console.WriteLine(SizeHint<long>()); // 8
static int SizeHint<T>() where T : struct
{
// Value-type instantiations are compiled per T, so these checks become
// constants and the untaken branches disappear from the machine code.
if (typeof(T) == typeof(int)) return sizeof(int);
if (typeof(T) == typeof(long)) return sizeof(long);
return Unsafe.SizeOf<T>();
}
static class Counter<T>
{
private static int s_count;
public static int Next() => Interlocked.Increment(ref s_count);
}This model has practical consequences. Generic code over value types is as fast as hand-written specialized code, which is why the base libraries rely on typeof(T) == typeof(...) branches in vectorized helpers. Generic code over reference types is compact but pays a small cost for dictionary lookups. Native AOT must know every value-type instantiation at compile time, so APIs such as Type.MakeGenericType carry RequiresDynamicCode warnings. See inside the CLR for how method tables and JIT compilation fit together.
Getting Started: Generic Methods and Types#
A generic method declares type parameters after its name, and the compiler usually infers them from the arguments. A generic type is a template that becomes a concrete type when you supply type arguments:
int level = Guard.Clamp(150, 0, 100); // T inferred as int, no boxing
DateOnly due = Guard.Clamp(
new DateOnly(2031, 1, 1), DateOnly.MinValue, new DateOnly(2030, 12, 31));
var recent = new BoundedBuffer<string>(capacity: 3);
foreach (var item in new[] { "a", "b", "c", "d" })
recent.Add(item); // keeps b, c, d
public static class Guard
{
public static T Clamp<T>(T value, T min, T max) where T : IComparable<T>
{
if (min.CompareTo(max) > 0)
throw new ArgumentException($"{nameof(min)} must not exceed {nameof(max)}.");
if (value.CompareTo(min) < 0) return min;
return value.CompareTo(max) > 0 ? max : value;
}
}
public sealed class BoundedBuffer<T>(int capacity)
{
private readonly Queue<T> _items = new(capacity);
public IReadOnlyCollection<T> Items => _items;
public void Add(T item)
{
if (_items.Count == capacity) _items.Dequeue();
_items.Enqueue(item);
}
}The IComparable<T> constraint is what allows Clamp to call CompareTo. Because the constraint is generic, calls on value types such as int and DateOnly go straight to their implementations without boxing.
Generic Constraints in C#
Constraints restrict which type arguments are legal and, in exchange, tell the compiler which operations are available on T. The set has grown steadily since C# 2.0:
| Constraint | Meaning | Since |
|---|---|---|
where T : class | Reference type; non-nullable in a nullable context | C# 2.0 |
where T : class? | Reference type, nullable or not | C# 8 |
where T : struct | Non-nullable value type | C# 2.0 |
where T : new() | Public parameterless constructor; must come last | C# 2.0 |
where T : BaseType or IInterface | Derives from a class or implements an interface | C# 2.0 |
where T : U | Must be or derive from another type parameter | C# 2.0 |
where T : notnull | Non-nullable value type or reference type | C# 8 |
where T : unmanaged | Value type with no references anywhere inside | C# 7.3 |
where T : Enum or Delegate | Any enum or delegate type | C# 7.3 |
where T : default | Unconstrained T? in overrides and explicit implementations | C# 9 |
where T : allows ref struct | Anti-constraint: T may be a ref struct | C# 13 |
The combination rules are strict: at most one of struct, class, class?, notnull and unmanaged, and it must come first; new() must be last; and allows ref struct follows everything else. A tour of the most useful ones:
public static class ConstraintExamples
{
// class + new(): reference types that can be created without arguments
public static T CreateConfigured<T>(Action<T> configure) where T : class, new()
{
var instance = new T();
configure(instance);
return instance;
}
// struct + Enum: enum-only helpers with no casts to int
public static TEnum ParseOrDefault<TEnum>(string? text, TEnum fallback)
where TEnum : struct, Enum =>
Enum.TryParse(text, ignoreCase: true, out TEnum value) ? value : fallback;
// unmanaged: no references inside, so the memory can be viewed as raw bytes
public static ReadOnlySpan<byte> AsBytes<T>(ReadOnlySpan<T> values) where T : unmanaged =>
MemoryMarshal.AsBytes(values);
}
// notnull: keys may be value types or non-nullable references, never null
public sealed class TtlCache<TKey, TValue>(TimeProvider clock) where TKey : notnull
{
private readonly ConcurrentDictionary<TKey, (TValue Value, DateTimeOffset Expires)> _items =
new();
public void Set(TKey key, TValue value, TimeSpan ttl) =>
_items[key] = (value, clock.GetUtcNow() + ttl);
public bool TryGet(TKey key, [MaybeNullWhen(false)] out TValue value)
{
if (_items.TryGetValue(key, out var entry) && entry.Expires > clock.GetUtcNow())
{
value = entry.Value;
return true;
}
value = default;
return false;
}
}Constraints are part of your public contract. Adding one later is a breaking change for callers, while removing one is safe, so start with the minimum your implementation genuinely needs.
allows ref struct: Generics Over Spans#
Until C# 13, a ref struct such as Span<T> could not be a type argument, which kept generic algorithms and span-based code apart. C# 13 added the allows ref struct anti-constraint, together with the ability for ref struct types to implement interfaces. A type parameter marked this way can accept a ref struct, so the generic code must obey ref-safety rules: no boxing, no storage in static fields and no escaping to the heap. .NET 9 used it throughout the libraries, including Func and Action delegates and span-based dictionary lookups:
// .NET 9: look up string keys with spans, without allocating a string per word.
var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var lookup = counts.GetAlternateLookup<ReadOnlySpan<char>>();
ReadOnlySpan<char> text = "the quick brown fox jumps over the lazy dog";
foreach (Range range in text.Split(' '))
{
ReadOnlySpan<char> word = text[range];
lookup[word] = lookup.TryGetValue(word, out int count) ? count + 1 : 1;
}
var sink = new WordCounter();
Tokenizer.Tokenize(text, ref sink);
Console.WriteLine(sink.Count); // 9
public interface ITokenSink
{
void Accept(ReadOnlySpan<char> token);
}
public static class Tokenizer
{
// TSink may be a ref struct; calls are constrained, so nothing is boxed.
public static void Tokenize<TSink>(ReadOnlySpan<char> text, ref TSink sink)
where TSink : ITokenSink, allows ref struct
{
foreach (Range range in text.Split(' '))
sink.Accept(text[range]);
}
}
public ref struct WordCounter : ITokenSink
{
public int Count;
public void Accept(ReadOnlySpan<char> token) { if (!token.IsEmpty) Count++; }
}Because WordCounter is a value type, the JIT compiles a dedicated Tokenize<WordCounter> body in which the Accept call can be inlined. That combination of specialization and ref safety is how modern .NET builds zero-allocation pipelines. The Span and Memory guide covers ref-safety rules in detail.
Covariance and Contravariance (in and out)#
Variance defines when a constructed generic type converts to another constructed type whose type arguments are related by inheritance. By default, type parameters are invariant: List<string> is not a List<object>, even though string derives from object. Interfaces and delegates can opt in to variance per type parameter:
| Kind | Keyword | T may appear | Examples |
|---|---|---|---|
| Covariance | out | Only in output positions, such as return types | IEnumerable<out T>, IReadOnlyList<out T>, Func<out TResult> |
| Contravariance | in | Only in input positions, such as parameters | IComparer<in T>, IEqualityComparer<in T>, Action<in T> |
| Invariance | none | Anywhere | List<T>, IList<T>, Dictionary<TKey, TValue> |
The safety argument is intuitive once you picture the data flow. A sequence that only produces strings can safely be treated as a sequence that produces objects. A comparer that consumes any Shape can safely compare circles. A list both produces and consumes elements, so treating a List<string> as a List<object> would let someone add an integer to a list of strings:
IEnumerable<string> names = ["Ada", "Grace"];
IEnumerable<object> items = names; // covariance: producer of T
Action<object> log = o => Console.WriteLine(o);
Action<string> logName = log; // contravariance: consumer of T
IComparer<Shape> byArea = Comparer<Shape>.Create((a, b) => a.Area.CompareTo(b.Area));
List<Circle> circles = [new Circle(2), new Circle(1)];
circles.Sort(byArea); // IComparer<Shape> acts as IComparer<Circle>
// List<object> list = new List<string>(); // error: List<T> is invariant
// IEnumerable<object> numbers = new List<int>(); // error: variance needs reference types
object[] array = new string[1]; // legacy array covariance compiles...
array[0] = 42; // ...but throws ArrayTypeMismatchException
public abstract record Shape { public abstract double Area { get; } }
public sealed record Circle(double Radius) : Shape
{
public override double Area => Math.PI * Radius * Radius;
}The rules have sharp edges. Variance applies only to interfaces and delegates, never to classes or structs. It applies only when the type arguments are reference types, because converting int to object requires boxing, not a reference conversion. ref, in and out parameters cannot use variant type parameters, and a covariant T cannot appear in a method's generic constraints. Arrays are the historical exception: they are covariant but unsafe, which is why the runtime checks every array store and why C# 14's span conversions prefer ReadOnlySpan<T> over Span<T> for covariant arrays.
Design your own interfaces around variance when they are clearly producers or consumers. Splitting a read interface from a write interface is often what makes variance possible:
// An audit handler for all integration events also handles OrderPlaced events.
// AuditHandler implements IMessageHandler<IntegrationEvent>.
IMessageHandler<OrderPlaced> handler = new AuditHandler();
// Producer: T appears only in outputs, so the interface can be covariant.
public interface IReadRepository<out T>
{
T? Find(Guid id);
IReadOnlyList<T> List(int skip, int take);
}
// Consumer: T appears only in inputs, so the interface can be contravariant.
public interface IMessageHandler<in TMessage>
{
Task HandleAsync(TMessage message, CancellationToken ct);
}Static Abstract Interface Members and Generic Math#
For most of C#'s history, interfaces could only describe instance members, so there was no way to write T.Zero or left + right for a generic T. C# 11 introduced static abstract and static virtual interface members, and .NET 7 used them to add generic math: a family of interfaces in System.Numerics that describe number-like types. INumber<T> roughly corresponds to a real number and brings operators, comparisons, T.Zero, T.One and conversions; finer-grained interfaces such as IAdditionOperators<TSelf, TOther, TResult> and IAdditiveIdentity<TSelf, TResult> let you ask for exactly what you use. The built-in numeric types implement them all.
double mean = Stats.Average<int, double>([3, 4, 8]); // 5
decimal price = Stats.Average<decimal, decimal>([9.99m, 12.50m]); // 11.245
public static class Stats
{
public static TResult Average<T, TResult>(ReadOnlySpan<T> values)
where T : INumber<T>
where TResult : INumber<TResult>
{
if (values.IsEmpty)
throw new ArgumentException("At least one value is required.", nameof(values));
TResult sum = TResult.Zero;
foreach (T value in values)
sum += TResult.CreateChecked(value); // throws OverflowException if it can't fit
return sum / TResult.CreateChecked(values.Length);
}
}The three conversion families behave differently when a value does not fit. CreateChecked throws an OverflowException, CreateSaturating clamps to the target's minimum or maximum, and CreateTruncating wraps around by keeping the low bits. Choosing between them is a correctness decision, not a style choice.
Your own types can join in by implementing only the interfaces they need. The self-referencing TSelf pattern tells the interface which concrete type implements it, so the static members can be strongly typed:
Money[] payments = [new(10m), new(5.50m), new(4.50m)];
Money total = Accumulator.Sum(payments); // Money { Amount = 20.00 }
public readonly record struct Money(decimal Amount)
: IAdditionOperators<Money, Money, Money>, IAdditiveIdentity<Money, Money>
{
public static Money AdditiveIdentity => new(0m);
public static Money operator +(Money left, Money right) => new(left.Amount + right.Amount);
}
public static class Accumulator
{
public static T Sum<T>(IEnumerable<T> items)
where T : IAdditionOperators<T, T, T>, IAdditiveIdentity<T, T>
{
T total = T.AdditiveIdentity;
foreach (T item in items)
total += item;
return total;
}
}IAdditionOperators declares operator + as static abstract and provides a default for the checked variant, so implementing the ordinary operator is enough. The compiler emits calls to static abstract members as constrained calls through the type parameter, and in specialized value-type instantiations the JIT binds them directly to the concrete operator, so there is no virtual dispatch. Generic math is primarily a tool for library authors, because it removes the need for one overload per numeric type; application code benefits indirectly as libraries adopt it. C# 14 extension members can add operators to types you don't own, but an extension operator does not make a type satisfy a generic math interface constraint.
Generic Caching Patterns#
Because every closed type has its own static fields, a static generic class makes a fast, lock-free, per-type cache. The runtime runs a type's static initializer exactly once and thread-safely, so reads after the first access are ordinary field loads. The base libraries use this pattern for EqualityComparer<T>.Default, Comparer<T>.Default and Array.Empty<T>():
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
public static class CsvSchema<
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>
{
// Computed once per closed type, on first use, with no locks on later reads.
public static readonly PropertyInfo[] Columns =
typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
public static readonly string Header = string.Join(',', Columns.Select(p => p.Name));
}
public static class CsvWriter
{
public static void WriteHeader<
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(
TextWriter writer) => writer.WriteLine(CsvSchema<T>.Header);
}The pattern has three caveats. Entries are never evicted, so do not key it on unbounded, dynamically created types. An exception in a static initializer surfaces as TypeInitializationException and leaves that closed type unusable for the life of the process, so keep initializers simple. And reflection-based caches need trimming annotations like the DynamicallyAccessedMembers attributes above to work with trimming and Native AOT. When the cached data can be computed at compile time, a source generator is usually the better answer.
Best Practices#
- Constrain to the minimum. Ask only for the interfaces and capabilities your implementation uses, because every constraint is a public promise.
- Prefer generic interface constraints over
objectparameters to avoid boxing value types and casts. - Split producer and consumer interfaces so they can be covariant and contravariant.
- Use
IReadOnlyList<T>orIEnumerable<T>in public signatures instead of invariantList<T>when callers only read. - Pick the right generic math conversion.
CreateChecked,CreateSaturatingandCreateTruncatingencode different overflow policies. - Add
allows ref structto generic APIs that should accept spans, and design them to respect ref safety from the start. - Keep static generic caches small and side-effect free, and annotate reflection for trimming.
Common Pitfalls#
- Expecting
List<Derived>to convert toList<Base>. Classes are invariant; exposeIEnumerable<T>orIReadOnlyList<T>instead. - Relying on array covariance. It compiles but can throw
ArrayTypeMismatchExceptionon write and adds a type check to every store. - Using variance with value types.
IEnumerable<int>never converts toIEnumerable<object>; project withCast<object>()orSelect. - Using
where T : structto mean "no nulls". Usenotnullwhen both reference and value types are acceptable. - Assuming shared code is free. Reference-type instantiations share one body, so heavy use of
typeof(T)ornew T()inside it pays for dictionary lookups; value types get specialized bodies but increase code size. - Adding constraints to a published API. It breaks existing callers whose type arguments no longer qualify.
Frequently Asked Questions#
What is the difference between covariance and contravariance in C#?#
Covariance (out) lets a generic interface or delegate that produces T be used where one producing a base type is expected, as with IEnumerable<string> to IEnumerable<object>. Contravariance (in) lets one that consumes T be used where one consuming a derived type is expected, as with IComparer<Shape> to IComparer<Circle>. Both apply only to interfaces and delegates, and only with reference-type arguments.
Why is List of string not a List of object?#
List<T> uses T both as input (Add) and output (the indexer), so allowing the conversion would let code add an integer to a list of strings through the List<object> view. The CLR also supports variance only on interfaces and delegates, not classes. Use IEnumerable<object> or IReadOnlyList<object>, which are covariant, when you need a read-only view.
How does the CLR implement generics for value types and reference types?#
Each value-type instantiation gets its own specialized native code, so there is no boxing and T-specific checks are compiled away. All reference-type instantiations share one canonical code body that uses a generic dictionary to look up type-specific information at run time. Every closed type still has its own identity and static fields.
What is generic math in .NET?#
Generic math is a set of interfaces in System.Numerics, introduced in .NET 7, that use C# 11 static abstract interface members to describe number-like types. Constraining T to INumber<T> lets you use operators, T.Zero, T.One and conversions such as T.CreateChecked in generic code. All built-in numeric types implement the interfaces, and your own types can too.
When should I use the allows ref struct constraint?#
Use it when a generic type or method should accept ref struct type arguments such as Span<T>, ReadOnlySpan<T> or your own ref structs. It requires C# 13 and .NET 9 or later, and your code must follow ref-safety rules for T, meaning no boxing and no storage on the heap.
Summary#
- Generics give type safety, boxing-free performance and reuse; .NET generics are reified at run time.
- The CLR specializes code for each value type and shares one body across reference types, with separate static fields per closed type.
- Constraints range from
classandnew()tonotnull,unmanaged,Enumand C# 13'sallows ref struct. - Variance (
outandin) works only on interfaces and delegates with reference types; arrays are covariant but unsafe. - Static abstract interface members power generic math through
INumber<T>and the operator interfaces. - Static generic classes provide lock-free per-type caches, with care for eviction, initialization failures and trimming.