C# · lesson 14 of 26
Generics in C#
C# generics by example: generic classes and methods, where constraints, static abstract members, a Result<T> type, and covariance vs contravariance.
By SkillByExample · Updated
Generic Classes
Write the logic once with a type parameter T; each use such as Stack<int> gets a fully typed version with no casts and no boxing.
var ints = new Stack<int>();
ints.Push(1);
ints.Push(2);
Console.WriteLine(ints.Pop() + 10);
var words = new Stack<string>();
words.Push("hello");
Console.WriteLine(words.Peek().ToUpper());
Console.WriteLine(words.TryPop(out var w) + " " + w);
Console.WriteLine(words.TryPop(out var none) + " " + (none ?? "(null)"));
Console.WriteLine(ints.GetType());
Console.WriteLine(ints.GetType() == words.GetType());
public class Stack<T>
{
private readonly List<T> _items = new();
public void Push(T item) => _items.Add(item);
public T Peek() => _items.Count > 0 ? _items[^1] : throw new InvalidOperationException("Stack is empty");
public T Pop()
{
T item = Peek();
_items.RemoveAt(_items.Count - 1);
return item;
}
public bool TryPop(out T? item)
{
item = default;
if (_items.Count == 0) return false;
item = Pop();
return true;
}
}
$ dotnet run
12
HELLO
True hello
False (null)
Stack`1[System.Int32]
False
ints.Pop() + 10 compiles because Pop returns int, not object, and words.Peek().ToUpper() compiles because it returns string. default for string is null, which is why the failed TryPop leaves none null. The runtime type name, Stack1[System.Int32], shows the type argument is real at run time — StackandStack
Generic Methods and Type Inference
A method can have its own type parameters. The compiler usually infers them from the arguments, so you rarely write them out.
int x = 1, y = 2;
Swap(ref x, ref y);
Console.WriteLine($"x={x}, y={y}");
var dict = ZipToDictionary(new[] { "a", "b" }, new[] { 1, 2 });
Console.WriteLine(string.Join(", ", dict));
Console.WriteLine(string.Join(" ", Repeat("ab", 3)));
Console.WriteLine(Repeat<double>(1, 2)[0].GetType().Name);
static void Swap<T>(ref T a, ref T b) => (a, b) = (b, a);
static Dictionary<TKey, TValue> ZipToDictionary<TKey, TValue>(
IEnumerable<TKey> keys, IEnumerable<TValue> values) where TKey : notnull
=> keys.Zip(values).ToDictionary(p => p.First, p => p.Second);
static T[] Repeat<T>(T value, int times) => Enumerable.Repeat(value, times).ToArray();
$ dotnet run
x=2, y=1
[a, 1], [b, 2]
ab ab ab
Double
ZipToDictionary inferred both TKey = string and TValue = int from the arrays. The last line specifies T explicitly: without <double>, the literal 1 would have made T an int.
Generic Constraints
Without constraints, T supports only what object does. A where clause promises more — and the compiler holds callers to that promise.
Console.WriteLine(Max(3, 7));
Console.WriteLine(Max("apple", "zap"));
Console.WriteLine(ParseOrNull<int>("42"));
Console.WriteLine(ParseOrNull<int>("4x2") is null);
Console.WriteLine(ParseOrNull<DateOnly>("2024-01-15"));
var list = Create<List<string>>();
list.Add("made by new T()");
Console.WriteLine(list[0]);
Console.WriteLine(Sum(new[] { 1, 2, 3 }));
Console.WriteLine(Sum(new[] { 0.5, 0.25 }));
static T Max<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b) >= 0 ? a : b;
static T? ParseOrNull<T>(string s) where T : struct, IParsable<T> =>
T.TryParse(s, System.Globalization.CultureInfo.InvariantCulture, out T result) ? result : null;
static T Create<T>() where T : new() => new T();
static T Sum<T>(IEnumerable<T> values) where T : System.Numerics.INumber<T>
{
T total = T.Zero;
foreach (var v in values) total += v;
return total;
}
$ dotnet run
7
zap
42
True
01/15/2024
made by new T()
6
0.75
IComparable<T> unlocks CompareTo; new() unlocks new T(). The last two constraints use static abstract interface members (.NET 7+): IParsable<T> lets you call T.TryParse, and INumber<T> gives you T.Zero and +, so one Sum works for int and double. (DateOnly prints as 01/15/2024 because this run used the invariant culture; your machine’s culture may format it differently.) Calling Max(new object(), new object()) would not compile (CS0311), because object does not implement IComparable<object>.
A Generic Result Type
Result<T> puts success or failure into the return type, so callers cannot forget the failure case.
Console.WriteLine(Parse("42").Map(n => n * 2));
Console.WriteLine(Parse("abc").Map(n => n * 2));
Console.WriteLine(Parse("7").Map(n => $"#{n}").Map(s => s.Length));
static Result<int> Parse(string s) =>
int.TryParse(s, out int n) ? Result<int>.Ok(n) : Result<int>.Fail($"'{s}' is not an integer");
public class Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public string? Error { get; }
private Result(bool ok, T? value, string? error) => (IsSuccess, Value, Error) = (ok, value, error);
public static Result<T> Ok(T value) => new(true, value, null);
public static Result<T> Fail(string error) => new(false, default, error);
public Result<TOut> Map<TOut>(Func<T, TOut> f) =>
IsSuccess ? Result<TOut>.Ok(f(Value!)) : Result<TOut>.Fail(Error!);
public override string ToString() => IsSuccess ? $"Ok({Value})" : $"Fail({Error})";
}
$ dotnet run
Ok(84)
Fail('abc' is not an integer)
Ok(2)
Map runs the function only on success; a failure passes straight through with its original message, so "abc" never reaches n * 2. Each Map can change the type — int to string to int in the last line — which is why Map has its own type parameter TOut.
Static Fields Are Per Closed Type
Each constructed type like Counter<int> is a separate type at run time, with its own statics.
Counter<int>.Hit();
Counter<int>.Hit();
Counter<string>.Hit();
Console.WriteLine($"int: {Counter<int>.Count}, string: {Counter<string>.Count}, bool: {Counter<bool>.Count}");
public static class Counter<T>
{
public static int Count;
public static void Hit() => Count++;
}
$ dotnet run
int: 2, string: 1, bool: 0
There are three independent Count fields here. This is handy for per-type caches — EqualityComparer<T>.Default works this way — and a trap if you expected one shared counter.
Covariance and Contravariance
Generic classes are invariant. Interfaces and delegates can opt in: out T (covariant) for types that only produce T, in T (contravariant) for types that only consume it.
IEnumerable<string> strings = new List<string> { "a", "b" };
IEnumerable<object> objects = strings;
Console.WriteLine(objects.Count());
IProducer<object> producer = new StringProducer();
Console.WriteLine(producer.Produce());
Action<object> printAny = o => Console.WriteLine($"Got: {o}");
Action<string> printString = printAny;
printString("hello");
IConsumer<string> consumer = new ObjectConsumer();
consumer.Consume("world");
public interface IProducer<out T> { T Produce(); }
public class StringProducer : IProducer<string> { public string Produce() => "produced"; }
public interface IConsumer<in T> { void Consume(T value); }
public class ObjectConsumer : IConsumer<object>
{
public void Consume(object value) => Console.WriteLine($"Consumed {value}");
}
$ dotnet run
2
produced
Got: hello
Consumed world
A producer of strings is safely a producer of objects, so out goes “up” the hierarchy. A consumer that accepts any object can certainly accept a string, so in goes “down”. What does not compile is List<object> l = new List<string>(); (CS0029): a List<T> both reads and writes T, so neither direction is safe.
A Generic Repository
Generics plus constraints remove per-entity boilerplate: one in-memory repository works for any entity and any key type.
var customers = new InMemoryRepository<Customer, int>(c => c.Id);
customers.Add(new Customer(1, "Alice"));
customers.Add(new Customer(2, "Bob"));
customers.Add(new Customer(1, "Alice B."));
Console.WriteLine(customers.GetById(1));
Console.WriteLine(customers.GetById(9)?.ToString() ?? "not found");
Console.WriteLine(customers.GetAll().Count());
var skus = new InMemoryRepository<Product, string>(p => p.Sku);
skus.Add(new Product("SKU-1", 9.99m));
Console.WriteLine(skus.GetById("SKU-1"));
public record Customer(int Id, string Name);
public record Product(string Sku, decimal Price);
public class InMemoryRepository<T, TKey>(Func<T, TKey> keyOf)
where T : class
where TKey : notnull
{
private readonly Dictionary<TKey, T> _store = new();
public void Add(T entity) => _store[keyOf(entity)] = entity;
public T? GetById(TKey id) => _store.GetValueOrDefault(id);
public IEnumerable<T> GetAll() => _store.Values;
}
$ dotnet run
Customer { Id = 1, Name = Alice B. }
not found
2
Product { Sku = SKU-1, Price = 9.99 }
The caller passes a key selector, so the repository never needs to know that customers have Id and products have Sku. Adding a second customer with id 1 overwrote the first — an upsert — so there are two customers, not three. The T : class constraint is what makes T? mean “nullable reference” and lets GetById return null for a miss.
Practice
- A generic pair. Write
record Pair<T1, T2>(T1 First, T2 Second)with aSwap()method that returnsPair<T2, T1>.
Solution
var p = new Pair<string, int>("age", 42);
Console.WriteLine(p);
Console.WriteLine(p.Swap());
public record Pair<T1, T2>(T1 First, T2 Second)
{
public Pair<T2, T1> Swap() => new(Second, First);
}
$ dotnet run
Pair { First = age, Second = 42 }
Pair { First = 42, Second = age }
- Generic Min over a sequence. Write
T MinOf<T>(IEnumerable<T> items) where T : IComparable<T>that throws on an empty sequence, and try it on ints and strings.
Solution
Console.WriteLine(MinOf(new[] { 5, 2, 9 }));
Console.WriteLine(MinOf(new[] { "pear", "apple", "fig" }));
static T MinOf<T>(IEnumerable<T> items) where T : IComparable<T>
{
using var e = items.GetEnumerator();
if (!e.MoveNext()) throw new InvalidOperationException("empty");
T min = e.Current;
while (e.MoveNext())
if (e.Current.CompareTo(min) < 0) min = e.Current;
return min;
}
$ dotnet run
2
apple
- Average with INumber. Write
T Average<T>(T[] values) where T : INumber<T>and call it withint[] { 1, 2 }anddouble[] { 1, 2 }. Why do the results differ?
Solution
using System.Numerics;
Console.WriteLine(Average(new[] { 1, 2 }));
Console.WriteLine(Average(new[] { 1.0, 2.0 }));
static T Average<T>(T[] values) where T : INumber<T>
{
T sum = T.Zero;
foreach (var v in values) sum += v;
return sum / T.CreateChecked(values.Length);
}
$ dotnet run
1
1.5
For int, / is integer division, so 3 / 2 is 1. The generic code uses whatever / the type defines.
- Result.Bind. Add
Bind<TOut>(Func<T, Result<TOut>> f)toResult<T>so you can chain a second step that can itself fail — for example, rejecting negative numbers after parsing.
Solution
foreach (var s in new[] { "5", "-5", "x" })
Console.WriteLine(Parse(s).Bind(NonNegative));
static Result<int> Parse(string s) =>
int.TryParse(s, out int n) ? Result<int>.Ok(n) : Result<int>.Fail($"'{s}' is not an integer");
static Result<int> NonNegative(int n) =>
n >= 0 ? Result<int>.Ok(n) : Result<int>.Fail($"{n} is negative");
public class Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public string? Error { get; }
private Result(bool ok, T? v, string? e) => (IsSuccess, Value, Error) = (ok, v, e);
public static Result<T> Ok(T v) => new(true, v, null);
public static Result<T> Fail(string e) => new(false, default, e);
public Result<TOut> Bind<TOut>(Func<T, Result<TOut>> f) =>
IsSuccess ? f(Value!) : Result<TOut>.Fail(Error!);
public override string ToString() => IsSuccess ? $"Ok({Value})" : $"Fail({Error})";
}
$ dotnet run
Ok(5)
Fail(-5 is negative)
Fail('x' is not an integer)
Map would have produced a Result<Result<int>>; Bind flattens it.