Generics

Generics let you write a class or method once and use it with any type, while keeping full type safety. List<T> and Dictionary<TKey, TValue> are generic — the T is a type parameter filled in when you use them. This lesson shows how to write your own generic methods and classes, and how constraints say what a type parameter must support.

The problem generics solve

Suppose you want a method that returns the larger of two values. Without generics you'd need a copy for int, another for double, another for string… or one that takes object and loses type checking. A generic method works for all of them:

C#
Console.WriteLine(Larger(3, 9));
Console.WriteLine(Larger(2.5, -1.0));
Console.WriteLine(Larger("apple", "banana"));
Console.WriteLine(Larger<DateOnly>(new(2026, 1, 1), new(2025, 12, 31)));

static T Larger<T>(T a, T b) where T : IComparable<T>
{
    return a.CompareTo(b) >= 0 ? a : b;
}
Output
9
2.5
banana
1/1/2026

<T> after the name declares the type parameter. Usually the compiler infers T from the arguments, but you can state it explicitly, as in the last call. where T : IComparable<T> is a constraint: it says T must be comparable, which is what lets the method call CompareTo.

Generic methods

C#
int[] numbers = [4, 8, 15, 16, 23, 42];
string[] words = ["red", "green", "blue"];

Console.WriteLine(string.Join(", ", Swapped(numbers, 0, 5)));
Console.WriteLine(string.Join(", ", Swapped(words, 0, 2)));
Console.WriteLine(CountWhere(numbers, n => n % 2 == 0));
Console.WriteLine(CountWhere(words, w => w.Contains('e')));

static T[] Swapped<T>(T[] items, int i, int j)
{
    T[] copy = [.. items];
    (copy[i], copy[j]) = (copy[j], copy[i]);
    return copy;
}

static int CountWhere<T>(IEnumerable<T> items, Func<T, bool> test)
{
    int count = 0;
    foreach (T item in items)
        if (test(item)) count++;
    return count;
}
Output
42, 8, 15, 16, 23, 4
blue, green, red
4
3

Func<T, bool> is a generic delegate type — a function from T to bool — covered in lesson 47.

Generic classes

A generic class takes type parameters after its name. Here is a simple fixed-capacity history that remembers the most recent items of any type:

C#
var recentSearches = new RecentList<string>(3);
foreach (string s in new[] { "c# records", "linq groupby", "span vs array", "async await" })
    recentSearches.Add(s);
Console.WriteLine(string.Join(" | ", recentSearches.Items));

var recentScores = new RecentList<int>(2);
recentScores.Add(70);
recentScores.Add(85);
recentScores.Add(92);
Console.WriteLine(recentScores.Items.Average());

class RecentList<T>(int capacity)
{
    private readonly Queue<T> _items = new();

    public IReadOnlyCollection<T> Items => _items;

    public void Add(T item)
    {
        if (_items.Count == capacity)
            _items.Dequeue();
        _items.Enqueue(item);
    }
}
Output
linq groupby | span vs array | async await
88.5

RecentList<string> and RecentList<int> are separate, fully typed classes: adding an int to the string list is a compile error.

Several type parameters

C#
var p = new Pair<string, int>("answer", 42);
Console.WriteLine(p);
Console.WriteLine(p.Swap());

var result = Result<int>.Ok(7);
var failure = Result<int>.Fail("not found");
Console.WriteLine($"{result} / {failure}");

record Pair<TFirst, TSecond>(TFirst First, TSecond Second)
{
    public Pair<TSecond, TFirst> Swap() => new(Second, First);
}

record Result<T>(bool Success, T? Value, string? Error)
{
    public static Result<T> Ok(T value) => new(true, value, null);
    public static Result<T> Fail(string error) => new(false, default, error);
    public override string ToString() => Success ? $"Ok({Value})" : $"Fail({Error})";
}
Output
Pair { First = answer, Second = 42 }
Pair { First = 42, Second = answer }
Ok(7) / Fail(not found)

By convention, type parameters are named T, or start with T when there are several (TKey, TValue). default gives a type parameter's default value — null for reference types, 0 for numbers.

Constraints

Without constraints, the compiler only lets you do things that work for every type — which isn't much. Constraints narrow the allowed types so you can do more:

ConstraintT must be…
where T : classa reference type
where T : structa non-nullable value type
where T : notnulla non-nullable type
where T : new()a type with a public parameterless constructor
where T : SomeBaseSomeBase or derived from it
where T : ISomethinga type implementing the interface
C#
var zoo = new Shelter<Dog>();
zoo.Admit("Rex");
zoo.Admit("Bella");
zoo.FeedAll();

abstract class Animal
{
    public string Name { get; set; } = "";
    public abstract string Food { get; }
}

class Dog : Animal
{
    public override string Food => "kibble";
}

class Shelter<T> where T : Animal, new()
{
    private readonly List<T> _animals = [];

    public void Admit(string name) => _animals.Add(new T { Name = name });

    public void FeedAll()
    {
        foreach (T a in _animals)
            Console.WriteLine($"Feeding {a.Name} some {a.Food}");
    }
}
Output
Feeding Rex some kibble
Feeding Bella some kibble

Because of where T : Animal, new(), the shelter can create new animals and use Name and Food. Trying new Shelter<string>() is a compile error.

Generic math

Interfaces in System.Numerics such as INumber<T> describe numeric types. Constraining to them lets you write one method that works with int, double, decimal and every other number type, using normal operators:

C#
using System.Numerics;

Console.WriteLine(Average([1, 2, 3, 4]));
Console.WriteLine(Average([1.5, 2.5]));
Console.WriteLine(Average([10.25m, 20.75m, 30m]));
Console.WriteLine(Clamp(150, 0, 100));

static T Average<T>(T[] values) where T : INumber<T>
{
    T sum = T.Zero;
    foreach (T v in values) sum += v;
    return sum / T.CreateChecked(values.Length);
}

static T Clamp<T>(T value, T min, T max) where T : INumber<T> =>
    value < min ? min : value > max ? max : value;
Output
2
2
20.333333333333333333333333333
100

Note that Average of ints uses integer division, just as it would with plain int code.

Generic interfaces and variance

Interfaces can be generic too — IEnumerable<T>, IComparable<T>, IRepository<T>. Some library interfaces are covariant (marked out T), which means a sequence of a derived type can be used as a sequence of its base type:

C#
List<Circle> circles = [new(1), new(2)];
IEnumerable<Shape> shapes = circles;    // allowed: IEnumerable<out T> is covariant
foreach (Shape s in shapes) Console.WriteLine(s);

record Shape;
record Circle(double Radius) : Shape;
Output
Circle { Radius = 1 }
Circle { Radius = 2 }

This is safe because you can only read from an IEnumerable. A List<Circle> can't be treated as a List<Shape>, because then someone could add a square to it.

Try it yourself

Write a generic Cache<TKey, TValue> that computes a value only the first time it's requested.

Try it yourself · Program.cs
Edit the code, copy it into Program.cs and run it with dotnet run Program.cs (.NET 10 or later). How to set up .NET
Output
  computing 12...
144
144
  computing 99999...
9999800001