C# Generics on .NET 10: Constraints, Variance, Boxing
C# generics re-tested on .NET 10: each constraint with its compiler error, allows ref struct, INumber<T>, variance, and ArrayList vs List<int> measured.
Revised November 2026. This tutorial first appeared in Korean in 2021. We recompiled every example on .NET 10.0.14 (C# 14). Two of them did not compile because of a space inside a float literal (
178.1 f, error CS1002), the constraint example declared two classes with the same name (error CS0101 and three more), and the original constraint table wrongly said an interface constraint accepts only classes. Below are the fixed examples, the constraints added since 2021 (notnull,allows ref struct), generic math, variance, and a measurement of what boxing costs.
This article is for developers who use List<T> every day but get stuck on where T : ..., wonder what the out in IEnumerable<out T> is for, or still meet ArrayList in old code. The short version: generics are code that takes a type as a parameter, and for each type you plug in, the runtime has a separate closed type. That one fact explains why constraints exist, why static fields in a generic class do not behave like globals, and why List<int> avoids boxing.
Generic methods: one method instead of one per type
If you write a print method for int, another for float and a third for string, you get three methods with identical bodies. The only thing that changes is the parameter's type, so you turn the type itself into a parameter, T.
The 2021 sample failed to build on .NET 10:
float height = 178.1 f; // as published in 2021
Program.cs(8,34): error CS1002: ; expected
Because of the space, the compiler ends the statement at 178.1 and asks for a semicolon. The space was most likely inserted by the blog editor's code formatter. The spaces it also put around angle brackets (print < int > (age)) are harmless; that line compiled. Here is the fixed version and its output:
static void Print<T>(T value)
{
Console.WriteLine(value);
}
int age = 35;
float height = 178.1f;
string name = "Superman";
Print<int>(age);
Print<float>(height);
Print(name); // type argument omitted: the compiler infers T = string
35
178.1
Superman
When the arguments make T obvious, you can drop <string>, and most real code does. The type parameter does not have to be called T; by convention a single one is T and several get descriptive names starting with T, such as TKey and TValue. We also renamed the method to Print to follow .NET naming guidelines.
Generic classes and closed types
A class can take type parameters too: class Box<T>. The 2021 example was a class named List<T> that wrapped an array. After removing the same literal-suffix space (2.2 f, CS1002 again) it printed:
10
2.2
일반화 클래스
(The last line is the Korean string from the original sample; it means "generic class".)
Watch the name, though. Projects created with .NET 6 or later have ImplicitUsings enabled, which imports System.Collections.Generic, so a home-made List<T> collides with the framework's List<T>. A type in the current namespace wins, so it compiles, but readers will assume it is the standard list. Call it Box<T> or FixedArray<T> instead.
A generic type with all its parameters filled in, such as Box<int> or Box<string>, is a closed type. Closed types are distinct types at run time, and each has its own static fields. This is the source of a classic bug: you keep a cache or counter in a static field of a generic class, and the value seems to vanish when you look at it through a different T.
Counter<int>.Hit(); Counter<int>.Hit(); Counter<string>.Hit();
Console.WriteLine($"int={Counter<int>.Count} string={Counter<string>.Count} double={Counter<double>.Count}");
static class Counter<T>
{
public static int Count;
public static void Hit() => Count++;
}
int=2 string=1 double=0
You can also use this on purpose. A value that must be computed once per type, such as a reflection lookup, can live in a static field of static class Cache<T>: a per-type cache with no dictionary lookup. And when you need an "empty" value without knowing what T is, use default; default(int) gave 0 and default(string) gave null.
Constraints, with the 2021 table corrected
An unconstrained T only gives you the members of object (ToString(), Equals() and so on). A where clause narrows the types T may be, and in return you can use what those types offer. The 2021 article showed these two declarations next to each other, and as one file they do not compile:
class List<T> where T : class { }
class List<U> where U : struct { }
error CS0264: Partial declarations of 'List<T>' must have the same type parameter names in the same order
error CS0265: Partial declarations of 'List<T>' have inconsistent constraints for type parameter 'T'
error CS0699: 'List<T>' does not define type parameter 'U'
error CS0101: The namespace '<global namespace>' already contains a definition for 'List'
Constraints do not take part in overloading. Same name plus same number of type parameters means same class, so name them RefList<T> and ValList<T> if you need both. The article's where T : Parent example (a parent and a child class) compiled as published.
The original table also said where T : SomeInterface requires a class that implements the interface. Structs qualify too: passing 3 to a method constrained with where T : IComparable<T> compiled without complaint. Here is the current list, with the diagnostic .NET 10 produced when we violated each constraint:
| Constraint | Meaning | Violation → result |
|---|---|---|
where T : class |
reference type | RefOnly(42) → error CS0452 |
where T : struct |
non-nullable value type (int? is rejected) |
ValOnly("abc"), ValOnly<int?>(null) → error CS0453 |
where T : new() |
public parameterless constructor | Make<string>() → error CS0310 |
where T : notnull (C# 8) |
non-nullable type | NoNull(maybe) with string? → warning CS8714 |
where T : unmanaged (C# 7.3) |
value type with no references inside | Size<string>() → error CS8377 |
where T : BaseClass |
that class or a subclass | — |
where T : IInterface |
any type implementing it, structs included | Iface(3) compiles |
where T : allows ref struct (C# 13) |
also allows ref structs such as Span<T> |
see below |
Note that notnull is the odd one out: violating it is a warning, not an error, because it belongs to nullable reference type analysis. Ignore the warning and the build succeeds. This is why many teams set <WarningsAsErrors>nullable</WarningsAsErrors>.
C# 13: allows ref struct
Ref structs such as Span<T> and ReadOnlySpan<T> must stay on the stack, so for years they could not be generic type arguments. C# 13 (.NET 9) added an "anti-constraint", allows ref struct, that opts in. On .NET 10:
static void F<T>(T x) { }
ReadOnlySpan<char> span = "hello".AsSpan();
F(span);
error CS9244: The type 'ReadOnlySpan<char>' may not be a ref struct or a type parameter allowing ref structs in order to use it as parameter 'T' in the generic type or method 'F<T>(T)'
ref struct Holder<T> where T : allows ref struct
{
public static string Describe(T value) => typeof(T).Name;
}
Console.WriteLine(Holder<ReadOnlySpan<char>>.Describe(span)); // ReadOnlySpan`1
Unlike other constraints, this one widens the set of allowed types, and in exchange forbids boxing T or storing it in a field of a normal class. You will rarely write it yourself, but it is already in the framework. Checking GenericParameterAttributes via reflection on .NET 10, the T of IEnumerable<T>, IEnumerator<T>, Func<T>, Action<T> and IComparer<T> carried AllowByRefLike; List<T> did not.
Generic math with INumber
In 2021 you could not write a generic "sum any numeric type" method, because nothing could say that T has a + operator. Since .NET 7, static abstract interface members make it possible through INumber<T>:
using System.Numerics;
static T Sum<T>(IEnumerable<T> xs) where T : INumber<T>
{
T total = T.Zero;
foreach (var x in xs) total += x;
return total;
}
Console.WriteLine(Sum(new[] { 1, 2, 3 }));
Console.WriteLine(Sum(new[] { 1.5, 2.25 }));
Console.WriteLine(Sum(new decimal[] { 0.1m, 0.2m }));
Console.WriteLine(Sum(new byte[] { 200, 100 }));
6
3.75
0.3
44
The last line is the trap. The byte total of 300 overflowed and silently became 44 (300 − 256), because the accumulator has the same type as the input. Changing the loop body to total = checked(total + x); made the same input throw OverflowException, so checked works in generic math just as it does with concrete types. If small integer types can reach your method, use checked, or offer an overload that accumulates into a wider type such as long or decimal.
Covariance and contravariance: IEnumerable
string derives from object, yet assigning a List<string> to a List<object> fails, while assigning it to IEnumerable<object> works:
IEnumerable<string> names = new List<string> { "a", "b" };
IEnumerable<object> objs = names; // OK: IEnumerable<out T>
List<object> lo = new List<string>(); // error CS0029
IEnumerable<object> eo = new List<int>(); // error CS0266
Action<object> printObj = o => Console.WriteLine($"obj:{o}");
Action<string> printStr = printObj; // OK: Action<in T>
printStr("contra"); // obj:contra
List<T> lets you put values in through Add(T). If a List<string> could pose as a List<object>, someone could add an int and break it. IEnumerable<out T> only hands values out, so it is safe, which is what out (covariance) declares. Action<in T> and IComparer<in T> only take values in, so the opposite direction is allowed: something that handles any object can be used where a string handler is expected (contravariance). The third line shows the limit: List<int> does not become IEnumerable<object>, because variance only applies to reference types. Viewing an int as an object requires boxing.
Measured: boxing in ArrayList vs List
ArrayList, the collection from before generics, stores everything as object. Every int you add is boxed into a new heap object, and every read needs an unboxing cast. To see the cost, we pre-sized each collection, added 10 million ints, summed them with foreach, and recorded the time and the difference in GC.GetAllocatedBytesForCurrentThread().
var list = new ArrayList(n);
for (int i = 0; i < n; i++) list.Add(i); // boxing
long sum = 0;
foreach (object o in list) sum += (int)o; // unboxing
// The List<int> version uses new List<int>(n) and foreach (int v in list)
.NET 10.0.14, Release build, Apple Silicon Mac, three runs after a warm-up:
| Run 1 | Run 2 | Run 3 | Allocated | Per item | |
|---|---|---|---|---|---|
ArrayList |
520.7 ms | 451.7 ms | 432.5 ms | 305.2 MB | 32 B |
List<int> |
21.1 ms | 15.5 ms | 19.2 ms | 38.1 MB | 4 B |
The 32 bytes per item are an 8-byte reference in the internal object[] plus a 24-byte box for each int. List<int> keeps the values in an int[], 4 bytes each. Most of the 20–30x time difference is the cost of creating 10 million small objects and having the GC track them. There is no reason to use ArrayList or Hashtable in new code, and they are the first thing worth replacing when you touch old code.
Which tool when
| Situation | Use |
|---|---|
| Same logic, different types | A generic method; let the compiler infer the type argument |
You need T's methods or constructor |
Only the where constraints you need (IComparable<T>, new(), …) |
| Numeric code over any number type | where T : INumber<T>, plus checked if overflow matters |
A general API that should also take Span<T> |
where T : allows ref struct (C# 13) |
| Passing a collection read-only | An IEnumerable<T> or IReadOnlyList<T> parameter; covariance lets callers pass lists of subtypes |
| A cache per type | A static field in static class Cache<T> (one per closed type) |
| Collections of value types | List<T> instead of ArrayList (one eighth of the allocation in our test) |
Func<T, TResult>, the delegate you will pair with generics most often, is covered in Func and Action delegates, lambdas in C# lambda expressions, and LINQ over IEnumerable<T> in LINQ basics.
Tested with .NET SDK 10.0.403 / runtime 10.0.14, C# 14, macOS on an Apple Silicon Mac.
FAQ
Can I declare class List<T> where T : class and class List<U> where U : struct side by side?
No. On .NET 10 that pair fails with error CS0101 (the namespace already contains a definition for 'List') plus CS0264, CS0265 and CS0699. Constraints are not part of a type's identity, so two classes with the same name and the same number of type parameters are the same class. Give them different names.
How much does ArrayList cost compared with List<int>?
Adding 10 million ints and summing them allocated 305.2 MB (32 bytes per item) and took 432–521 ms with ArrayList, versus 38.1 MB (4 bytes per item) and 16–21 ms with List<int> (.NET 10, Release, Apple Silicon Mac). The difference is boxing every int into an object.
Do all T share a static field in a generic class?
No. Counter<int> and Counter<string> are different types at run time, each with its own static fields. Calling Counter<int>.Hit() twice and Counter<string>.Hit() once gave 2 and 1, and Counter<double>, never called, stayed at 0.