Skip to main content
ANVISoftware Solutions
Lesson 57 of 62Advanced18 min

Generics in Depth

By the end of this lesson

Write generic types and methods with constraints.

You have used generics since the first time you wrote List<string>. A type parameter is a placeholder: List<T> is written once, and the T is filled in when you use it. List<string> holds strings, List<Order> holds orders, and the compiler checks both.

Writing your own generic type is the same idea from the other side. You write the placeholder and let callers choose.

The reason to do it is repetition you cannot remove any other way. A cache for orders and a cache for customers are the same code with one type swapped. Without generics you copy the class, or you store object and cast everything back — losing compile-time checking and paying for boxing on value types.

A generic cache, written once
C#
public class TimedCache<TKey, TValue>
    where TKey : notnull
{
    private readonly Dictionary<TKey, (TValue Value, DateTimeOffset Expires)> _entries = new();
    private readonly TimeSpan _lifetime;

    public TimedCache(TimeSpan lifetime) => _lifetime = lifetime;

    public bool TryGet(TKey key, out TValue? value)
    {
        if (_entries.TryGetValue(key, out var entry) && entry.Expires > DateTimeOffset.UtcNow)
        {
            value = entry.Value;
            return true;
        }

        _entries.Remove(key);
        value = default;
        return false;
    }

    public void Set(TKey key, TValue value) =>
        _entries[key] = (value, DateTimeOffset.UtcNow.Add(_lifetime));
}

// Two very different caches, one class.
TimedCache<string, decimal> rates = new(TimeSpan.FromMinutes(5));
TimedCache<int, Order> orders = new(TimeSpan.FromSeconds(30));

rates.Set("GBP", 1.0m);

if (orders.TryGet(4471, out Order? order))
{
    Console.WriteLine(order!.Reference);
}
  • Two type parameters. TKey and TValue are named for their roles; the bare T is a fine name when there is only one and its role is obvious.
  • where TKey : notnull is a constraint. It says a key may not be a nullable type, which is what a dictionary key requires. Without it the compiler warns as soon as TKey is used as a Dictionary key.
  • TValue has no constraint at all, and look at what the class does with it: stores it, returns it, assigns default to it. That is the complete list of things you can do with an unconstrained type parameter.
  • value = default gives you null for a reference type and a zero-filled value for a struct. It is the only way to produce an empty TValue when you know nothing about the type.
  • At the two usage sites, TValue is decimal in one case and Order in the other. The decimal version stores decimals directly — no boxing, no cast, which is what an object-based cache could not do.
  • TryGet follows the shape of Dictionary.TryGetValue on purpose. Matching the conventions of the framework makes a generic type feel familiar rather than novel.

Constraints are what decide how much you can do with T. A where clause narrows which types a caller may supply, and in exchange widens what your code is allowed to do:

where T : class
T must be a reference type. This lets you compare against null, and makes T? mean a nullable reference. Write class? instead if you want to allow nullable reference types too.
where T : struct
T must be a value type. Useful when you want to guarantee no null is possible, and when you need T? to mean Nullable<T>.
where T : notnull
T may be a reference type or a value type, but not a nullable one. This is the right constraint for dictionary keys and anything else that must always have a value.
where T : SomeInterface
T must implement the interface, so you may call its members. where T : IComparable<T> is what lets you write a.CompareTo(b). This is the most common and most useful kind.
where T : SomeBaseClass
T must derive from that class, so you may use its public and protected members. where TEntity : Entity is the usual shape for a repository base class.
where T : new()
T must have a public parameterless constructor, so your code may write new T(). Always the last constraint in the list. Useful for factories and mappers, though a Func<T> parameter is often more flexible.
where T : U
One type parameter must be assignable to another. This turns up in conversion and mapping helpers where two generic types must relate to each other.
A generic method, and what each constraint unlocks
C#
public static class Selecting
{
    // IComparable<TKey> is what makes CompareTo legal below.
    public static T? HighestBy<T, TKey>(IEnumerable<T> items, Func<T, TKey> keySelector)
        where T : class
        where TKey : IComparable<TKey>
    {
        T? best = null;
        TKey? bestKey = default;

        foreach (T item in items)
        {
            TKey key = keySelector(item);

            if (best is null || key.CompareTo(bestKey!) > 0)
            {
                best = item;
                bestKey = key;
            }
        }

        return best;
    }

    // new() is what makes "new TReport()" legal below.
    public static TReport StartReport<TReport>(string title)
        where TReport : ReportBase, new()
    {
        TReport report = new();
        report.Title = title;        // Allowed because of the ReportBase constraint.
        report.StartedAt = DateTimeOffset.UtcNow;

        return report;
    }
}

// Type arguments are inferred from the arguments you pass.
Order? biggest = Selecting.HighestBy(orders, o => o.Total);
Order? latest = Selecting.HighestBy(orders, o => o.PlacedAt);

// Inference cannot work here, because nothing in the call mentions the type.
VatReport report = Selecting.StartReport<VatReport>("Q3 VAT");
  • HighestBy has two type parameters: the item type and the key type. Each carries its own where clause, written on separate lines by convention.
  • where TKey : IComparable<TKey> is doing the real work. Remove it and key.CompareTo stops compiling, because nothing says an arbitrary TKey can be compared.
  • where T : class allows best to be declared as T? and initialised to null. Without it, the compiler cannot accept null as a value of T, since T might be a struct.
  • The second method combines two constraints. ReportBase grants access to Title and StartedAt; new() permits new TReport(). Both are needed, and new() must come last in the list.
  • The two HighestBy calls never mention a type argument. The compiler infers T from the collection and TKey from the lambda's return type — decimal in the first call, DateTimeOffset in the second.
  • StartReport has to be called with an explicit type argument, because there is nothing in its parameters to infer from. Inference works from arguments only, never from what you assign the result to.
  • In real code you would reach for LINQ's MaxBy rather than writing HighestBy. It is shown here because its signature is a compact example of two cooperating constraints, and reading generic signatures is most of the skill.

Summary

  • A type parameter lets one implementation serve many types while keeping compile-time checking
  • An unconstrained T supports almost nothing: storing, passing, and the members of object
  • A where clause is a promise from the caller that widens what your code may do with T
  • Add only the constraints you use — each one turns away callers who cannot meet it
  • Type arguments are inferred from the arguments you pass, never from the assignment target

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Try it yourself

Write a generic method that takes a collection and returns the items that appear more than once, using a key you supply — duplicate order references, repeated email addresses, and so on.

Decide what constraints you need, and be able to say what each one buys you.

Show solution

One constraint is enough: where TKey : notnull. The keys go into a dictionary, and a dictionary key cannot be null. Without the constraint the compiler warns at that line.

The item type T needs no constraint at all, because the method only stores items and hands them back. Adding where T : class would turn away records declared as structs for no gain.

Why a dictionary of counts rather than sorting? Because counting needs only equality, which every type has, while sorting would need IComparable<TKey> and therefore a constraint that excludes callers who do not need ordering. Choosing the algorithm that needs fewer capabilities keeps the method usable by more callers.

LINQ's GroupBy would do this in one line. Writing it out is worthwhile once, to see exactly which line forces which constraint.

One judgement call: the method returns items rather than keys. Returning the offending orders is more useful than returning a list of duplicated references, because the caller can report or fix them.

C#
public static List<T> Duplicates<T, TKey>(
    IEnumerable<T> items,
    Func<T, TKey> keySelector)
    where TKey : notnull
{
    Dictionary<TKey, List<T>> byKey = new();

    foreach (T item in items)
    {
        TKey key = keySelector(item);

        if (!byKey.TryGetValue(key, out List<T>? group))
        {
            group = new List<T>();
            byKey[key] = group;
        }

        group.Add(item);
    }

    return byKey.Values
        .Where(group => group.Count > 1)
        .SelectMany(group => group)
        .ToList();
}

// Usage: type arguments inferred from the arguments.
List<Order> clashes = Duplicates(orders, o => o.Reference);

Think about it

Think about it

A colleague writes a generic repository: Repository<T> with Add, Update, Delete, GetById and GetAll, constrained with where T : class, new(). Every entity in the application gets one.

Six months later, orders need a method that loads their lines, customers need a search by partial name, and invoices must never be deleted. What has the generic bought, and what has it cost?

Show solution

What it bought is real but small: five method bodies written once instead of once per entity. On a system with twenty entities that is genuine duplication removed.

The first cost is the shape of the interface. Delete exists on Repository<Invoice> because it exists on Repository<T>, and the requirement is that invoices are never deleted. The generic has published a capability the domain forbids, and nothing in the type system stops a caller using it.

The second cost is everything specific. Loading order lines and searching customers by name cannot live on Repository<T>, because they are not true of every T. They end up in subclasses, or in extension methods, or as a leaked IQueryable<T> — and once callers hold an IQueryable, the repository has stopped abstracting anything.

The new() constraint is worth noticing too. It is there so the repository can construct a T, which entities rarely need, and it quietly excludes any entity with a required constructor parameter. That is a constraint costing callers something for a capability almost nobody uses.

The honest conclusion is not that generic repositories are always wrong. It is that a type parameter is right when the behaviour truly is identical for every type. Data access starts out looking identical and stops being so as soon as the domain gets specific, which is why specific repositories — OrderRepository with the methods orders actually need — tend to age better. If you use EF Core, the DbContext is already a generic data-access abstraction, and wrapping it in another one buys less than it appears to.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

A method declares <T> with no where clause. Which of these can its body do with a value of type T?
Why does StartReport<VatReport>("Q3 VAT") need its type argument written out when HighestBy(orders, o => o.Total) does not?

Saved in this browser only.