Skip to main content
C# · lesson 14 of 26
C# intermediate 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` are different types.

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

  1. A generic pair. Write record Pair<T1, T2>(T1 First, T2 Second) with a Swap() method that returns Pair<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 }
  1. 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
  1. Average with INumber. Write T Average<T>(T[] values) where T : INumber<T> and call it with int[] { 1, 2 } and double[] { 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.

  1. Result.Bind. Add Bind<TOut>(Func<T, Result<TOut>> f) to Result<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.

Frequently Asked Questions

Why use generics instead of object?
Generics are type-safe at compile time and avoid boxing for value types. A List<int> stores ints directly; an ArrayList stores boxed objects. Generics also enable better IntelliSense and catch type errors before runtime.
What are generic constraints?
Constraints restrict which types can be used as type arguments. Common ones: where T : class (reference type), where T : struct (value type), where T : new() (has parameterless constructor), where T : IComparable<T> (implements interface). They enable you to call members on T.
What is the difference between covariance and contravariance?
Covariance (out T) means you can use a more derived type — IEnumerable<Dog> is assignable to IEnumerable<Animal>. Contravariance (in T) means you can use a less derived type — Action<Animal> is assignable to Action<Dog>. Both only apply to interfaces and delegates, not concrete classes.
Does List<object> accept a List<string>?
No. List<T> is invariant: if List<string> could be treated as List<object>, you could Add an int to it and corrupt the list. Only interfaces and delegates can be variant, and only in safe directions — IEnumerable<string> converts to IEnumerable<object> because IEnumerable<out T> only hands values out.
Does a static field in a generic class have one value or many?
Many. Each closed type — Cache<int>, Cache<string> — is a separate type at run time with its own copy of every static field. That makes static fields in generic classes a handy per-type cache, and a surprising bug if you expected a single shared counter.