Generics

Use type parameters to design reusable, type-safe C# APIs, with precise constraints, inference, variance, and runtime generic behavior.

level intermediate time 12 min at Standard depth
version C# 14 / .NET 10
what

Generics use type parameters to preserve type relationships across inputs, outputs, and storage, while one declaration supports many concrete types.

trap

Constraints expose compile-time capabilities but don’t validate runtime business data. Generic classes are invariant, and out and in apply only to eligible interfaces and delegates.

fix

Express only the operations an algorithm needs as constraints, handle values through generic protocols such as EqualityComparer<T>.Default, and validate null, empty collections, and business invariants at API boundaries.

What it is and why it exists

A generic declaration leaves one or more types as parameters. Classes, structs, interfaces, delegates, and methods can declare a type parameter , conventionally named T, TKey, or TResult. Once a caller supplies type arguments, a constructed type such as List<string> or Dictionary<string, int> is fully specified.

Generics reuse type relationships, not merely lines of code. T Find<T>(IEnumerable<T> source) says that the result and the sequence element have the same type. Changing it to object Find(IEnumerable source) loses that relationship, forcing callers to cast and accept runtime failure.

Passing a value type through object usually causes boxing , followed by unboxing when the value comes back. List<int> stores and retrieves int elements directly, preserving compile-time checking without first converting every element to object. This doesn’t mean all generic code allocates nothing; it avoids this specific conversion path.

Generics appear throughout collections, LINQ, delegates, asynchronous APIs, dependency injection, and serialization libraries. In IEnumerable<Order>, the type between angle brackets isn’t documentation. It is part of the contract used by both the compiler and runtime.

Generics fit algorithms that apply to several types and depend on a small, explicit set of capabilities. If each type needs a different business flow, a stack of type tests and typeof(T) branches usually hides several implementations. Interfaces, polymorphism, or separate services state that design more clearly.

How it works

Declaration and construction are separate stages

Cache<TKey, TValue> declares two type parameters. Cache<string, Product> supplies two type arguments and forms a closed constructed type; arity, order, and constraints must all match. Cache<,> is an unbound generic type used only for metadata operations such as typeof and reflection. You can’t instantiate it directly.

When one type parameter occurs in several positions, the compiler keeps those positions equal. The following signature requires item, fallback, and the result to use one T. A caller can’t mix unrelated types in one call.

csharp
static T Choose<T>(bool useItem, T item, T fallback) =>
    useItem ? item : fallback;

A generic method owns its type parameters even when its containing type isn’t generic. For Choose(true, 10, 20), the compiler infers T as int from the arguments. Method type inference doesn’t infer T from the return value. If inference fails or its result violates a constraint, the caller must change the arguments or write the type argument explicitly.

Constraints expose capabilities

An unconstrained T supports only operations available to every type, such as assignment, object members, and EqualityComparer<T>.Default. A generic constraint in a where clause narrows the accepted types and lets the implementation use members guaranteed by that constraint.

ConstraintTypes a caller can supplyCapability available to the implementation
where T : classNon-nullable reference typesAnalyze T as a non-nullable reference type
where T : class?Nullable or non-nullable reference typesAnalyze T as a nullable reference type
where T : structNon-nullable value typesApply value-type rules; a parameterless constructor is implicit
where T : unmanagedNon-nullable value types without managed referencesObtain size or pointers when unsafe code is enabled
where T : notnullNon-nullable value or reference typesDiagnose nullable type arguments
where T : BaseTypeTypes derived from the named base classUse public base-class members
where T : IContractTypes implementing the named interfaceUse interface members, including static abstract members
where T : new()Non-abstract types with a public parameterless constructorExecute new T()

Constraints are compile-time rules, not business validators. where T : class doesn’t stop older code, reflection, or callers with nullable analysis disabled from passing null. where T : new() doesn’t prove that the new object satisfies domain invariants. Public boundaries must still check actual values.

Multiple constraints follow a prescribed order: a primary constraint such as a reference-type, value-type, or unmanaged constraint comes first, followed by base-class or interface constraints, with new() near the end. The allows ref struct anti-constraint states that the implementation obeys ref struct safety rules. It comes after other constraints and prevents the implementation from assuming that T can be boxed or stored across await.

Equality, comparison, and operators need protocols

Using == with an unknown T doesn’t always compile and may not express the domain’s equality rule. Ordinary equality searches should use EqualityComparer<T>.Default; sorting can accept an IComparer<T>; add IComparable<T> only when the implementation calls CompareTo. Value types, reference types, and caller-supplied comparison policies then follow one explicit path.

C# interfaces can declare static abstract members. Generic math interfaces such as INumber<TSelf> therefore let constrained code use T.Zero and + without falling back to dynamic. This is a real capability constraint: callers can supply only types implementing the required static protocol.

Don’t add new(), class, or a broad interface just for convenience. Constraints become compatibility boundaries in a public API. A constraint the algorithm doesn’t use excludes otherwise valid types and may need to be removed later as a design correction.

Variance controls reference-conversion direction

Variance describes how existing reference conversions propagate through generic interfaces or delegates. IEnumerable<out T> is covariant, so IEnumerable<string> can be assigned to IEnumerable<object>. It only produces T through that interface, preventing the consumer from inserting an arbitrary object.

IComparer<in T> is contravariant. A comparer that handles any Animal can also compare two Dog values, so IComparer<Animal> can be used where IComparer<Dog> is required. The direction looks reversed because T flows only into the interface.

Only interface and delegate type parameters can declare out or in; generic classes remain invariant. Even when Dog derives from Animal, List<Dog> isn’t assignable to List<Animal>, because the receiver could then insert an Animal that isn’t a Dog. Variance conversions also apply only to reference-type arguments. They don’t convert IEnumerable<int> to IEnumerable<object>.

Examples

These four examples cover type relationships, static capabilities supplied by constraints, variance conversions, and runtime type identity. This environment has no .NET SDK or C# compiler, so each block carries the repository’s non-execution marker and no output is fabricated.

This search method binds the element, target, and comparer to the same T. The caller gets string? inference from the string?[] argument and never converts elements to object.

GenericFind.cs
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
using System.Collections.Generic;

string?[] states = ["queued", null, "sent", "sent"];

Console.WriteLine(FindIndex(states, null));
Console.WriteLine(FindIndex(states, "sent"));
Console.WriteLine(FindIndex(states, "missing"));

static int FindIndex<T>(IReadOnlyList<T> items, T target)
{
    EqualityComparer<T> comparer = EqualityComparer<T>.Default;

    for (int index = 0; index < items.Count; index++)
    {
        if (comparer.Equals(items[index], target))
            return index;
    }

    return -1;
}
# not executed here: the .NET SDK and C# compilers are unavailable

EqualityComparer<T>.Default handles the two null operands in the example and follows the concrete type’s default equality rules. The method returns an index instead of default(T), so “not found” can’t be confused with a valid default value of the element type.

A real API must also decide whether items itself can be null. Nullable annotations express intent and produce warnings. Across reflection, deserialization, or code compiled without nullable analysis, a contract that rejects null still needs a runtime check.

Sum through static abstract members

INumber<T> guarantees that T.Zero and addition are available. The implementation neither branches on concrete numeric types nor defers operator binding to dynamic.

GenericSum.cs
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
using System.Collections.Generic;
using System.Numerics;

int[] units = [2, 3, 5];
decimal[] prices = [1.25m, 2.50m, 4.00m];

Console.WriteLine(Sum(units));
Console.WriteLine(Sum(prices));

static T Sum<T>(IEnumerable<T> values) where T : INumber<T>
{
    T total = T.Zero;

    foreach (T value in values)
        total += value;

    return total;
}
# not executed here: the .NET SDK and C# compilers are unavailable

An empty sequence returns T.Zero, the additive identity deliberately chosen by this method. That result doesn’t automatically fit averages, maxima, or business validation for money. Those operations need their own empty-input contracts rather than copying sum’s behavior.

If the code only needs addition and zero, it can use narrower operator interfaces instead of all of INumber<T>. Constraints should follow the members the implementation actually calls, which makes the API available to more custom numeric types.

Separate producers from consumers

The producer only returns T, so it declares out T; the consumer only accepts T, so it declares in T. Assignment direction follows these interface positions without changing any concrete object’s runtime type.

Variance.cs
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;

IProducer<Dog> dogSource = new DogSource();
IProducer<Animal> animalSource = dogSource;

IConsumer<Animal> animalSink = new AnimalSink();
IConsumer<Dog> dogSink = animalSink;

Animal animal = animalSource.Create();
dogSink.Save(new Dog("Rex"));
Console.WriteLine(animal.Name);

public interface IProducer<out T>
{
    T Create();
}

public interface IConsumer<in T>
{
    void Save(T value);
}

public sealed class DogSource : IProducer<Dog>
{
    public Dog Create() => new("Milo");
}

public sealed class AnimalSink : IConsumer<Animal>
{
    public void Save(Animal value) =>
        Console.WriteLine($"saved: {value.Name}");
}

public record Animal(string Name);
public sealed record Dog(string Name) : Animal(Name);
# not executed here: the .NET SDK and C# compilers are unavailable

The static type of animalSource promises only an Animal, but the object remains a DogSource and the produced instance remains a Dog. dogSink can only be called with a Dog, while the underlying AnimalSink already knows how to handle that input.

An interface that both accepts and returns the same T usually can’t mark that parameter covariant or contravariant. Splitting reads and writes into narrow interfaces can enable safe variance conversions, but only when that split matches the domain contract.

Observe constructed types at runtime

The runtime retains generic type arguments. This example also shows that a generic static class has separate fields for each closed constructed type.

GenericRuntime.cs
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
using System.Collections.Generic;

Type integers = typeof(List<int>);
Type strings = typeof(List<string>);

Console.WriteLine(integers == strings);
Console.WriteLine(integers.GetGenericTypeDefinition() == typeof(List<>));
Console.WriteLine(string.Join(", ", integers.GetGenericArguments()));

TypeSlot<int>.Label = "quantities";
TypeSlot<string>.Label = "names";

Console.WriteLine(TypeSlot<int>.Label);
Console.WriteLine(TypeSlot<string>.Label);

public static class TypeSlot<T>
{
    public static string Label { get; set; } = typeof(T).Name;
}
# not executed here: the .NET SDK and C# compilers are unavailable

List<int> and List<string> are distinct constructed types, but both have List<> as their generic type definition. Reflection code must distinguish a definition from a closed type before it chooses GetGenericArguments(), MakeGenericType(), or instance creation.

TypeSlot<int>.Label and TypeSlot<string>.Label don’t share storage. This behavior can cache metadata per type, but it still needs capacity and lifetime design. When untrusted dynamic input determines how many types appear, generic static fields aren’t an automatically bounded cache.

Pitfalls

Replacing a design problem with object or dynamic

Fix: write down the smallest protocol the algorithm needs. Use IEqualityComparer<T> for equality, IComparer<T> for ordering, and an interface with static abstract members for static operations. Use polymorphism or separate implementations for genuinely different business behavior instead of hiding it behind dynamic.

Treating default(T) as not found

Fix: return an index, a Boolean plus out T, a discriminated result type, or a nullable value when that fits the contract. The choice must account for value types, reference types, and the actual domain meaning.

Using new() as an object-creation contract

Fix: accept a Func<T> or domain factory, or constrain the parameter to an interface that represents the real creation protocol. Use new() only when an empty object is valid and the generic implementation truly owns creation.

Assuming every generic collection is covariant

Fix: inspect the actual interface declaration for out or in, and check that the type arguments are reference types. Read-only consumers can accept a covariant interface. Code that mutates elements must preserve the exact element type or explicitly copy into a new target collection.

Assuming nullable constraints checked the value

Fix: express type-level intent with constraints and protect real boundaries with runtime checks such as ArgumentNullException.ThrowIfNull. Test inputs arriving through reflection, deserialization, older assemblies, and nullable-disabled contexts.

Adding constraints the algorithm doesn’t use

Fix: map each constrained capability to an actual operation in the implementation. Remove constraints with no such operation. Put business data rules in parameter validation or domain types instead of pretending they are type constraints.

Deep Constraint boundaries and nullability

Constraint boundaries and nullability

class, class?, notnull, and struct describe different sets. With nullable analysis enabled, class requires a non-nullable reference type and class? accepts nullable reference types. notnull accepts both non-nullable reference types and non-nullable value types. struct accepts non-nullable value types but not Nullable<T>. Calling all of them non-null constraints hides these differences.

Nullable diagnostics also depend on the caller’s nullable context. Violating notnull generally produces a compiler warning rather than a runtime exception, so a library can’t treat it as a trust boundary. The meaning of T? also depends on the constraints. Public APIs may need annotation attributes and runtime validation to state the full contract.

The default constraint is limited to overrides and explicit interface implementations, where it states that a type parameter has neither the class nor struct primary constraint. It isn’t a zero-value constraint that can be added anywhere. allows ref struct widens the accepted set while requiring the generic implementation to obey stack-safety rules. Because it works opposite to an ordinary narrowing constraint, the documentation calls it an anti-constraint.

Some constraints are mutually exclusive. struct already guarantees a parameterless constructor and can’t be combined with new(); a base-class constraint also can’t be combined arbitrarily with every primary constraint. Don’t arrange a complex clause from memory. Compile the smallest declaration and retain only the parts the public signature uses.

Constructed generics at runtime

The .NET runtime retains type arguments for closed generic types. typeof(List<int>) can report int, and reflection distinguishes List<int> from List<string>. This differs from a model that erases every type argument into one runtime type. Serializers, dependency injection containers, and reflection factories rely on this metadata.

Retaining type identity doesn’t mean every construction duplicates all machine code. The runtime can usually share generic implementation code across several reference-type arguments, while value-type constructions generally need code suited to their layout. This is a runtime strategy, not evidence for an unmeasured performance claim.

Static fields on a generic type belong to each closed constructed type. Registry<Order> and Registry<Customer> have separate static state. That enables per-type storage but can quietly create several long-lived caches. Review the actual set of constructed types and define cleanup, capacity, and concurrency rules.

In reflection, typeof(Dictionary<,>) is a generic type definition with unbound parameters, while typeof(Dictionary<string, int>) is a closed constructed type. MakeGenericType() validates arity and constraints at runtime and throws for invalid arguments. External input should map to an allowlist instead of choosing type definitions or arguments directly.

Exact variance boundaries

A variance annotation appears on the interface or delegate declaration, not at the use site. A covariant parameter occurs mainly in output positions, while a contravariant parameter occurs mainly in input positions. The compiler checks properties, method parameters, return values, and nested delegate positions, rejecting declarations that could permit an unsafe read or write.

Variance propagates only existing implicit reference conversions. string has a reference conversion to object, so IEnumerable<string> converts to IEnumerable<object>. Converting int to object requires boxing and isn’t the same conversion, so the corresponding generic interfaces aren’t covariant. User-defined conversions don’t automatically become generic variance conversions either.

A generic class may implement a variant interface, but the class remains invariant. A List<string> can be used as IEnumerable<object> by first viewing the list as IEnumerable<string> and then applying interface covariance. This doesn’t create a List<object> or add the ability to write object values into the original list.

Array covariance is a separate, older runtime rule. A string[] can be assigned to object[], but storing an incompatible object throws ArrayTypeMismatchException at runtime. Don’t infer generic collection behavior from arrays. Generic variance exists to reject unsafe directions at compile time.

Evolving a generic API

Public type-parameter names appear in documentation and reflection. TKey, TValue, and TSource explain relationships better than undifferentiated T1 and T2. Names don’t change type identity, but they do affect how callers understand constraints, diagnostics, and generated documentation.

Adding a constraint can stop existing callers from compiling because a formerly valid type argument becomes excluded. Removing a constraint widens the caller set but may force the implementation to stop using a member. Changing in, out, or a parameter position also changes assignability. All of these are API design changes.

Returning a broad interface doesn’t automatically improve a design. IEnumerable<T> may defer work, repeat a query, or support only one enumeration. IReadOnlyList<T> promises a count and indexing but still doesn’t guarantee that the backing data stays unchanged. The type parameter describes element type, while the collection interface describes access capability; both contract layers need to be accurate.

Don’t test a generic library with only one reference type. Include a value type to expose boxing and default-value assumptions, a nullable case to validate boundaries, and a type with custom equality or ordering. For variance, write both assignments that should compile and assignments the compiler should reject, and treat the diagnostics as part of the test target.

Open generics in frameworks

Reflection and dependency injection frameworks often accept open generic definitions. A registration can say that every IRepository<T> is implemented by Repository<T>, with the container closing both sides over the same argument when a concrete service is requested. This removes repetitive registrations but doesn’t remove constructor, lifetime, or constraint checks.

The arity and positions of open parameters must be compatible. A two-parameter definition can’t directly implement a one-parameter service, and a constrained implementation can’t close over an argument that violates its constraints. A framework may validate during registration, container construction, or first resolution, so startup tests should deliberately resolve important closed types.

ExpressionType representedDirectly instantiable
typeof(Box<>)A generic definition with one unbound parameterNo
typeof(Box<int>)A closed constructed type with int as its argumentYes
typeof(Box<>).MakeGenericType(typeof(string))A closed constructed type created at runtimeYes

An open registration doesn’t prove that every closed combination makes business sense. Repository<AuditLog> may satisfy language constraints while violating application rules for aggregate roots, transactions, or authorization. Express such rules through an explicit marker, registration allowlist, or domain interface instead of discovering them on the first request.

Don’t pass a client-supplied assembly-qualified name directly into Type.GetType() and MakeGenericType(). Language constraints may limit the eventual construction, but an attacker could still select application types that were never meant to be exposed or trigger many closed combinations. Map external names to a fixed set of services and type arguments first.

Open-generic diagnostics should record both the definition and final closed type. Seeing only an IHandler<> registration doesn’t reveal which message type failed; seeing only IHandler<Invoice> may hide that it came from a global open registration. Include both types and the selected lifetime in diagnostic output.

Further reading

checkpoint

5 questions · 2 predict-the-output · 1 spot-the-bug

Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?