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:
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; }
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
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; }
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:
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); } }
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
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})"; }
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:
| Constraint | T must be… |
|---|---|
where T : class | a reference type |
where T : struct | a non-nullable value type |
where T : notnull | a non-nullable type |
where T : new() | a type with a public parameterless constructor |
where T : SomeBase | SomeBase or derived from it |
where T : ISomething | a type implementing the interface |
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}"); } }
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:
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;
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:
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;
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.
computing 12... 144 144 computing 99999... 9999800001