Generics
Writing type-safe code that works for many types in C#: generic methods and classes, type parameters and constraints, and how the generic collections you already use work.
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.