Generics in C#: What the Runtime Knows about T

By Sergey Nosov

16 October 2026

Suppose a report adds up quantities stored in an ArrayList, the general-purpose list .NET offered before generics. Most of the code stores an int. One path stores a long it read from a 64-bit database column, and a form handler stores the text it received:

using System.Collections;

var quantities = new ArrayList();
quantities.Add(3);
// A long from a bigint column
quantities.Add(5L);
// A string from a form field
quantities.Add("7");

int total = 0;
foreach (object quantity in quantities)
{
    // Compiles; fails at run time
    total += (int)quantity;
}

Every line compiles, because ArrayList.Add accepts any object. The first item unboxes. The second throws an InvalidCastException: “Unable to cast object of type 'System.Int64' to type 'System.Int32'.” The value is 5, which fits in an int, but unboxing does not convert: a boxed long cannot be unboxed directly as an int. The mistake happens where the value goes in, but the exception fires where it comes out, possibly in another module.

Here is the same code with a generic list:

var quantities = new List<int>();
quantities.Add(3);
// CS1503: cannot convert
// from 'long' to 'int'
quantities.Add(5L);
// CS1503: cannot convert
// from 'string' to 'int'
quantities.Add("7");

Both bad lines now fail to compile with error CS1503, and nothing is left to unbox. That trade, a compile error instead of a run-time exception, is why generics exist. C# went further than TypeScript or Java, though: the type argument survives compilation. At run time, .NET knows that a List<int> holds int values, and it uses that knowledge for speed and for features that TypeScript cannot offer.

This article covers what generics fixed, how TypeScript differs, what the runtime does with List<int> and List<string>, what constraints promise, and where generics stop paying. Reflection, the sharpest case, gets a section of its own. The C# samples run on .NET 10, and the TypeScript samples are checked with TypeScript 7.0.

What Generics Fixed

C# 1.0 shipped in 2002, with Visual Studio .NET 2002. Microsoft’s history of the language is plain about what it lacked: “As a matter of fact, it lacked generics altogether.” A general-purpose collection had to hold object, the root of the type system, and that cost developers in three ways.

Errors moved to run time. The compiler could not know what a collection held, so every read needed a cast, and a wrong cast compiled. Andrew Kennedy and Don Syme of Microsoft Research Cambridge described the pattern in 2001, in the paper that laid out generics for the .NET runtime. C# boxes a value automatically when you push it onto an object-based stack and makes you write the cast when you pop it. “Of course,” they wrote, “the latter can fail at run-time.”

Every number paid for a box. Boxing wraps a value type in a new heap object, and Microsoft’s guide names the cost: “When a value type is boxed, a new object must be allocated and constructed.” On .NET 10, creating an ArrayList with room for 1,000,000 integers and filling it allocates 32,000,072 bytes, as measured with GC.GetAllocatedBytesForCurrentThread. In a 64-bit process, that is an 8-byte reference in the list’s array plus a 24-byte box for every value. A List<int> of the same capacity allocates 4,000,056 bytes, 4 bytes per value.

Since .NET 9, the 64-bit just-in-time (JIT) compiler can put a box on the stack when it proves that the box never leaves the method. A box stored in an ArrayList does leave, because the list’s array keeps a reference to it, so these boxes stay on the heap.

Casts everywhere, or a collection per type. The workarounds were a cast at every read or a hand-written collection for each element type. Microsoft’s history page names both when it introduces C# 2.0: “Using generics is better than creating a ListInt type that derives from ArrayList or casting from Object for every operation.”

C# 2.0 shipped in 2005, with Visual Studio 2005, and generics came with it. They reached below the language: the runtime gained new instructions for generic code, such as Unbox_Any and the Constrained prefix. Today, the ArrayList reference page opens its remarks with a warning: “We don’t recommend that you use the ArrayList class for new development.”

TypeScript: Checked, Then Erased

JavaScript has nothing to parameterize, because its types belong to values. “Variables in JavaScript are not directly associated with any particular value type,” as MDN puts it, so an identity(value) function accepts anything and promises nothing. TypeScript adds static types on top of JavaScript, generics included, with the same angle brackets as C#:

function identity<T>(value: T): T {
  return value;
}

const count: number = identity(42);
// TS2322: Type 'number' is not
// assignable to type 'string'.
const label: string = identity(42);

The checker rejects the last line with error TS2322, the same protection C# gives at compile time. The difference appears in the JavaScript that tsc emits for the function:

function identity(value) {
    return value;
}

The type parameter is gone. The TypeScript handbook states the rule: “Type annotations never change the runtime behavior of your program.” Node.js makes the point literally. Since version 23.6, it runs .ts files that use only erasable syntax by default. Its documentation explains how: “Node.js will replace TypeScript syntax with whitespace, and no type checking is performed.”

So the checker rejects anything that needs T at run time. Both new T() and value instanceof T draw error TS2693, “'T' only refers to a type, but is being used as a value here.” The TypeScript way is to pass the run-time part in as a value. The handbook’s chapter on generics says so for factories: “When creating factories in TypeScript using generics, it is necessary to refer to class types by their constructor functions.” This sample runs as is under Node.js 24:

function make<T>(ctor: new () => T): T {
  return new ctor();
}

class Order {
  id = "o-1";
}

const order = make(Order);
// Prints: o-1 true
console.log(order.id, order instanceof Order);

const fake = JSON.parse('{"id": 42}') as Order;
// Prints: number false
console.log(typeof fake.id, fake instanceof Order);

The second half shows the other consequence. The type assertion as Order is a promise to the checker, and nothing checks it at run time. Here fake.id holds a number, and fake is not an Order at all. Data from outside the program, such as parsed JSON, needs a run-time check that you write or take from a validation library.

This is also where TypeScript’s constraints differ from C#’s. At compile time they are expressive: the same chapter bounds a type parameter by an object shape and constrains a key with keyof. But a TypeScript constraint only tells the checker what to allow; at run time, the code has nothing but the values it was given. A C# constraint can let generic code create a T or call static members of T, operators included, as later sections show.

TypeScript is not alone. Java also implements generics by erasure. Its compiler replaces each type parameter with its bound or Object. In the words of Oracle’s tutorial, “The produced bytecode, therefore, contains only ordinary classes, interfaces, and methods.” Java also forbids primitive type arguments, so a list of int values is a List<Integer> of boxed objects. JEP 218, an OpenJDK proposal to lift that restriction, names the cost: primitives work as type arguments only by boxing, “undermining performance.”

C#: The Type Argument Survives

Kennedy and Syme had studied the ways to add generics to Java without changing its virtual machine. Their verdict: “The lesson in each case appears to be that if the virtual machine does not support polymorphism, the end result will suffer.” So they extended the .NET runtime itself, and there the type argument reaches run time. Microsoft’s introduction to C# generics compares them with generics in Java and templates in C++ and adds: “but with full runtime type information and no type erasure.” Everything TypeScript rejected compiles and runs in C#:

// Prints: Int32
Console.WriteLine(NameOf<int>());
// Prints: True False
Console.WriteLine(
    $"{IsA<string>("x")} {IsA<int>("x")}");
// Prints: 0
Console.WriteLine(Make<int>());

Counter<int>.Hits++;
Counter<int>.Hits++;
Counter<string>.Hits++;
// Prints: 2 1
Console.WriteLine(
    $"{Counter<int>.Hits} {Counter<string>.Hits}");

static string NameOf<T>() => typeof(T).Name;
static bool IsA<T>(object value) => value is T;
static T Make<T>() where T : new() => new T();

static class Counter<T>
{
    public static int Hits;
}

typeof(T) names the actual type argument, value is T tests against it, and new T() creates one. The last lines show something TypeScript cannot express. Each constructed type is a type of its own, with its own static fields, so Counter<int> and Counter<string> count separately. In TypeScript there is one class at run time, and a static member that mentions the type parameter is error TS2302, “Static members cannot reference class type parameters.”

Keeping the type argument does not mean compiling everything once per type. Microsoft’s page on generics in the runtime describes two rules. For value types, the runtime builds a specialized version per type argument: “Specialized generic types are created one time for each unique value type that is used as a parameter.” For reference types, it builds one version and reuses it for all of them. “This is possible because all references are the same size.”

You can watch the JIT compiler apply both rules. Set the environment variable DOTNET_JitDisasmSummary to 1, a switch that works in the shipped runtime, and DOTNET_JitStdOutFile to a file name. Then run this program with dotnet run -c Release:

var home = new Uri("https://example.com/");
Console.WriteLine(new Cell<int>(1).Get());
Console.WriteLine(new Cell<long>(2).Get());
Console.WriteLine(new Cell<string>("three").Get());
Console.WriteLine(new Cell<Uri>(home).Get());

public sealed class Cell<T>(T value)
{
    public T Get() => value;
}

The file lists every method the JIT compiled, mostly for the SDK itself. Among them are three versions of Get:

Cell`1[int]:Get()
Cell`1[long]:Get()
Cell`1[System.__Canon]:Get()

int and long each got a copy of their own. string and Uri share one, compiled for System.__Canon, the runtime’s stand-in for any reference type. Shared code is not a shared type, though: a List<string> is still not a List<object>. Among generic types, only variant interfaces and delegates, such as IEnumerable<out T>, allow that kind of conversion.

The shared copy cannot hard-code a type, so when it needs one, for a cast, a typeof(T), or a new T(), it looks the type up while running. The Book of the Runtime, the design notes of the .NET runtime, describes the lookup. The shared code finds the exact type “either through a call to a runtime helper API, or by loading it up from the generic dictionary of the instantiation” that is running.

Specialization costs code size, and Native AOT makes the cost visible. An app compiled ahead of time has no JIT to make copies on demand, so the compiler makes them all at build time. Microsoft’s Native AOT guide warns: “In Native AOT, all instantiations are pre-generated. This can have significant impact to the disk size of the application.”

The payoff is specialized code that needs no boxing to store or pass a T. That was the aim from the start: Kennedy and Syme’s early measurements suggested “that programmers will not need to pay an overhead for using generics, achieving performance almost matching hand-specialized code.”

What a Constraint Promises

Inside a generic method, an unconstrained T could be anything. Microsoft’s guide to constraints spells out the consequence: “The compiler can only assume the members of System.Object, which is the ultimate base class for any .NET type.” A constraint is a promise that callers must keep, and each promise lets the method do more with T. This repository needs two: every entity has an ID, and the repository can create one.

public interface IEntity
{
    Guid Id { get; set; }
}

public sealed class Repository<T> where T : IEntity, new()
{
    private readonly Dictionary<Guid, T> _items = [];

    public T CreateNew()
    {
        var entity = new T { Id = Guid.NewGuid() };
        _items.Add(entity.Id, entity);
        return entity;
    }
}

Without new(), the new T line fails with error CS0304, “Cannot create an instance of the variable type 'T' because it does not have the new() constraint.” With it, the compiler accepts the line and turns it into a method call. The Activator documentation explains: “The CreateInstance<T>() generic method is used by compilers to implement the instantiation of types specified by type parameters.” The constraint covers only a public parameterless constructor. When creation needs arguments, accept a factory delegate from the caller instead.

Here is the full menu in C# 14, from the same guide:

Order matters. At most one of struct, class, class?, notnull, and unmanaged may appear, and it must come first. new() must follow every other restriction, and only allows ref struct may come after it. So where T : class, IDisposable, new() compiles. Move class after the interface and the build stops with error CS0449; move new() forward and it stops with CS0401.

A constraint can also promise less than it seems to. Under where T : class, the compiler knows only that T is a reference type, so == compares references, even for a type that overloads the operator:

using System.Text;

string a = "target";
string b = new StringBuilder("target")
    .ToString();

// Prints: True False True
Console.WriteLine($"{a == b} {Same(a, b)} {Equal(a, b)}");

static bool Same<T>(T x, T y) where T : class => x == y;

static bool Equal<T>(T x, T y) =>
    EqualityComparer<T>.Default
        .Equals(x, y);

The constraints guide warns about exactly this: the operators “test for reference identity only, not for value equality.” EqualityComparer<T>.Default does what the code meant. It uses the type’s IEquatable<T> implementation when there is one, and Object.Equals otherwise.

Since C# 11 and .NET 7, a constraint can also promise static members, operators included, through static abstract interface members. Generic math is built on them. In Microsoft’s words, “Now you can write a single, generic method, where the type parameter is constrained to be a number-like type.”

using System.Numerics;

// Prints: 6
Console.WriteLine(Sum<int>([1, 2, 3]));
// Prints: 0.75
Console.WriteLine(
    Sum<double>([0.5, 0.25]));
// Prints: 25.00
Console.WriteLine(
    Sum<decimal>([19.99m, 5.01m]));

static T Sum<T>(ReadOnlySpan<T> values) where T : INumber<T>
{
    T total = T.Zero;
    foreach (T value in values)
        total += value;
    return total;
}

T.Zero and += compile because INumber<T> inherits a static Zero property and the + operator from the interfaces it extends. Each of these three value types gets a specialized copy of Sum, so the additions run on the actual numeric type with no boxing. The same feature lets a type argument choose an algorithm at compile time, which the article on the Strategy pattern in C# shows.

Where Generics Pay Off

Generics pay off when the code is the same for every type argument and callers want their own type back:

Where Generics Stop Paying

Generics are a scalpel, not a sledgehammer. Here are four signs of the sledgehammer.

A ladder of typeof(T) checks. A method that branches on typeof(T) == typeof(int), then long, then string, is several methods sharing a name. Overloads, or an interface the types implement, say the same thing more honestly.

One exception shows how much the runtime knows. In code compiled for a value type, the JIT compiler can treat typeof(T) as a constant and drop the branches that cannot run. Take a method that returns 4 when T is int and 8 when it is long. In optimized .NET 10 code, its int version is two instructions: mov eax, 4 and ret.

.NET’s own number formatting relies on this. Its methods are generic over the character type, char for UTF-16 or byte for UTF-8, and where the encodings differ, its source branches on typeof(TChar) == typeof(char). That is a technique for measured hot paths over a closed set of value types, not a default design.

A type you have to read three times. Dictionary<string, List<Dictionary<int, string>>> is legal, and it says nothing about what it holds. Give the inner concept a type of its own, such as a record for the lines of one order, and the outer type becomes readable. A using alias is the cheaper fix when only a name is missing.

A type parameter with one type argument. A repository like the one above, used with only one entity type, is a guess about future types. The entry on speculative generality in my Software Development Principles series lists this among its indicators of violation: generic type parameters instantiated with only one type in the whole codebase. Write the concrete version, and generalize when the second type arrives.

A type parameter the caller never gets back. If a method takes a T only to call an interface member on it and never returns a T, a parameter of the interface type is simpler. The generic version earns its keep when the caller gets its own type back or when T is a struct. An interface parameter would box a struct, and it cannot take a ref struct at all.

The Reflection Trap

Reflection can undo much of this. Here is a helper that totals any int property by name, so that one method serves every type:

static int TotalByName<T>(T[] items, string property)
{
    PropertyInfo prop = typeof(T).GetProperty(property)!;
    int total = 0;
    foreach (T item in items)
        total += (int)prop.GetValue(item)!;
    return total;
}

It compiles and returns the right totals. It also brings back the problems that generics solved:

The struct case deserves a closer look, because nothing fails:

using System.Reflection;

var item = new StockItem { Sku = "a1", Stock = 1 };
PropertyInfo stock = typeof(StockItem)
    .GetProperty("Stock")!;
stock.SetValue(item, 5);
// Prints: 1
Console.WriteLine(item.Stock);

The fix is to put the knowledge back into the type system. When the types share the concept, say so with an interface, and constrain T to it:

static int Total<T>(T[] items) where T : IStocked
{
    int total = 0;
    foreach (T item in items)
        total += item.Stock;
    return total;
}

public interface IStocked
{
    int Stock { get; }
}

public struct StockItem : IStocked
{
    public string Sku { get; set; }
    public int Stock { get; set; }
}

This version allocated nothing, a misspelled member does not compile, and the trimmer can see every member it uses. When the types share no interface, let the caller pass the property as a delegate: a Func<T, int> parameter, called as TotalOf(items, static i => i.Stock). In the same benchmark, that took about 1.5 times as long as the constrained version, and it allocated nothing per item.

Two heavier tools remain for code that needs metadata. Expression trees can build a typed getter once per type and cache it in a static generic class. Where the runtime can generate code, Compile() turns the tree into real code, and in a .NET 10 test, later calls allocated nothing. Native AOT allows no run-time code generation, so there .NET’s expression compiler falls back to an interpreter; with dynamic code switched off, the same test allocated on every call. Building the tree is still reflection, and the trimmer still warns about it.

Source generators move the work to compile time. In Microsoft’s words, they “aim to enable compile time metaprogramming.” The .NET libraries took that road themselves. JsonSerializer.Deserialize<T> looks as generic as anything above. By default, though, it collects metadata about T through reflection the first time it meets the type. For that reason, Microsoft warns, “calling a basic serialization method can break Native AOT apps.” The System.Text.Json source generator produces that metadata at build time instead, and you pass the generated context to the serializer.

So before you reflect over a type parameter, ask the question that generics already answered: does the code know what T is? If it does, tell the compiler. If it does not, generics may not be the right tool, and an API that takes an object and a Type says so more honestly.

Takeaways

Further Reading