C# Generics — generic classes, methods, and constraints explained

Generics let you write a single class or method that works with any type while remaining fully type-safe at compile time. Without generics you'd either duplicate code for every type or use object and lose type safety (plus pay the boxing cost for value types).

The classic example is List<T> — one class that works as List<int>, List<string>, List<Order>, and so on.

Generic Method

The simplest place to start is a generic method. The type parameter T is a placeholder filled in by the caller.

C# Example Code
// Swap any two values — no casting, no object
static void Swap<T>(ref T a, ref T b)
{
    T temp = a;
    a = b;
    b = temp;
}

int x = 1, y = 2;
Swap(ref x, ref y);
Console.WriteLine($"{x}, {y}"); // 2, 1

string s1 = "hello", s2 = "world";
Swap(ref s1, ref s2);
Console.WriteLine($"{s1}, {s2}"); // world, hello

The compiler infers T from the arguments — no need to write Swap<int>(ref x, ref y).

Generic Class

C# Example Code
// A simple stack that works for any type
public class Stack<T>
{
    private readonly List<T> _items = new();

    public void Push(T item) => _items.Add(item);

    public T Pop()
    {
        if (_items.Count == 0)
            throw new InvalidOperationException("Stack is empty.");
        T top = _items[^1];
        _items.RemoveAt(_items.Count - 1);
        return top;
    }

    public T Peek() => _items.Count > 0
        ? _items[^1]
        : throw new InvalidOperationException("Stack is empty.");

    public int Count => _items.Count;
}

var stack = new Stack<int>();
stack.Push(10);
stack.Push(20);
stack.Push(30);

Console.WriteLine(stack.Pop()); // 30
Console.WriteLine(stack.Peek()); // 20
Console.WriteLine(stack.Count);  // 2

Multiple Type Parameters

C# Example Code
// A key-value pair that accepts any two types
public class Pair<TKey, TValue>
{
    public TKey Key   { get; }
    public TValue Value { get; }

    public Pair(TKey key, TValue value) => (Key, Value) = (key, value);

    public override string ToString() => $"[{Key}: {Value}]";
}

var pair = new Pair<string, int>("Alice", 30);
Console.WriteLine(pair); // [Alice: 30]

var coords = new Pair<double, double>(51.5, -0.1);
Console.WriteLine(coords); // [51.5: -0.1]

Constraints — the where Keyword

Without constraints, T can be anything — you can only call methods on object. Constraints tell the compiler what capabilities T must have.

C# Example Code
// where T : IComparable<T> — guarantees T has CompareTo()
static T Max<T>(T a, T b) where T : IComparable<T>
    => a.CompareTo(b) >= 0 ? a : b;

Console.WriteLine(Max(3, 7));          // 7
Console.WriteLine(Max("apple", "pear")); // pear

Common Constraints

ConstraintMeaning
where T : structT must be a value type
where T : classT must be a reference type
where T : new()T must have a public parameterless constructor
where T : SomeClassT must inherit from SomeClass
where T : ISomeInterfaceT must implement ISomeInterface
where T : IComparable<T>T must implement IComparable<T>
where T : notnullT must be non-nullable
C# Example Code
// Multiple constraints on the same type parameter
public class Repository<T> where T : class, new()
{
    public T CreateDefault() => new T();
}

// Constraint on a method
static void PrintAll<T>(IEnumerable<T> items) where T : notnull
{
    foreach (var item in items)
        Console.WriteLine(item.ToString());
}

Generic Interface

C# Example Code
public interface IRepository<T>
{
    void Add(T item);
    T? GetById(int id);
    IEnumerable<T> GetAll();
}

public class InMemoryRepository<T> : IRepository<T>
{
    private readonly Dictionary<int, T> _store = new();
    private int _nextId = 1;

    public void Add(T item) => _store[_nextId++] = item;

    public T? GetById(int id)
        => _store.TryGetValue(id, out var val) ? val : default;

    public IEnumerable<T> GetAll() => _store.Values;
}

var repo = new InMemoryRepository<string>();
repo.Add("first");
repo.Add("second");

foreach (var item in repo.GetAll())
    Console.WriteLine(item);
// first
// second

default(T)

When you need the zero/null value of an unknown type, use default(T) (or just default in context where the type can be inferred).

C# Example Code
static T FirstOrDefault<T>(IEnumerable<T> source)
{
    foreach (var item in source)
        return item;
    return default!; // int → 0, string → null, bool → false
}

Console.WriteLine(FirstOrDefault(new[] { 5, 10, 15 })); // 5
Console.WriteLine(FirstOrDefault(Array.Empty<int>()));   // 0

Real-World Example: Generic Result Type

C# Example Code
// Represents either a success value or an error — avoids exceptions for expected failures
public class Result<T>
{
    public bool IsSuccess { get; }
    public T? Value       { get; }
    public string? Error  { get; }

    private Result(bool success, T? value, string? error)
        => (IsSuccess, Value, Error) = (success, value, error);

    public static Result<T> Ok(T value)      => new(true,  value, null);
    public static Result<T> Fail(string err) => new(false, default, err);
}

Result<int> Parse(string input)
    => int.TryParse(input, out int n)
        ? Result<int>.Ok(n)
        : Result<int>.Fail($"'{input}' is not a valid integer.");

var ok   = Parse("42");
var fail = Parse("abc");

Console.WriteLine(ok.IsSuccess ? $"Got: {ok.Value}" : $"Error: {ok.Error}");
// Got: 42
Console.WriteLine(fail.IsSuccess ? $"Got: {fail.Value}" : $"Error: {fail.Error}");
// Error: 'abc' is not a valid integer.

Generics vs object

Generics (T)object
Type safetyCompile-timeRuntime (cast required)
Boxing for value typesNoYes
PerformanceFull — no overheadSlower for value types
IntelliSense / toolingFullLimited
Null safetyConstrainedAlways nullable

Prefer generics whenever you'd otherwise write the same logic for multiple types or reach for object as a container.