Generic Classes, Generic Methods and Constraints in C#
Writing your own generic classes and methods: paging with Page<T>, the limits of type inference, default and the unconstrained T? trap. The class, struct, new(), notnull, unmanaged and Enum constraints with real compiler errors; per-closed-type statics and the == trap in generic code.
Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.
Guess First
We want a small helper that returns the larger of two prices. So that the same function also works for dates and book titles, we made it generic. Does this code compile?
static T Largest<T>(T first, T second) => first > second ? first : second;
Console.WriteLine(Largest(12.99m, 9.50m));
What Does the Compiler Know About T?
It does not compile:
Program.cs(1,43): error CS0019: Operator '>' cannot be applied to operands of type 'T' and 'T'
decimal certainly has >, but the compiler compiles Largest once, independently of where it is called. At that moment the only thing it knows about T is that it is a type. T could be a Book or a Stream. That is why the compiler only lets you use members that every type has: Equals, GetHashCode, ToString, GetType.
The way to tell the compiler more is a constraint. When you write where T : IComparable<T>, you are saying "only types that can compare themselves with a value of the same type may come here." In return you earn the right to use the CompareTo method:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Console.WriteLine(Largest(12.99m, 9.50m));
Console.WriteLine(Largest("Dune", "Foundation"));
Console.WriteLine(Largest(new DateOnly(2021, 11, 8), new DateOnly(2005, 11, 7)));
static T Largest<T>(T first, T second) where T : IComparable<T>
=> first.CompareTo(second) >= 0 ? first : second;
12.99
Foundation
11/08/2021
A constraint is a two-way contract. The inside of the method can rely on that capability, and the caller has to meet the requirement. Call Largest(dune, foundation) with a record Book that does not implement IComparable<Book>, and this time the error shows up on the line making the call:
Program.cs(3,19): error CS0311: The type 'Book' cannot be used as type parameter 'T' in the generic type or method 'Largest<T>(T, T)'. There is no implicit reference conversion from 'Book' to 'System.IComparable<Book>'.
Think of it like a job posting. A posting that says "driver's license required" guarantees you can hand a car to whoever you hire; anyone without a license is filtered out at the application stage. No surprises once the job starts.
Your Own Generic Class: Page<T>
Sooner or later every endpoint in a Web API that returns a list needs paging. Instead of writing one page class for books and another for authors, we make the type a parameter:
List<Book> books = new()
{
new("978-0441172719", "Dune", 12.99m),
new("978-0553293357", "Foundation", 9.50m),
new("978-0261102217", "The Hobbit", 10.99m),
new("978-0451524935", "1984", 8.99m),
new("978-0060850524", "Brave New World", 11.49m),
};
List<string> authors = new() { "Frank Herbert", "Isaac Asimov", "J. R. R. Tolkien" };
Page<Book> secondPage = Paginate(books, pageNumber: 2, pageSize: 2);
Console.WriteLine($"Page {secondPage.PageNumber}/{secondPage.TotalPages}, next: {secondPage.HasNext}");
foreach (Book book in secondPage.Items)
{
Console.WriteLine($" {book.Title}");
}
Page<string> authorPage = Paginate(authors, 1, 10);
Console.WriteLine($"Authors: {string.Join(", ", authorPage.Items)}, next: {authorPage.HasNext}");
static Page<T> Paginate<T>(IReadOnlyList<T> source, int pageNumber, int pageSize)
{
List<T> items = source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToList();
return new Page<T>(items, pageNumber, pageSize, source.Count);
}
public sealed class Page<T>
{
public Page(IReadOnlyList<T> items, int pageNumber, int pageSize, int totalCount)
{
Items = items;
PageNumber = pageNumber;
PageSize = pageSize;
TotalCount = totalCount;
}
public IReadOnlyList<T> Items { get; }
public int PageNumber { get; }
public int PageSize { get; }
public int TotalCount { get; }
public int TotalPages => (TotalCount + PageSize - 1) / PageSize; // division rounded up
public bool HasNext => PageNumber < TotalPages;
}
record Book(string Isbn, string Title, decimal Price);
Page 2/3, next: True
The Hobbit
1984
Authors: Frank Herbert, Isaac Asimov, J. R. R. Tolkien, next: False
Inside Page<T> we do nothing with T; we only carry it. That is why no constraint is needed. As long as a generic type does not operate on T, it should stay as open as possible. There is also a convention for type parameter names: T when there is a single parameter, and meaningful names starting with T when there are several: TKey, TValue, TEntity.
Generic Methods: Type Inference and default
We did not write <Book> in the call Paginate(books, 2, 2). The compiler inferred T from the arguments: the first parameter is IReadOnlyList<T>, the value passed is a List<Book>, so T is Book. This inference looks only at the arguments, never at the return type or at the variable the result is assigned to:
List<Book> empty = CreateList();
List<Book> books = CreateList<Book>();
Console.WriteLine(books.Count);
static List<T> CreateList<T>() => new List<T>();
record Book(string Isbn, string Title, decimal Price);
Program.cs(1,20): error CS0411: The type arguments for method 'CreateList<T>()' cannot be inferred from the usage. Try specifying the type arguments explicitly.
Write the type explicitly, as on the second line, and the problem goes away.
The "empty value" is a question in generic code too. If T is a reference type the empty value is null, for int it is 0, and for a struct it is the instance with all fields zeroed. default covers all of them. The place to be careful is an unconstrained T?:
List<int> stockCounts = new() { 3, 0, 12 };
List<string> titles = new() { "Dune", "Foundation" };
Console.WriteLine(FindFirst(stockCounts, s => s > 100)); // not null, 0
Console.WriteLine(FindFirst(titles, t => t.StartsWith("Z")) ?? "(null)");
Console.WriteLine(ParseEnum<OrderStatus>("shipped"));
Console.WriteLine(ParseEnum<OrderStatus>("lost")?.ToString() ?? "(null)");
static T? FindFirst<T>(IEnumerable<T> source, Func<T, bool> predicate)
{
foreach (T item in source)
{
if (predicate(item))
{
return item;
}
}
return default;
}
static TEnum? ParseEnum<TEnum>(string text) where TEnum : struct, Enum
=> Enum.TryParse(text, ignoreCase: true, out TEnum value) ? value : null;
enum OrderStatus { Pending, Paid, Shipped, Delivered }
0
(null)
Shipped
(null)
The first line is the surprise. No book has more than 100 in stock, yet the result is 0; you cannot tell "found a book with zero stock" from "found nothing." Here is why: for an unconstrained T, T? (since C# 9) means "may be null" when T is a reference type and changes nothing when it is a value type. For int the return type is plain int, and default is zero. ParseEnum has a struct constraint; there TEnum? really is Nullable<TEnum>, and we can return null. If you need to tell "not found" apart for value types, the bool TryFind(..., out T result) pattern is the more honest design.
Making Promises with Constraints
In real projects you see constraints most often in the repository and service layers. We have an interface saying every entity has an Id:
InMemoryRepository<Book> books = new();
books.Add(new Book(1, "978-0441172719", "Dune"));
books.Add(new Book(2, "978-0553293357", "Foundation"));
Console.WriteLine(books.GetById(2)?.Title ?? "(not found)");
Console.WriteLine(books.GetById(7)?.Title ?? "(not found)");
interface IEntity
{
int Id { get; }
}
class InMemoryRepository<TEntity> where TEntity : class, IEntity
{
private readonly Dictionary<int, TEntity> _items = new();
public void Add(TEntity entity) => _items.Add(entity.Id, entity);
public TEntity? GetById(int id) => _items.TryGetValue(id, out TEntity? entity) ? entity : null;
}
record Book(int Id, string Isbn, string Title) : IEntity;
Foundation
(not found)
Two constraints work together here. Thanks to IEntity we can write entity.Id. Thanks to class, TEntity? really means "a reference that may be null," and we can return null. Delete class and the compiler objects right away:
Program.cs(19,95): error CS0403: Cannot convert null to type parameter 'TEntity' because it could be a non-nullable value type. Consider using 'default(TEntity)' instead.
A struct can implement IEntity too, and you cannot assign null to a struct. The compiler sees that possibility and stops you.
A Catalog of Constraints
I gathered most of the constraints you can use in C# 10 into a single program:
using System.Runtime.CompilerServices;
Console.WriteLine(ParseEnum<OrderStatus>("paid"));
Console.WriteLine(SizeOf<Isbn13>());
Console.WriteLine(CreateDefault<ShoppingCart>().Items.Count);
Cache<string, decimal> prices = new();
prices.Set("978-0441172719", 12.99m);
Console.WriteLine(prices.Count);
static TEnum? ParseEnum<TEnum>(string text) where TEnum : struct, Enum
=> Enum.TryParse(text, ignoreCase: true, out TEnum value) ? value : null;
static int SizeOf<T>() where T : unmanaged => Unsafe.SizeOf<T>();
static T CreateDefault<T>() where T : new() => new T();
class Cache<TKey, TValue> where TKey : notnull
{
private readonly Dictionary<TKey, TValue> _items = new();
public int Count => _items.Count;
public void Set(TKey key, TValue value) => _items[key] = value;
}
class ShoppingCart
{
public List<string> Items { get; } = new();
}
enum OrderStatus { Pending, Paid, Shipped, Delivered }
readonly struct Isbn13
{
public Isbn13(long value) => Value = value;
public long Value { get; }
}
readonly record struct Money(decimal Amount, string Currency);
record Book(string Isbn, string Title, decimal Price);
The output is Paid, 8, 0 and 1, in that order. Now let's add four rule-breaking lines below Console.WriteLine(prices.Count);:
Cache<string?, int> stock = new();
ShoppingCart? cart = ParseEnum<ShoppingCart>("open");
Book blank = CreateDefault<Book>();
int moneySize = SizeOf<Money>();
Program.cs(11,7): warning CS8714: The type 'string?' cannot be used as type parameter 'TKey' in the generic type or method 'Cache<TKey, TValue>'. Nullability of type argument 'string?' doesn't match 'notnull' constraint.
Program.cs(12,22): error CS0453: The type 'ShoppingCart' must be a non-nullable value type in order to use it as parameter 'TEnum' in the generic type or method 'ParseEnum<TEnum>(string)'
Program.cs(13,14): error CS0310: 'Book' must be a non-abstract type with a public parameterless constructor in order to use it as parameter 'T' in the generic type or method 'CreateDefault<T>()'
Program.cs(14,17): error CS8377: The type 'Money' must be a non-nullable value type, along with all fields at any level of nesting, in order to use it as parameter 'T' in the generic type or method 'SizeOf<T>()'
struct requires T to be a non-nullable value type. Combined with Enum (C# 7.3), it says exactly "an enum." class requires a reference type; in a nullable context, class? also allows reference types that may be null.
new() requires a public parameterless constructor; Book is a positional record, so it has none. unmanaged (C# 7.3) requires a value type that holds no reference at any level; the string inside Money takes it out of that group, while Isbn13, which only holds a long, passes. notnull comes with nullable reference types, and violating it produces a warning rather than an error. The definition of Dictionary<TKey, TValue> itself says where TKey : notnull.
On top of these, a base class (where T : OrderItem), one or more interfaces, and another type parameter (where T : TBase) can also be constraints. The ordering rule is strict: class, struct, unmanaged or notnull goes first, and new() goes last. Write where T : new(), class and you get CS0401 ("The new() constraint must be the last restrictive constraint specified") and CS0449 together.
Every Closed Type Has Its Own static
A static member of a generic class is not shared across all Ts. Every closed type carries its own copy:
Counter<Book>.Increment();
Counter<Book>.Increment();
Counter<Author>.Increment();
Console.WriteLine($"Book: {Counter<Book>.Count}, Author: {Counter<Author>.Count}");
static class Counter<T>
{
public static int Count { get; private set; }
public static void Increment() => Count++;
}
record Book(string Isbn, string Title, decimal Price);
record Author(string Name);
Book: 2, Author: 1
Because Counter<Book> and Counter<Author> are two separate types to the runtime, their counters are separate too. If you are not aware of this, what you thought was "a single application-wide counter" turns out to be one counter per type. If you are aware of it, the behavior can work for you: keeping a per-type result (say, a property list read through reflection) in a generic static class is a fast caching pattern that needs no dictionary lookup.
Common Pitfalls
Using == under a class constraint. In that case the compiler does a reference comparison and ignores the type's own == operator:
string title = "Dune";
string typed = new string(new[] { 'D', 'u', 'n', 'e' });
Console.WriteLine(title == typed);
Console.WriteLine(Same(title, typed));
Console.WriteLine(SameByEquals(title, typed));
static bool Same<T>(T first, T second) where T : class => first == second;
static bool SameByEquals<T>(T first, T second) => EqualityComparer<T>.Default.Equals(first, second);
The output is True, False, True. The same two strings come out "different" inside the generic method, and the compiler gives no warning. For equality in generic code, use EqualityComparer<T>.Default; it honors both IEquatable<T> and an overridden Equals.
What happens if the constructor of an object created with new T() throws? The compiler turns new T() into a call to Activator.CreateInstance<T>(), and the exception arrives wrapped. When I tried it with a class whose constructor throws InvalidOperationException, the type I caught was TargetInvocationException; the real error was in InnerException. If you wrote catch (InvalidOperationException), that block never runs.
Adding more constraints than you need. Every method where you write where T : class gets in the way the day someone wants to call it with a struct. Add a constraint for a capability you actually use inside the method.
A Small Experiment
★☆☆ Write Smallest next to Largest and try it with DateOnly values.
★★☆ Add IComparable<Book> to Book (comparing by price) and watch the CS0311 error disappear. What does Largest(dune, foundation) return now?
★★★ Rewrite FindFirst with the signature bool TryFind<T>(IEnumerable<T> source, Func<T, bool> predicate, out T result), and show that "not found" and "found zero" can now be told apart for stock counts.
Now we have written generic types too. In the next post we take a close look at the generic type you will use most, List<T>: its add and remove methods, how Capacity grows, sorting and searching.