Why generics exist

Many pieces of code do not care what type of data they handle: a list stores items, a cache keeps values, a method returns the largest of a set. Without generics there are two bad options: write the same code once per type, or write it once for object, the base type of everything. The first duplicates code; the second throws away the static typing that makes C# safe. Before C# 2.0 (2005), the standard collections took the second route. ArrayList still exists:

using System.Collections;

ArrayList readings = new ArrayList();
readings.Add(12.5m);
readings.Add(13.1m);
readings.Add("14.0");          // compiles: an ArrayList accepts any object

decimal total = 0;
foreach (object item in readings)
{
    total += (decimal)item;    // a cast is needed to get a decimal back
    Console.WriteLine($"Running total: {total}");
}
Running total: 12.5
Running total: 25.6
Unhandled exception. System.InvalidCastException: Unable to cast object of type 'System.String' to type 'System.Decimal'.

The mistake is found only when the program runs, and only when that line is reached. Generics solve this: a class, interface, record, struct, method or delegate declares a placeholder for a type, and code that uses it says which type it wants. The compiler then checks every use against that type. The same mistake with List<decimal> is caught before the program can run:

List<decimal> readings = [12.5m, 13.1m];
readings.Add("14.0");
example.cs(2,14): error CS1503: Argument 1: cannot convert from 'string' to 'decimal'

Type parameters and type arguments

In List<T>, T is a type parameter: a name that stands for a type, written between angle brackets after the name of the generic type or method. When you write List<decimal>, decimal is the type argument that replaces T everywhere in the class. It mirrors ordinary parameters and arguments, but for types instead of values.

The collections from Arrays, Lists and Collections are all generic: List<T>, HashSet<T>, Queue<T>, and Dictionary<TKey, TValue> with two type parameters. By convention a single type parameter is called T; when there are several, they get descriptive names that start with T.

var readings = new List<decimal> { 12.5m, 13.1m };
var tariffs = new Dictionary<string, decimal> { ["standard"] = 0.32m };

Console.WriteLine(readings.GetType());
Console.WriteLine(tariffs.GetType());
Console.WriteLine(typeof(List<string>) == typeof(List<decimal>));
System.Collections.Generic.List`1[System.Decimal]
System.Collections.Generic.Dictionary`2[System.String,System.Decimal]
False

The backtick and number (List`1, Dictionary`2) are how .NET names generic types internally: the number of type parameters. The last line shows that List<string> and List<decimal> are different types at run time.

Generic methods

A generic method declares its own type parameters after its name. Each call can use a different type argument. Usually you do not write it: the compiler infers it from the arguments, just as var infers a variable's type.

string[] tickets = ["INC-10231", "INC-10232", "INC-10233"];
int[] temperatures = [14, 17, 21];

Console.WriteLine(LastOf(tickets));          // T is inferred as string
Console.WriteLine(LastOf(temperatures));     // T is inferred as int
Console.WriteLine(LastOf<double>([1.5, 2.5])); // T given explicitly

string first = "GW-07", second = "GW-08";
Swap(ref first, ref second);
Console.WriteLine($"{first} {second}");

static T LastOf<T>(T[] items) => items[items.Length - 1];

static void Swap<T>(ref T a, ref T b) => (a, b) = (b, a);
INC-10233
21
2.5
GW-08 GW-07

Inside LastOf, T is used like any type: for the parameter (T[]) and the return type. The compiler checks each call: LastOf(tickets) returns a string, so you can call string methods on the result without a cast. Many .NET methods are generic in this way, including Array.Empty<T>(), Enum.Parse<T>() and almost every LINQ method.

Generic classes, records and interfaces

A class declares type parameters after its name, and the whole class body can use them. This class keeps the last few values of any type — temperatures, ticket numbers — and drops the oldest:

var temperatures = new RecentValues<double>(3);
foreach (double t in new[] { 14.2, 15.0, 16.8, 17.5 })
{
    temperatures.Add(t);
}
Console.WriteLine(string.Join(", ", temperatures.Items));

var tickets = new RecentValues<string>(2);
tickets.Add("INC-10231");
tickets.Add("INC-10232");
tickets.Add("INC-10233");
Console.WriteLine(string.Join(", ", tickets.Items));

class RecentValues<T>
{
    private readonly int capacity;
    private readonly Queue<T> items = new();

    public RecentValues(int capacity) => this.capacity = capacity;

    public IReadOnlyCollection<T> Items => items;

    public void Add(T value)
    {
        items.Enqueue(value);
        if (items.Count > capacity)
        {
            items.Dequeue();
        }
    }
}
15, 16.8, 17.5
INC-10232, INC-10233

Records, structs and interfaces can be generic in the same way. A generic record is a short way to pair a value of any type with some fixed fields:

var reading = new Measurement<decimal>("GW-07", 12.5m);
var status = new Measurement<string>("GW-08", "offline");

Console.WriteLine(reading);
Console.WriteLine(status);
Console.WriteLine(reading.Value * 2);    // Value is a decimal: arithmetic works

record Measurement<T>(string MeterId, T Value);
Measurement { MeterId = GW-07, Value = 12.5 }
Measurement { MeterId = GW-08, Value = offline }
25.0

.NET is full of generic interfaces: IEnumerable<T> (anything you can loop over), IComparable<T> (can be ordered), IEquatable<T> (can be compared for equality). Records are explained in Records, Structs and Enums, interfaces in Inheritance, Interfaces and Polymorphism.

Constraints

Inside a generic method, the compiler knows nothing about T — it could be any type — so you can only do what every type can do: assign it, pass it on, call ToString() or Equals(). To compare two values of type T, the method must promise that T supports comparison. A generic constraint, a where clause after the signature, makes that promise. The compiler then allows the operations it guarantees, and refuses type arguments that do not meet it.

Console.WriteLine(Largest([14, 21, 17]));
Console.WriteLine(Largest(["INC-10231", "INC-10233", "INC-10232"]));
Console.WriteLine(Largest([new DateOnly(2026, 10, 3), new DateOnly(2026, 9, 28)]).ToString("yyyy-MM-dd"));

static T Largest<T>(T[] items) where T : IComparable<T>
{
    T best = items[0];
    foreach (T item in items)
    {
        if (item.CompareTo(best) > 0)
        {
            best = item;
        }
    }
    return best;
}
21
INC-10233
2026-10-03

int, string and DateOnly all implement IComparable<T>. A type that does not is rejected at compile time, with a message that names the missing interface:

Console.WriteLine(Largest(new[] { new Meter("GW-07"), new Meter("GW-08") }));

static T Largest<T>(T[] items) where T : IComparable<T>
{
    T best = items[0];
    foreach (T item in items)
    {
        if (item.CompareTo(best) > 0)
        {
            best = item;
        }
    }
    return best;
}

record Meter(string Id);
example.cs(1,19): error CS0311: The type 'Meter' cannot be used as type parameter 'T' in the generic type or method 'Largest<T>(T[])'. There is no implicit reference conversion from 'Meter' to 'System.IComparable<Meter>'.

The constraints you will meet most often:

Constraint The type argument must…
where T : ISomeInterface implement that interface
where T : SomeBaseClass be that class or derive from it
where T : class be a non-nullable reference type (class? also allows nullable ones)
where T : struct be a non-nullable value type
where T : notnull be a non-nullable type (value or reference)
where T : new() have a public parameterless constructor

Several constraints are separated by commas (where T : class, IComparable<T>, new()), and each type parameter gets its own where clause. Dictionary<TKey, TValue>, for example, declares where TKey : notnull: a key may not be null.

Generic math

Arithmetic used to be impossible in generic code: there was no interface for "supports +". Since C# 11 and .NET 7, the numeric types implement interfaces such as INumber<T> (namespace System.Numerics), which include the operators and values such as T.Zero. One method can then add up integers and decimals alike:

using System.Numerics;

int[] outagesPerDay = [3, 0, 5, 2];
decimal[] invoiceAmounts = [84.20m, 112.75m, 64.05m];

Console.WriteLine(Total(outagesPerDay));
Console.WriteLine(Total(invoiceAmounts));

static T Total<T>(T[] values) where T : INumber<T>
{
    T sum = T.Zero;
    foreach (T value in values)
    {
        sum += value;
    }
    return sum;
}
10
261.00

You will use this less often than you read about it: LINQ's Sum() already works for the built-in number types. It matters most to authors of libraries.

default(T) and typeof(T)

Generic code sometimes needs "an empty value" of a type it does not know. default(T) gives the default value for the type argument: 0 for numbers, false for bool, null for reference types. And because .NET keeps type arguments at run time, typeof(T) tells the code which type it is working with.

Console.WriteLine(Describe<int>());
Console.WriteLine(Describe<decimal>());
Console.WriteLine(Describe<bool>());
Console.WriteLine(Describe<string>());

static string Describe<T>() =>
    $"{typeof(T).Name}: default is {default(T)?.ToString() ?? "null"}";
Int32: default is 0
Decimal: default is 0
Boolean: default is False
String: default is null

Methods such as LINQ's FirstOrDefault() use exactly this: on an empty List<int> they return 0, on an empty List<string> null. That 0 cannot be told apart from a real zero — a reason to be careful with the …OrDefault methods on value types.

Boxing: what generics avoid

Value types such as int, decimal and DateOnly are stored directly in a variable; reference types live on the heap (see Variables, Types and Operators and Memory Management). When a value type is assigned to a variable of type object (or to an interface type), .NET performs boxing: it allocates a small object on the heap and copies the value into it. Getting the value back out — unboxing — needs a cast to exactly the original type.

int reading = 42;
object boxed = reading;        // boxing: the value is copied into a new object
reading = 50;

Console.WriteLine(boxed);      // the box holds its own copy
Console.WriteLine(boxed.GetType());

int back = (int)boxed;         // unboxing: cast to the original type
Console.WriteLine(back);

long wrong = (long)boxed;      // an int box cannot be unboxed as long
Console.WriteLine(wrong);
42
System.Int32
42
Unhandled exception. System.InvalidCastException: Unable to cast object of type 'System.Int32' to type 'System.Int64'.

Every box is an extra allocation that the garbage collector later has to clean up. That is why the old ArrayList is slow for numbers: each Add(12.5m) boxes. A List<decimal> stores the decimals themselves in its internal array — no boxes, no casts. This is the second reason to use generic collections, after type safety. Boxing still happens in modern code, for example when a value type is passed to a parameter of type object, but rarely enough that it only matters in performance-critical loops.

Variance in brief

A string is an object, so is a List<string> a List<object>? No — otherwise code could add a decimal to the "list of objects" and break the list of strings:

List<string> customers = ["Amara Okafor", "Mei Chen"];
List<object> things = customers;
things.Add(12.5m);
example.cs(2,23): error CS0029: Cannot implicitly convert type 'System.Collections.Generic.List<string>' to 'System.Collections.Generic.List<object>'

Interfaces that only hand out values can safely allow it, and some do. IEnumerable<T> is declared with out T (it is covariant), because you can only read from it:

List<string> customers = ["Amara Okafor", "Mei Chen"];
IEnumerable<object> things = customers;     // allowed: read-only view

foreach (object thing in things)
{
    Console.WriteLine(thing);
}
Amara Okafor
Mei Chen

The opposite, in T (contravariant), appears on types that only take values, such as IComparer<T> and the Action<T> delegate from Delegates, Lambdas and Events. You rarely declare variance yourself, but it explains why some assignments between generic types compile and others do not.