Generics

This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Generics let a type or method be written once and used with many types, with full compile-time type safety and no boxing. In C# they are reified — the runtime knows the type arguments — which is the deepest difference between C# generics and Java’s.

Generic Types and Methods

// A generic class.
public class Box<T>
{
    private T? _value;

    public void Put(T value) => _value = value;
    public T? Take() => _value;
    public bool HasValue => _value is not null;
}

// A generic struct.
public readonly struct Pair<TFirst, TSecond>(TFirst first, TSecond second)
{
    public TFirst First { get; } = first;
    public TSecond Second { get; } = second;
    public override string ToString() => $"({First}, {Second})";
}

// A generic interface.
public interface IRepository2<TEntity, TKey>
{
    TEntity? Find(TKey key);
    void Save(TKey key, TEntity entity);
}

// A generic delegate.
public delegate TResult Transform<in TInput, out TResult>(TInput input);

// A generic method on a non-generic type.
public static class Utility
{
    public static void Swap<T>(ref T left, ref T right) => (left, right) = (right, left);

    public static T[] Repeat<T>(T value, int count)
    {
        var result = new T[count];
        Array.Fill(result, value);
        return result;
    }
}

Type parameters are named T when there is one, and TKey/TValue/TResult/TEntity — descriptive, T prefixed — when there are several.

Type Inference

The compiler infers a method’s type arguments from its arguments. It never infers a type’s:

public static class InferenceDemo
{
    public static void Run()
    {
        int a = 1, b = 2;
        Utility.Swap(ref a, ref b);              // T inferred as int
        Console.WriteLine($"{a} {b}");

        string[] words = Utility.Repeat("x", 3); // T inferred from the first argument
        Console.WriteLine(words.Length);

        // Explicit when inference cannot help (the return type is never used to infer).
        var empty = Utility.Repeat<double>(0, 0);
        Console.WriteLine(empty.Length);

        // A generic *type* always needs its arguments -- but `new()` is target-typed.
        Box<string> box = new();
        var pair = new Pair<int, string>(1, "one");
        Console.WriteLine($"{box.HasValue} {pair}");
    }
}

A common workaround for the missing type inference on constructors is a static factory method:

public static class Pair
{
    public static Pair<TFirst, TSecond> Of<TFirst, TSecond>(TFirst first, TSecond second)
        => new(first, second);
}
// var p = Pair.Of(1, "one");   // Pair<int, string>, inferred

Constraints

Without a constraint, a type parameter offers only what object offers. Constraints widen that.

Constraint Means

where T : struct

A non-nullable value type.

where T : class

A reference type. With nullable enabled, class? allows a nullable one.

where T : notnull

A non-nullable type, value or reference.

where T : unmanaged

A value type containing no references — blittable, usable with pointers.

where T : new()

Has a public parameterless constructor. Must be the last constraint listed.

where T : BaseClass

Derives from (or is) BaseClass.

where T : IInterface

Implements IInterface.

where T : U

Derives from (or is) another type parameter.

where T : default

Disambiguates an override when neither struct nor class applies.

where T : allows ref struct

(C# 13) Permits a ref struct argument such as Span<int>.

public static class Constrained
{
    // struct: no null, and `T?` means Nullable<T>.
    public static T? FirstOrNull<T>(IEnumerable<T> source) where T : struct
    {
        foreach (T item in source)
        {
            return item;
        }
        return null;
    }

    // new(): the method can construct one.
    public static T CreateAndConfigure<T>(Action<T> configure) where T : new()
    {
        var instance = new T();
        configure(instance);
        return instance;
    }

    // Interface constraints compose, and give access to the members.
    public static T Largest<T>(IEnumerable<T> source) where T : IComparable<T>
    {
        using IEnumerator<T> enumerator = source.GetEnumerator();
        if (!enumerator.MoveNext())
        {
            throw new ArgumentException("Empty sequence.", nameof(source));
        }

        T best = enumerator.Current;
        while (enumerator.MoveNext())
        {
            if (enumerator.Current.CompareTo(best) > 0)
            {
                best = enumerator.Current;
            }
        }
        return best;
    }

    // unmanaged: safe to take the size of and copy as raw bytes.
    public static int SizeOf<T>() where T : unmanaged => Unsafe.SizeOf<T>();

    // Several constraints on several parameters.
    public static TResult Map<TSource, TResult>(TSource source, Func<TSource, TResult> selector)
        where TSource : notnull
        where TResult : class, new()
        => selector(source);
}

Generics at Run Time: Reification

In Java, List<String> and List<Integer> are the same class at run time — the type argument is erased. In C# they are genuinely different types:

public static class Reification
{
    public static void Run()
    {
        var ints = new List<int>();
        var strings = new List<string>();

        Console.WriteLine(ints.GetType());               // System.Collections.Generic.List`1[System.Int32]
        Console.WriteLine(ints.GetType() == strings.GetType());   // False -- distinct types

        // Type arguments are recoverable at run time.
        Type[] arguments = ints.GetType().GetGenericArguments();
        Console.WriteLine(arguments[0]);                 // System.Int32

        // `typeof(T)` works inside a generic method -- impossible with erasure.
        Console.WriteLine(NameOf<DateTime>());           // DateTime
    }

    private static string NameOf<T>() => typeof(T).Name;
}

What this buys you:

  • No boxing. List<int> stores int`s inline. In Java it is `List<Integer> — a heap object per element.

  • typeof(T), default(T) and new T() all work inside generic code.

  • is/as and overload resolution see the real type.

  • Runtime specialisation: the JIT compiles a separate native body for each value-type argument (optimal code, some code-size cost) and shares one body across all reference-type arguments (they are all pointer-sized).

The cost is that a generic type over many value types produces more native code, and that generic instantiations over value types cannot be created reflectively under Native AOT unless the compiler could see them statically.

Covariance and Contravariance

Variance answers: if Derived converts to Base, does IFoo<Derived> convert to IFoo<Base>? By default, no — generics are invariant. out and in opt in.

public class Animal2 { public string Name { get; init; } = ""; }
public class Dog2 : Animal2 { }

// `out T`: covariant. T appears only in OUTPUT positions (return types).
public interface IProducer<out T>
{
    T Produce();
}

// `in T`: contravariant. T appears only in INPUT positions (parameters).
public interface IConsumer<in T>
{
    void Consume(T item);
}

public sealed class DogProducer : IProducer<Dog2>
{
    public Dog2 Produce() => new() { Name = "Rex" };
}

public sealed class AnimalConsumer : IConsumer<Animal2>
{
    public void Consume(Animal2 item) => Console.WriteLine($"consumed {item.Name}");
}

public static class VarianceDemo
{
    public static void Run()
    {
        // Covariance: a producer of Dogs IS a producer of Animals.
        IProducer<Animal2> producer = new DogProducer();
        Console.WriteLine(producer.Produce().Name);

        // Contravariance: a consumer of Animals IS a consumer of Dogs.
        IConsumer<Dog2> consumer = new AnimalConsumer();
        consumer.Consume(new Dog2 { Name = "Fido" });

        // The BCL uses both heavily.
        IEnumerable<Dog2> dogs = [new Dog2 { Name = "A" }];
        IEnumerable<Animal2> animals = dogs;          // IEnumerable<out T>
        Console.WriteLine(animals.Count());

        Comparison<Animal2> byName = (x, y) => string.CompareOrdinal(x.Name, y.Name);
        Comparison<Dog2> dogComparison = byName;      // Comparison<in T>
        Console.WriteLine(dogComparison(new Dog2(), new Dog2()));
    }
}
flowchart TB subgraph COV["out T — covariance (producer)"] direction TB C1["IEnumerable<Dog>"] -->|"converts to"| C2["IEnumerable<Animal>"] C3["T appears only in return positions.
Every Dog you take out is an Animal, so this is safe."] end subgraph CONTRA["in T — contravariance (consumer)"] direction TB D1["IComparer<Animal>"] -->|"converts to"| D2["IComparer<Dog>"] D3["T appears only in parameter positions.
Anything that compares Animals can compare Dogs."] end subgraph INV["no modifier — invariance"] direction TB E1["List<Dog>"] -.->|"does NOT convert"| E2["List<Animal>"] E3["T is both read and written.
Allowing it would let you Add a Cat to a List<Dog>."] end style COV fill:#eefaf2,stroke:#2f7d51 style CONTRA fill:#eef4fb,stroke:#3d6fa5 style INV fill:#fdf3f3,stroke:#b5523d

Variance applies only to interfaces and delegates, only to reference type arguments, and only where the parameter’s position is safe. IList<T> is invariant because T is both read and written.

Array Covariance

Arrays are covariant, and it is unsound — a C# 1.0 decision that predates generics:

public static class ArrayCovariance
{
    public static void Run()
    {
        Dog2[] dogs = [new Dog2()];
        Animal2[] animals = dogs;        // legal, and a trap

        try
        {
            animals[0] = new Animal2();  // compiles; throws at run time
        }
        catch (ArrayTypeMismatchException)
        {
            Console.WriteLine("ArrayTypeMismatchException -- the array is really Dog2[]");
        }
    }
}

Every array store carries a run-time type check because of this. Prefer IReadOnlyList<T> (covariant and safe) when you want to pass a read-only sequence.

Static Members in Generic Types

Each closed type gets its own statics:

public class Counter<T>
{
    public static int Count;

    static Counter() => Console.WriteLine($"static ctor for Counter<{typeof(T).Name}>");

    public Counter() => Count++;
}

public static class StaticsDemo
{
    public static void Run()
    {
        _ = new Counter<int>();
        _ = new Counter<int>();
        _ = new Counter<string>();

        Console.WriteLine(Counter<int>.Count);      // 2
        Console.WriteLine(Counter<string>.Count);   // 1 -- a separate field
    }
}

This is a feature — it is how per-type caches such as EqualityComparer<T>.Default are built — but it surprises people who expect one shared static.

Generic Math

Static abstract interface members (C# 11) let generic code use operators:

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

    public static T Mean<T>(ReadOnlySpan<T> values) where T : INumber<T>
        => values.IsEmpty ? T.Zero : Sum(values) / T.CreateChecked(values.Length);

    public static void Demo()
    {
        Console.WriteLine(Mean<double>([1, 2, 3, 4]));      // 2.5
        Console.WriteLine(Mean<decimal>([1m, 2m]));         // 1.5
        Console.WriteLine(Sum<int>([1, 2, 3]));             // 6
    }
}

See Interfaces for the numeric interface hierarchy.

Open and Closed Types

An open generic type has unbound parameters; a closed one has them all supplied:

public static class OpenTypes
{
    public static void Run()
    {
        Type open = typeof(List<>);                 // note the empty <>
        Type closed = typeof(List<int>);

        Console.WriteLine(open.IsGenericTypeDefinition);   // True
        Console.WriteLine(closed.IsGenericTypeDefinition); // False
        Console.WriteLine(open.GetGenericArguments().Length);   // 1

        // Close an open type reflectively -- the basis of most DI containers.
        Type constructed = open.MakeGenericType(typeof(string));
        object? instance = Activator.CreateInstance(constructed);
        Console.WriteLine(instance?.GetType());     // List`1[System.String]

        Console.WriteLine(typeof(Dictionary<,>).GetGenericArguments().Length);   // 2

        // C# 14: nameof accepts an unbound generic.
        Console.WriteLine(nameof(List<>));          // List
    }
}

MakeGenericType over a value type argument needs the JIT, so it does not work under Native AOT unless the instantiation was statically visible. Source generators are the AOT-friendly alternative.

See Also