Generic Constraints — Restricting What T Can Be

Without constraints, a generic type parameter T is treated as object inside the generic body — you can only call methods that exist on every type (ToString(), GetHashCode(), Equals()). Constraints (where T : something) tell the compiler more about what T can be, unlocking the methods and properties of the constraint type inside the generic body. They also protect callers from passing inappropriate types. Constraints are essential for building the generic repository pattern used throughout the Web API part of this series.

Common Constraints

// ── where T : class — T must be a reference type ──────────────────────────
public class Cache<T> where T : class
{
    private T? _value;
    public void Set(T value) => _value = value;
    public T? Get() => _value;   // can return null because T is a reference type
}

// ── where T : struct — T must be a value type ─────────────────────────────
public static T? ParseNullable<T>(string? input, TryParseDelegate<T> tryParse)
    where T : struct
{
    if (input is null) return null;
    return tryParse(input, out T result) ? result : null;
}
delegate bool TryParseDelegate<T>(string s, out T result);

// Usage:
int?    age  = ParseNullable<int>("25", int.TryParse);
double? rate = ParseNullable<double>("3.14", double.TryParse);

// ── where T : new() — T must have a parameterless constructor ─────────────
public T CreateDefault<T>() where T : new() => new T();   // ✓ can call new T()

// ── where T : SomeInterface — T must implement the interface ─────────────
public static void SaveAll<T>(IEnumerable<T> items) where T : IAuditable
{
    foreach (var item in items)
    {
        item.AuditedAt = DateTime.UtcNow;   // ✓ IAuditable.AuditedAt is accessible
        Save(item);
    }
}

// ── where T : BaseEntity — T must inherit from BaseEntity ─────────────────
public class Repository<T> where T : BaseEntity
{
    public T? GetById(int id) { /* ... */ return default; }

    public void SoftDelete(T entity)
    {
        entity.IsDeleted  = true;   // ✓ BaseEntity.IsDeleted is accessible
        entity.DeletedAt  = DateTime.UtcNow;
    }
}

// ── where T : notnull — T cannot be null (value or non-nullable reference) ─
public class NonNullCache<T> where T : notnull
{
    private readonly Dictionary<string, T> _store = new();
    public T Get(string key) => _store[key];   // never returns null
}
Note: Constraints are checked at compile time — if you try to use Cache<int> when Cache<T> requires where T : class, you get a compile error, not a runtime error. The where T : class constraint also changes the nullability of T? inside the body — without it, the compiler does not know whether T is a reference type (where T? means nullable reference) or a value type (where T? means Nullable<T>).
Tip: You can combine multiple constraints on a single type parameter using multiple where clauses: where T : class, IAuditable, new(). This means T must be a reference type, implement IAuditable, and have a parameterless constructor — all three must hold. When building generic repository base classes, the typical constraint is where T : class (it’s a reference type) combined with where T : IEntity (it has an Id property) to enable operations like GetByIdAsync(int id).
Warning: Do not over-constrain generics. If you only need to call one method on T, constrain to the interface that declares that method — not to a concrete class. Over-constraining to concrete types defeats the purpose of generics (code that works with many types). The constraint should be the minimum restriction needed to make the generic body compile, no more. Adding unnecessary constraints makes the generic type harder to use and narrows the set of types it can work with.

Multiple Type Parameters with Constraints

// Multiple type parameters, each with independent constraints
public class Mapper<TSource, TDestination>
    where TSource      : class
    where TDestination : class, new()   // must be ref type AND have parameterless ctor
{
    private readonly Func<TSource, TDestination> _mapFn;

    public Mapper(Func<TSource, TDestination> mapFn) => _mapFn = mapFn;

    public TDestination Map(TSource source)
    {
        ArgumentNullException.ThrowIfNull(source);
        return _mapFn(source);
    }

    public IReadOnlyList<TDestination> MapAll(IEnumerable<TSource> sources)
        => sources.Select(Map).ToList();
}

// Usage
var mapper = new Mapper<Post, PostDto>(
    post => new PostDto { Id = post.Id, Title = post.Title }
);
PostDto dto = mapper.Map(post);

Common Mistakes

Mistake 1 — Calling T-specific methods without a constraint

❌ Wrong — compile error: T has no Id property without a constraint declaring it:

public class Repo<T>
{
    public T? GetById(int id)
        => _items.FirstOrDefault(x => x.Id == id);   // compile error — T has no .Id!
}

✅ Correct — constrain T to an interface that declares Id:

public class Repo<T> where T : IEntity
{
    public T? GetById(int id)
        => _items.FirstOrDefault(x => x.Id == id);   // ✓ IEntity declares Id
}

Mistake 2 — Over-constraining to a concrete class (defeats purpose of generics)

❌ Wrong — only Post can be T; generics add no value:

public class Repo<T> where T : Post { }

✅ Correct — constrain to the interface or base class that provides the needed members.

🧠 Test Yourself

You want to write a generic method that creates a new instance of T and sets a default value. Which constraints are required and why?