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
Diagram: the generic class Counter<T> with a static field Count is closed three times. With T = int it becomes Counter<int>, called Hit twice, so Count is 2. With T = string it becomes Counter<string>, called once, so Count is 1. With T = double it becomes Counter<double>, never called, so Count is 0. The three counters do not share the static field.
One generic definition, three closed types. Counter<int>, Counter<string> and Counter<double> are separate types at run time, so each has its own static Count. Output measured on .NET 10.

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
Bar chart of allocated memory for 10 million items: ArrayList 305.2 MB, List<int> 38.1 MB. ArrayList allocates eight times as much.
Bytes allocated to add 10 million ints and sum them. ArrayList boxes every int (32 bytes per item); List<int> stores them inline (4 bytes per item). .NET 10, Release, Apple Silicon Mac.

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.

  • #C#
  • #generics
  • #.NET
  • #constraints
  • #generic math

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑