A previous post on TechStack blog covered the foundations of C# generics - generic classes, methods, basic constraints, and the five main collection types. A reader on Dev.to asked specifically for a contravariant collection base class example. A separate comment noted the first post was too light for senior level. Both pieces of feedback point at the same gap: there is a meaningful difference between using generics and designing with them. This post covers the patterns that belong in the second category, with complete working code throughout.
Quick Recap
The previous post covered these foundations:
// Generic class - type filled at point of use
public class Box<T>
{
private T _item;
public void Store(T item) => _item = item;
public T Retrieve() => _item;
}
// Generic method - type inferred from argument
public T GetFirst<T>(List<T> items)
=> items.Count > 0 ? items[0] : default(T)!;
// Basic constraints
where T : class // reference types only
where T : new() // has parameterless constructor
where T : IComparable<T> // supports comparison
This post builds directly on that foundation.
Part 1: Covariance and Contravariance, Properly
The Direction Problem
Generics are invariant by default. A List<string> is not a List<object>, even though every string is an object. This is correct behavior:
List<string> strings = new List<string>();
// Does NOT compile - correct behavior
List<object> objects = strings;
// If it compiled, strings would now contain an int
// and crash at runtime - this is why invariance exists
objects.Add(42);
Covariance and contravariance are the two safe exceptions to this rule, and they work in opposite directions for a specific reason.
Covariance - Safe to Widen the Type (out T)
Covariance is safe when a generic type only ever PRODUCES values of T, never accepts them. If you can only read T out, widening is safe because every string you read out is already an object.
// IEnumerable<T> is declared as IEnumerable<out T> // It only produces T via foreach, never accepts it IEnumerable<string> strings =
new List<string> { "hello", "world" };
// Compiles - safe covariance
IEnumerable<object> objects = strings;
foreach (var obj in objects)
Console.WriteLine(obj); // "hello", "world"
// Your own covariant interface
public interface IProducer<out T>
{
T Produce(); // only ever RETURNS T - safe to widen
}
public class StringProducer : IProducer<string>
{
public string Produce() => "from string producer";
}
IProducer<string> sp = new StringProducer();
// Compiles because IProducer<out T> is covariant
IProducer<object> op = sp;
object result = op.Produce(); // "from string producer"
Contravariance - Safe to Narrow the Type (in T)
Contravariance is safe when a generic type only ever CONSUMES values of T, never produces them. If something handles any object, it certainly handles a string.
// Action<T> is declared as Action<in T>
// It only consumes T as a parameter, never returns it
Action<object> processObject =
obj => Console.WriteLine(obj.GetType().Name);
// Compiles - safe contravariance
Action<string> processString = processObject;
processString("hello"); // prints "String"
// Your own contravariant interface
public interface IConsumer<in T>
{
void Consume(T item); // only ACCEPTS T - safe to narrow
}
public class ObjectPrinter : IConsumer<object>
{
public void Consume(object item)
=> Console.WriteLine(item);
}
IConsumer<object> oc = new ObjectPrinter();
// Compiles because IConsumer<in T> is contravariant
// Something that handles object handles string too
IConsumer<string> sc = oc;
sc.Consume("works"); // prints "works"
The rule in plain English: out T means the type only produces T (safe to widen). in T means it only consumes T (safe to narrow).
The Contravariant Collection Base Class
Multiple concrete collections sharing a common generic base, usable polymorphically through a contravariant interface - the exact pattern requested on Dev.to.
// Data models
public class Post
{
public int Id { get; set; }
public string Title { get; set; } = "";
public string Slug { get; set; } = "";
public string Tech { get; set; } = "";
public int ReadingTime { get; set; }
}
public class Tag { public string Name { get; set; } = ""; }
public class Author { public string Bio { get; set; } = ""; }
// Abstract generic base - shared behaviour
// without knowing the concrete type
public abstract class EntityCollectionBase<T>
: IEnumerable<T>
where T : class
{
protected readonly List<T> _items = new();
public void Add(T item)
{
ArgumentNullException.ThrowIfNull(item);
_items.Add(item);
}
public bool Remove(T item) => _items.Remove(item);
public int Count => _items.Count;
public IEnumerator<T> GetEnumerator()
=> _items.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator()
=> GetEnumerator();
}
// Concrete collections - each adds domain-specific methods
public class PostCollection : EntityCollectionBase<Post>
{
public IEnumerable<Post> GetByTech(string tech) =>
_items.Where(p => p.Tech == tech);
}
public class TagCollection : EntityCollectionBase<Tag>
{
public IEnumerable<string> Names =>
_items.Select(t => t.Name);
}
public class AuthorCollection
: EntityCollectionBase<Author>
{
public Author? FindByBio(string keyword) =>
_items.FirstOrDefault(
a => a.Bio.Contains(keyword));
}
// Contravariant consumer interface
public interface ICollectionProcessor<in T>
{
void Process(IEnumerable<T> items);
}
// A processor for object handles ANY entity collection
public class ItemLogger : ICollectionProcessor<object>
{
public void Process(IEnumerable<object> items)
{
foreach (var item in items)
{
var name = item.GetType().Name;
Console.WriteLine($"[LOG] {name}: {item}");
}
}
}
// The polymorphism in action
var posts = new PostCollection();
posts.Add(new Post { Title = "Advanced Generics" });
var tags = new TagCollection();
tags.Add(new Tag { Name = "Azure" });
ICollectionProcessor<object> logger = new ItemLogger();
// Contravariance - one logger, all entity types
ICollectionProcessor<Post> postLogger = logger;
ICollectionProcessor<Tag> tagLogger = logger;
postLogger.Process(posts); // works
tagLogger.Process(tags); // works
// Generic method that accepts any EntityCollectionBase<T>
public void PrintAll<T>(EntityCollectionBase<T> col)
where T : class
{
Console.WriteLine(
$"{typeof(T).Name}: {col.Count} items");
foreach (var item in col)
Console.WriteLine($" {item}");
}
PrintAll(posts); // Post: 1 items
PrintAll(tags); // Tag: 1 items
Part 2: Custom Comparers
IComparer - Custom Sorting Logic
IComparer lets you define sorting strategy without touching the class being sorted. Writing a generic KeyComparer once lets you sort any type by any key.
// A generic key comparer - write once, use with any type
public class KeyComparer<T, TKey> : IComparer<T>
where TKey : IComparable<TKey>
{
private readonly Func<T, TKey> _keySelector;
public KeyComparer(Func<T, TKey> keySelector)
{
_keySelector = keySelector;
}
public int Compare(T? x, T? y)
{
if (x == null && y == null) return 0;
if (x == null) return -1;
if (y == null) return 1;
return _keySelector(x)
.CompareTo(_keySelector(y));
}
}
// Usage - same class, completely different sort keys
var posts = new List<Post>
{
new() { Title = "Azure Key Vault", ReadingTime = 13 },
new() { Title = "C# Generics", ReadingTime = 16 },
new() { Title = "OAuth Token Flows", ReadingTime = 15 }
};
// Sort by reading time
var byReading =
new KeyComparer<Post, int>(p => p.ReadingTime);
posts.Sort(byReading);
// Result: Azure Key Vault(13), OAuth(15), C# Generics(16)
// Sort by title - same comparer class, different key
var byTitle =
new KeyComparer<Post, string>(p => p.Title);
posts.Sort(byTitle);
// Result: Azure Key Vault, C# Generics, OAuth Token Flows
// You can also use it with LINQ
var sorted = posts
.OrderBy(p => p, byReading)
.ToList();
IEqualityComparer - Custom Equality for Dictionary and HashSet
IEqualityComparer defines what "equal" means when a type is used as a Dictionary key or HashSet member. Both Equals and GetHashCode must stay consistent - equal objects must produce the same hash code.
// Case-insensitive slug lookup for Dictionary
public class CaseInsensitiveStringComparer
: IEqualityComparer<string>
{
public bool Equals(string? x, string? y) =>
string.Equals(
x, y,
StringComparison.OrdinalIgnoreCase);
public int GetHashCode(string obj) =>
obj.ToUpperInvariant().GetHashCode();
}
// Dictionary with case-insensitive keys
var bySlug = new Dictionary<string, Post>(
new CaseInsensitiveStringComparer());
bySlug["Azure-Key-Vault"] =
new Post { Title = "Azure Key Vault" };
// All three find the same post - case doesn't matter
var p1 = bySlug["azure-key-vault"]; // found
var p2 = bySlug["AZURE-KEY-VAULT"]; // found
var p3 = bySlug["Azure-Key-Vault"]; // found
// Custom Post equality based on slug not reference
public class PostBySlugComparer
: IEqualityComparer<Post>
{
public bool Equals(Post? x, Post? y)
{
if (ReferenceEquals(x, y)) return true;
if (x == null || y == null) return false;
return x.Slug == y.Slug;
}
public int GetHashCode(Post obj) =>
obj.Slug?.GetHashCode() ?? 0;
}
// HashSet deduplication by slug, not reference
var uniquePosts = new HashSet<Post>(
new PostBySlugComparer());
uniquePosts.Add(new Post
{
Slug = "azure-key-vault",
Title = "Azure Key Vault"
});
// Duplicate - same slug, different Title
uniquePosts.Add(new Post
{
Slug = "azure-key-vault",
Title = "DUPLICATE ATTEMPT"
});
Console.WriteLine(uniquePosts.Count); // 1
The key distinction: IComparer belongs in sort operations. IEqualityComparer belongs in Dictionary and HashSet construction. Mixing them up compiles but does nothing useful.
Part 3: The notnull Constraint and Generic Factories
The notnull Constraint
Introduced in C# 8, notnull restricts T to non-nullable types. It makes intent explicit at compile time rather than leaving null as a runtime surprise.
// Without notnull - null can silently slip through
public T FindFirst<T>(
List<T> items,
Func<T, bool> predicate)
{
// Unsafe - suppresses nullable warning with !
return items.FirstOrDefault(predicate)!;
}
// With notnull - throw rather than return null
public T FindOrThrow<T>(
List<T> items,
Func<T, bool> predicate)
where T : notnull
{
return items.FirstOrDefault(predicate)
?? throw new InvalidOperationException(
"No matching item found");
}
// default(T) is different per type - always know which:
// string -> null
// int -> 0
// bool -> false
// DateTime -> DateTime.MinValue (0001-01-01)
// custom class -> null
// Safe dictionary lookup with notnull key
public T GetOrThrow<T>(
Dictionary<string, T> dict,
string key)
where T : notnull
{
return dict.TryGetValue(key, out var value)
? value
: throw new KeyNotFoundException(
$"Key '{key}' not found");
}
// Usage
var posts = new Dictionary<string, Post>
{
["azure-key-vault"] =
new Post { Title = "Azure Key Vault" }
};
var found = GetOrThrow(posts, "azure-key-vault");
// found.Title = "Azure Key Vault"
var missing = GetOrThrow(posts, "doesnt-exist");
// throws KeyNotFoundException: Key 'doesnt-exist' not found
Generic Factory with Validation
A generic factory pairs where T : new() with an Action for configuration and a Func for validation - construction, configuration, and validation as composable arguments.
public static T CreateValidated<T>(
Action<T> configure,
Func<T, bool> validate,
string errorMessage = "Validation failed")
where T : new()
{
var instance = new T();
configure(instance);
if (!validate(instance))
throw new InvalidOperationException(
errorMessage);
return instance;
}
// Usage - throws if post has no title or slug
var post = CreateValidated<Post>(
configure: p =>
{
p.Title = "Advanced Generics";
p.Slug = "csharp-advanced-generics";
},
validate: p =>
!string.IsNullOrEmpty(p.Title)
&& !string.IsNullOrEmpty(p.Slug),
errorMessage: "Post must have a title and slug"
);
// This throws
var invalid = CreateValidated<Post>(
configure: p => { p.Title = ""; p.Slug = ""; },
validate: p => !string.IsNullOrEmpty(p.Title),
errorMessage: "Post must have a title"
);
// throws InvalidOperationException: Post must have a title
Part 4: Generic Repository with Multiple Constraints
Combining constraints shows what the constraint system looks like composed into a real, reusable abstraction.
// Marker interface - only domain entities allowed
public interface IEntity { int Id { get; } }
// Post now implements IEntity
public class Post : IEntity
{
public int Id { get; set; }
public string Title { get; set; } = "";
public string Slug { get; set; } = "";
public string Tech { get; set; } = "";
public int ReadingTime { get; set; }
}
// Four constraints combined in one declaration:
// class - must be a reference type
// IEntity - must have an Id property
// notnull - must not be nullable
// new() - must be constructable with new T()
public abstract class RepositoryBase<T>
where T : class, IEntity, notnull, new()
{
protected readonly List<T> _store = new();
public virtual void Add(T entity)
{
if (_store.Any(e => e.Id == entity.Id))
throw new InvalidOperationException(
$"{typeof(T).Name} with Id "
+ $"{entity.Id} already exists");
_store.Add(entity);
}
public virtual T? GetById(int id) =>
_store.FirstOrDefault(e => e.Id == id);
public virtual IEnumerable<T> GetAll() =>
_store.AsReadOnly();
public virtual bool Delete(int id)
{
var entity = GetById(id);
if (entity == null) return false;
_store.Remove(entity);
return true;
}
// Create, configure, validate, and add in one call
// new() constraint lets us call new T() here
public T CreateAndAdd(
Action<T> configure,
Func<T, bool>? validate = null)
{
var entity = new T();
configure(entity);
if (validate != null && !validate(entity))
throw new InvalidOperationException(
$"Validation failed for "
+ $"{typeof(T).Name}");
Add(entity);
return entity;
}
}
// Concrete repository - inherits everything above,
// adds Post-specific queries
public class PostRepository : RepositoryBase<Post>
{
public IEnumerable<Post> GetByTech(string tech) =>
GetAll().Where(p => p.Tech == tech);
public Post? GetBySlug(string slug) =>
GetAll().FirstOrDefault(
p => p.Slug == slug);
// Override to add Post-specific validation
public override void Add(Post entity)
{
if (string.IsNullOrWhiteSpace(entity.Slug))
throw new InvalidOperationException(
"Post must have a slug");
base.Add(entity);
}
}
// Usage
var repo = new PostRepository();
// Create, configure, validate, and add in one call
var post = repo.CreateAndAdd(
configure: p =>
{
p.Id = 1;
p.Title = "Advanced Generics";
p.Slug = "csharp-advanced-generics";
p.Tech = "C#";
p.ReadingTime = 16;
},
validate: p => p.ReadingTime > 0
);
// Retrieve
var found = repo.GetBySlug("csharp-advanced-generics");
Console.WriteLine(found?.Title);
// "Advanced Generics"
var csharpPosts = repo.GetByTech("C#");
Console.WriteLine(csharpPosts.Count());
// 1
// Delete
bool deleted = repo.Delete(1);
Console.WriteLine(deleted); // true
Console.WriteLine(repo.GetAll().Count()); // 0
Part 5: Generic Extension Methods
Extension methods can be generic, making a single implementation available across every type satisfying the constraint - write once, use everywhere.
public static class CollectionExtensions
{
// Split any IEnumerable<T> into two lists
// by a predicate - works for any type
public static (
List<T> Matching,
List<T> NotMatching)
Partition<T>(
this IEnumerable<T> source,
Func<T, bool> predicate)
{
var matching = new List<T>();
var notMatching = new List<T>();
foreach (var item in source)
{
if (predicate(item))
matching.Add(item);
else
notMatching.Add(item);
}
return (matching, notMatching);
}
// Safe Dictionary conversion - throws on duplicates
// rather than silently overwriting like ToDictionary
public static Dictionary<TKey, T>
ToDictionaryOrThrow<T, TKey>(
this IEnumerable<T> source,
Func<T, TKey> keySelector)
where TKey : notnull
{
var result = new Dictionary<TKey, T>();
foreach (var item in source)
{
var key = keySelector(item);
if (!result.TryAdd(key, item))
throw new InvalidOperationException(
$"Duplicate key detected: {key}");
}
return result;
}
// Add an item to any ICollection<T> and return it
// Useful for fluent initialization chains
public static T AddAndReturn<T>(
this ICollection<T> collection,
T item)
{
collection.Add(item);
return item;
}
}
// Usage - all three extensions work on any type
var posts = new List<Post>
{
new() { Title = "Azure Key Vault", ReadingTime = 13 },
new() { Title = "C# Generics", ReadingTime = 16 },
new() { Title = "OAuth Token Flows", ReadingTime = 15 },
new() { Title = "Azure Service Bus", ReadingTime = 8 }
};
// Partition - split by reading time
var (longPosts, shortPosts) =
posts.Partition(p => p.ReadingTime >= 15);
Console.WriteLine($"Long: {longPosts.Count}");
// Long: 2
Console.WriteLine($"Short: {shortPosts.Count}");
// Short: 2
// ToDictionaryOrThrow - safe Dictionary from any list
var bySlug = posts.ToDictionaryOrThrow(p => p.Slug);
// AddAndReturn - fluent chain
var newList = new List<Post>();
var addedPost = newList.AddAndReturn(
new Post { Title = "New Post" });
Console.WriteLine(addedPost.Title); // "New Post"
Console.WriteLine(newList.Count); // 1
Key Lessons
Use covariance (out T) on interfaces that only produce T - widening is safe because every value coming out already satisfies the broader type.
Use contravariance (in T) on interfaces that only consume T - narrowing is safe because a consumer built for a broader type handles any narrower one.
IComparer belongs in sort operations - Sort(), OrderBy(). IEqualityComparer belongs in Dictionary and HashSet construction. They look similar and solve completely different problems.
The notnull constraint makes nullability intent explicit at compile time rather than leaving null as a runtime surprise for callers.
default(T) behaves differently for reference and value types - always know which category T falls into in context.
Generic extension methods multiply their usefulness because they work across every type satisfying the constraint, not just one specific type.
Multiple constraints combined - class, IEntity, notnull, new() - express a precise contract a concrete type must satisfy, and the base class can rely on every guarantee throughout its implementation.
Summary
The patterns in this post belong in real library and framework design rather than just application code. Covariance and contravariance enable polymorphism across generic types without unsafe casting. Custom comparers
separate ordering and equality logic from data classes. The generic repository with multiple constraints shows the constraint system composed into a real, reusable abstraction. Generic extension methods multiply their usefulness across the entire type system.
These are the generics patterns worth knowing when the goal is designing things other code depends on, not just using things that someone else already built.
Originally published at TechStack Blog:
https://www.techstackblog.com/post.html?slug=csharp-advanced-generics
First generics post (foundations):
https://www.techstackblog.com/post.html?slug=csharp-generics-collections-explained
More from TechStack Blog:
C# / .NET: https://www.techstackblog.com/category.html?cat=csharp
Azure: https://www.techstackblog.com/category.html?cat=azure
Top comments (0)