Generics

C# · Lesson 20 of 31 6 min read Updated

You have already used generics many times: List<string>, Dictionary<string, int>, Queue<T>. The part in angle brackets is a type parameter: one List<T> class works for strings, ints, books or anything else, and the compiler still checks every type. Without generics you would need a StringList, an IntList and a BookList, or a single list of object that loses all type safety.

In this lesson you will write your own generic methods and classes, restrict type parameters with constraints, and use generic math to write one method that works for every number type.

Generic methods

A generic method declares its type parameters in angle brackets after its name. By convention a single type parameter is called T.

C#
int a = 5, b = 9;
Swap(ref a, ref b);
Console.WriteLine($"{a} {b}");

string first = "Pune", second = "Goa";
Swap(ref first, ref second);
Console.WriteLine($"{first} {second}");

Console.WriteLine(Describe(42));
Console.WriteLine(Describe("Kochi"));
Console.WriteLine(Describe(3.5m));

void Swap<T>(ref T x, ref T y)
{
    T temp = x;
    x = y;
    y = temp;
}

string Describe<T>(T value) => $"{value} is a {typeof(T).Name}";
Output
9 5
Goa Pune
42 is a Int32
Kochi is a String
3.5 is a Decimal

You did not write Swap<int>(...): the compiler infers T from the arguments. typeof(T) gives the actual type used for T in that call. (For swapping two variables you can also write (a, b) = (b, a); with tuples.)

Constraints

Inside a generic method, the compiler only lets you do things that work for every possible T. Writing item > best fails, because not every type supports >. A constraint says what T must be able to do, and in return you may use those abilities.

C#
int[] runs = [45, 78, 12];
string[] cities = ["Pune", "Agra", "Kochi"];
double[] temps = [31.5, 38.25, 29.0];

Console.WriteLine(Largest(runs));
Console.WriteLine(Largest(cities));
Console.WriteLine(Largest(temps));

T Largest<T>(T[] items) where T : IComparable<T>
{
    T best = items[0];
    foreach (T item in items)
    {
        if (item.CompareTo(best) > 0)
        {
            best = item;
        }
    }
    return best;
}
Output
78
Pune
38.25

where T : IComparable<T> means "T must be comparable with itself", which int, string and double all are. The compiler now allows item.CompareTo(best). Calling Largest with a type that is not comparable is a compile error, not a run-time crash.

ConstraintMeaning
where T : classT must be a reference type
where T : structT must be a non-nullable value type
where T : notnullT must not be a nullable type
where T : new()T must have a public parameterless constructor
where T : SomeBaseClassT must be that class or derive from it
where T : ISomeInterfaceT must implement that interface

You can combine several constraints, for example where T : class, IComparable<T>, new().

Generic classes

A class can have type parameters too. Here is a shelf with a fixed capacity that can hold any kind of item:

C#
var shelf = new Shelf<Book>(capacity: 2);

Console.WriteLine(shelf.TryAdd(new Book("Godan")));
Console.WriteLine(shelf.TryAdd(new Book("Gitanjali")));
Console.WriteLine(shelf.TryAdd(new Book("Malgudi Days")));
Console.WriteLine($"{shelf.Count} items, first: {shelf[0]}");

var lunchBoxes = new Shelf<string>(3);
lunchBoxes.TryAdd("Rajma chawal");
Console.WriteLine(lunchBoxes[0]);

class Shelf<T>(int capacity)
{
    private readonly List<T> _items = [];

    public int Count => _items.Count;

    public T this[int index] => _items[index];

    public bool TryAdd(T item)
    {
        if (_items.Count >= capacity)
        {
            return false;
        }
        _items.Add(item);
        return true;
    }
}

record Book(string Title);
Output
True
True
False
2 items, first: Book { Title = Godan }
Rajma chawal

Shelf<Book> and Shelf<string> are two different, fully type-checked types made from one class. shelf[0] is a Book, so there is no casting. The member public T this[int index] is an indexer: it lets callers use square brackets on your object, just like a list.

default(T)

Sometimes a generic method needs a "nothing" value for T. default(T) (or just default when the type is clear) gives 0 for numbers, false for bool and null for reference types.

C#
List<int> empty = [];
List<string> names = ["Aarav", "Isha"];

Console.WriteLine(FirstItemOrDefault(empty));
Console.WriteLine(FirstItemOrDefault(names));
Console.WriteLine(FirstItemOrDefault(new List<string>()) is null);

T? FirstItemOrDefault<T>(List<T> items) => items.Count > 0 ? items[0] : default;
Output
0
Aarav
True

LINQ already has a FirstOrDefault method that works this way; you will use it in LINQ.

Generic math

C#
using System.Numerics;

int[] visitors = [120, 80, 45];
decimal[] bills = [99.5m, 0.5m, 250m];

Console.WriteLine(Total(visitors));
Console.WriteLine(Total(bills));

T Total<T>(T[] values) where T : INumber<T>
{
    T sum = T.Zero;
    foreach (T v in values)
    {
        sum += v;
    }
    return sum;
}
Output
245
350.0

Before generic math, you had to write one Total per number type, because + is not available on an unconstrained T. T.Zero works because INumber<T> declares a static abstract member that every number type provides.

Common mistakes

  • Using operators on an unconstrained T. a + b gives CS0019: Operator '+' cannot be applied to operands of type 'T' and 'T'. Add a constraint such as INumber<T>.
  • Calling methods the constraint does not promise. If T is only IComparable<T>, you cannot call .Length on it.
  • Over-generalising. If a method only ever handles decimal, just write decimal. Use generics when you truly need several types.
  • Thinking List<Manager> is a List<Employee>. It is not, because you could add an Intern to it. Read-only interfaces such as IEnumerable<Employee> do accept a List<Manager>.

Key points

  • Type parameters such as <T> let one method or class work with many types safely.
  • The compiler usually infers T from the arguments of a generic method.
  • Constraints (where T : ...) state what T must support and unlock those members.
  • Generic classes such as Shelf<T> become separate, type-checked types for each T.
  • default(T) gives the zero or null value for any type.
  • Generic math (INumber<T>, T.Zero) lets one method handle every number type.

Exercise

Write a generic method CountAbove<T>(IEnumerable<T> items, T limit) that returns how many items are greater than limit, using a constraint so you can compare. Test it with the marks [35, 72, 90, 64, 18] and limit 60, and with the cities ["Agra", "Pune", "Delhi", "Surat"] and limit "Kochi" (alphabetical comparison). Then write a generic class Pair<TFirst, TSecond> as a record and create a pair of a student name and her marks.

Show solution
C#
int[] marks = [35, 72, 90, 64, 18];
string[] cities = ["Agra", "Pune", "Delhi", "Surat"];

Console.WriteLine(CountAbove(marks, 60));
Console.WriteLine(CountAbove(cities, "Kochi"));

var topper = new Pair<string, int>("Ananya", 90);
Console.WriteLine(topper);

int CountAbove<T>(IEnumerable<T> items, T limit) where T : IComparable<T>
{
    int count = 0;
    foreach (T item in items)
    {
        if (item.CompareTo(limit) > 0)
        {
            count++;
        }
    }
    return count;
}

record Pair<TFirst, TSecond>(TFirst First, TSecond Second);
Output
3
2
Pair { First = Ananya, Second = 90 }

72, 90 and 64 are above 60. Alphabetically, "Pune" and "Surat" come after "Kochi". Records can be generic too, so Pair<TFirst, TSecond> works for any two types.

Quick Quiz

1. Why is List<int> better than an old ArrayList for numbers?

2. What does where T : IComparable<T> allow inside a generic method?

3. What is default(T) when T is string?

4. Which interface lets a generic method use + and T.Zero for any number type?

Learn C# with a mentor

Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call