Basic generics are straightforward, but advanced features like constraints, covariance, and contravariance unlock sophisticated type relationships. These features enable type-safe generic code that works across inheritance hierarchies and supports flexible APIs. Understanding them transforms generics from a convenience into a powerful type system tool.
In this deep dive, we'll explore type constraints, variance modifiers (in
/out
), and practical patterns. You'll learn to write generic code that adapts to different types while maintaining compile-time safety.
Type Constraints
Constraints limit generic type parameters to specific capabilities. Without them, you can only use methods from object
. Constraints enable type-specific operations.
public class Repository<T> where T : class, IEntity, new()
{
public void Save(T entity)
{
entity.Id = Guid.NewGuid(); // IEntity constraint
entity.CreatedAt = DateTime.Now;
}
public T Create()
{
return new T(); // new() constraint
}
}
Multiple constraints combine with commas. class
ensures reference type, IEntity
provides interface methods, new()
enables instantiation.
Common Constraint Types
Different constraints serve different purposes. Choose based on what your generic code needs to do.
// Reference type constraint
public void ProcessReference<T>(T item) where T : class
{
if (item == null) return; // Can check for null
}
// Value type constraint
public void ProcessValue<T>(T item) where T : struct
{
// T is definitely not null
}
// Interface constraint
public void SortItems<T>(List<T> items) where T : IComparable<T>
{
items.Sort(); // Can compare items
}
// Base class constraint
public void ProcessShape<T>(T shape) where T : Shape
{
shape.Draw(); // Inherits from Shape
}
Each constraint type enables specific operations. Interface constraints allow calling interface methods, base class constraints enable inheritance-based operations.
Covariance and Contravariance
Variance describes how generic types relate to inheritance. Covariance preserves direction (out
), contravariance reverses it (in
).
// Covariant - preserves inheritance direction
public interface IProducer<out T>
{
T Produce();
}
// Contravariant - reverses inheritance direction
public interface IConsumer<in T>
{
void Consume(T item);
}
out
means the type parameter only appears in output positions (return types). in
means it only appears in input positions (parameters).
Covariance in Action
Covariance allows treating generic types with derived types as their base types.
public class FruitProducer : IProducer<Fruit> { /* ... */ }
public class AppleProducer : IProducer<Apple> { /* ... */ }
// Covariance allows this assignment
IProducer<Fruit> fruitProducer = new AppleProducer();
// This works because Apple is a Fruit
Fruit fruit = fruitProducer.Produce();
Without covariance, you'd need explicit casting. Covariance makes generic types work naturally with inheritance.
Contravariance in Action
Contravariance allows treating generic types with base types as their derived types.
public class FruitConsumer : IConsumer<Fruit>
{
public void Consume(Fruit item) => Console.WriteLine('Consumed fruit');
}
// Contravariance allows this
IConsumer<Apple> appleConsumer = new FruitConsumer();
// Apple can be consumed as Fruit
appleConsumer.Consume(new Apple());
Contravariance seems counterintuitive but enables flexible APIs. A consumer that accepts any fruit can certainly accept apples.
Real-World Example: Generic Cache
Let's build a generic cache that uses constraints and variance for type safety and flexibility.
public interface ICache<in TKey, out TValue>
{
TValue Get(TKey key);
void Set(TKey key, TValue value);
}
public class MemoryCache<TKey, TValue> : ICache<TKey, TValue>
where TKey : notnull
{
private readonly Dictionary<TKey, TValue> _cache = new();
public TValue Get(TKey key)
{
return _cache.TryGetValue(key, out var value) ? value : default;
}
public void Set(TKey key, TValue value)
{
_cache[key] = value;
}
}
Let's build a practical example that brings together constraints and variance. This generic cache demonstrates how to create a type-safe, flexible caching system.
The interface uses both contravariance and covariance. in TKey
means the cache can accept more specific key types than expected. out TValue
means it can return more general value types.
The notnull
constraint on the key ensures we can't use null as a cache key, which prevents common bugs. This is a newer C# 8+ constraint that provides compile-time null safety.
The implementation uses a simple dictionary internally, but the generic constraints and variance make it much more flexible. You could cache with string keys but retrieve with object keys, or vice versa.
The Get
method returns default
if the key isn't found. For reference types this is null, for value types it's the zero value. This design choice makes the API simple but requires callers to handle missing keys.
Generic Methods with Variance
Methods can also use variance through type parameters. This enables flexible generic methods.
public static class CollectionUtils
{
public static void AddRange<T>(ICollection<T> collection, IEnumerable<T> items)
{
foreach (var item in items)
{
collection.Add(item);
}
}
public static IEnumerable<TResult> ConvertAll<T, TResult>(
IEnumerable<T> source, Func<T, TResult> converter)
{
foreach (var item in source)
{
yield return converter(item);
}
}
}
Generic methods work with type inference. The compiler figures out type parameters from usage.
var numbers = new List<int> { 1, 2, 3 };
var strings = CollectionUtils.ConvertAll(numbers, n => n.ToString());
// Compiler infers T=int, TResult=string
Advanced Pattern: Curiously Recurring Template Pattern
CRTP enables static polymorphism through generics. A base class constrains its type parameter to derived types.
public abstract class Shape<TSelf> where TSelf : Shape<TSelf>
{
public abstract double Area { get; }
public void Draw()
{
Console.WriteLine($'Drawing {typeof(TSelf).Name} with area {Area}');
}
}
public class Circle : Shape<Circle>
{
public double Radius { get; set; }
public override double Area => Math.PI * Radius * Radius;
}
The Curiously Recurring Template Pattern (CRTP) is a fascinating technique that enables static polymorphism through generics. It's 'curious' because the base class constrains its type parameter to be derived from itself.
Look at the constraint: where TSelf : Shape<TSelf>
. This means any class inheriting from Shape<TSelf>
must pass itself as the type parameter. So Circle
inherits from Shape<Circle>
.
This gives the base class knowledge of the exact derived type at compile time. The Draw
method can use typeof(TSelf).Name
to get the actual class name, not just 'Shape'.
Unlike traditional polymorphism with virtual methods, CRTP resolves everything at compile time. There's no virtual method table lookup at runtime - it's all statically dispatched.
This pattern is powerful for creating fluent interfaces, type-safe builders, or when you need compile-time polymorphism. It's commonly used in C++ but works great in C# too.
Constraint Combinations
Multiple constraints create precise type requirements. Use them to express complex type relationships.
public class SortedCollection<T> where T : IComparable<T>, new()
{
private readonly List<T> _items = new();
public void Add(T item)
{
_items.Add(item);
_items.Sort();
}
public T CreateDefault()
{
return new T();
}
}
This collection requires comparable, instantiable types. The constraints enable both sorting and creation.
Variance in Delegates
Delegates use variance automatically. Func and Action are covariant and contravariant respectively.
// Func<out TResult> - covariant
Func<string> getString = () => 'hello';
Func<object> getObject = getString; // Works due to covariance
// Action<in T> - contravariant
Action<object> printObject = obj => Console.WriteLine(obj);
Action<string> printString = printObject; // Works due to contravariance
Delegate variance enables flexible event handling and callback systems.
Generic Type Inference
C# infers generic types from usage, reducing verbosity. Understanding inference rules helps write cleaner code.
// Explicit types (verbose)
Dictionary<string, List<int>> dict1 = new Dictionary<string, List<int>>();
// Type inference (cleaner)
var dict2 = new Dictionary<string, List<int>>();
// Method inference
var result = ProcessItems(new[] { 1, 2, 3 }); // Infers T=int
Type inference works for methods too. The compiler analyzes arguments to determine type parameters.
Performance Considerations
Generics have minimal runtime overhead due to type erasure, but constraints affect performance differently.
Value type constraints (struct
) avoid boxing. Reference type constraints (class
) enable null checks. Interface constraints may require boxing for value types.
// Fast - no boxing for value types
public void ProcessValue<T>(T value) where T : struct
{
// value is not boxed
}
// May box value types
public void ProcessInterface<T>(T value) where T : IFormattable
{
// value might be boxed to call interface methods
}
Choose constraints based on performance requirements. Value types with interface constraints can cause boxing.
Common Pitfalls
Advanced generics have subtle gotchas. Watch for these common issues:
- Invalid variance: Can't use
out
on method parameters orin
on return types. - Constraint conflicts:
struct
andclass
constraints are mutually exclusive. - Boxing overhead: Interface constraints on value types cause boxing.
- Type inference limitations: Complex scenarios may require explicit type arguments.
Use constraints to express requirements clearly. Prefer interface constraints over base class constraints for flexibility. Leverage variance to create intuitive APIs.
Practical Example: Generic Validator
Let's build a validation framework using advanced generics.
public interface IValidator<in T>
{
ValidationResult Validate(T instance);
}
public class RequiredValidator<T> : IValidator<T>
{
public ValidationResult Validate(T instance)
{
if (instance == null)
return ValidationResult.Invalid('Required field is null');
if (instance.Equals(default(T)))
return ValidationResult.Invalid('Required field has default value');
return ValidationResult.Valid();
}
}
public class RangeValidator<T> : IValidator<T> where T : IComparable<T>
{
private readonly T _min;
private readonly T _max;
public RangeValidator(T min, T max)
{
_min = min;
_max = max;
}
public ValidationResult Validate(T instance)
{
if (instance.CompareTo(_min) < 0 || instance.CompareTo(_max) > 0)
return ValidationResult.Invalid($'Value must be between {_min} and {_max}');
return ValidationResult.Valid();
}
}
Let's build a complete validation framework to see advanced generics in action. This example combines constraints, variance, and generic methods to create a flexible validation system.
The IValidator<in T>
interface uses contravariance, meaning a validator for a base type can validate derived types. A validator that knows how to validate object
can certainly validate string
.
The RequiredValidator<T>
works with any type. It checks for null first, then uses default(T)
to see if the value equals the type's default value. For reference types, default is null; for value types, it's the zero value.
The RangeValidator<T>
is more constrained - it requires T
to implement IComparable<T>
. This enables the CompareTo
calls to check if values fall within the specified range.
Notice how the constructor takes the min and max values of type T
. This means you can create range validators for any comparable type - integers, decimals, dates, etc.
The validation logic uses CompareTo
to check bounds. If the value is less than min or greater than max, it returns an invalid result with a descriptive message.
This design creates a composable validation system. You can combine multiple validators for the same type, and the contravariance allows validators to work with inheritance hierarchies.
Summary
Advanced generics enable type-safe, flexible code through constraints and variance. Constraints express type requirements, while variance enables natural inheritance relationships. Together, they create powerful abstractions that work across the type system.
The key is understanding when to use each feature. Constraints limit possibilities to enable operations, variance expands possibilities to support inheritance. Master both to write generic code that feels like it was designed specifically for each use case.