Skip to content
Generics with Constraints and Variance
step 1/7

Reading — step 1 of 7

Learn

~3 min readGenerics, LINQ Internals, Pattern Matching

C# generics are reified — type information is preserved at runtime, unlike Java's erased generics. Combined with constraints and variance, they let you write reusable, type-safe APIs.

Type constraints with where

public static T Max<T>(IEnumerable<T> items) where T : IComparable<T>
{
    T max = items.First();
    foreach (var item in items.Skip(1))
        if (item.CompareTo(max) > 0) max = item;
    return max;
}

where T : IComparable<T> constrains T to types that implement IComparable<T> — gives you .CompareTo() inside the function.

Available constraints:

where T : class             // T is a reference type
where T : struct            // T is a value type (non-nullable)
where T : new()             // T has a parameterless constructor
where T : SomeBaseClass     // T is or derives from SomeBaseClass
where T : ISomeInterface    // T implements ISomeInterface
where T : U                 // T is or derives from another type parameter U
where T : notnull           // T is non-nullable (C# 8+)
where T : unmanaged         // T is a non-pointer, non-reference type
where T : default           // (for distinguishing notnull/class in disambiguation)

Multiple constraints

public class Cache<T> where T : class, ICacheable, new()
{
    public T Create() => new T();
    public bool IsValid(T item) => item.Hash != 0;
}

List them comma-separated. new() must be last. Class/struct must be first.

Variance: out and in

C# generics are invariant by default. You can declare variance on INTERFACES and DELEGATES:

public interface IProducer<out T>
{
    T Produce();              // produces T — covariant
}

public interface IConsumer<in T>
{
    void Consume(T item);     // consumes T — contravariant
}

out T = T appears only in OUTPUT positions (return types). Subtyping flows the same way:

IProducer<string> stringProducer = ...;
IProducer<object> objectProducer = stringProducer;     // OK — covariant

in T = T appears only in INPUT positions (parameters). Subtyping reverses:

IConsumer<object> objectConsumer = ...;
IConsumer<string> stringConsumer = objectConsumer;     // OK — contravariant

IEnumerable<out T> is covariant — that's why you can pass IEnumerable<string> where IEnumerable<object> is expected. Same with Func<in T, out R> (contravariant in T, covariant in R).

Generics on classes vs methods

// Class generic — applies to whole class
public class Stack<T>
{
    private List<T> items = new();
    public void Push(T item) => items.Add(item);
    public T Pop() { ... }
}

// Method generic — applies to one method
public class Helper
{
    public static T First<T>(IEnumerable<T> items) where T : class
        => items.FirstOrDefault();
}

Classes parameterize all members. Method generics are scoped to that method.

Reified generics — runtime type info

Unlike Java, C# preserves T at runtime:

public static string Describe<T>(T value)
{
    return $"{typeof(T).Name}: {value}";
}

Describe(42);          // "Int32: 42"
Describe("hello");     // "String: hello"

typeof(T) works inside generic methods. Each instantiation gets its own type tokens.

Common mistakes

  • Mixing variance with concrete types — variance only on interfaces/delegates. class List<out T> is a compile error.
  • Using out/in where T appears in both — compile error. Variance is structural; T must only appear in the matching position.
  • Forgetting new() constraint — without it, new T() won't compile.
  • Confusing covariance with subclass relationshipsList<Cat> is NOT a List<Animal>. Use IEnumerable (covariant) for that pattern.
  • Excessive constraints — sometimes only one method needs the constraint. Move it to that method.

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…