Generics and Constraints
One class that works for many types, and the compiler still checks every use
Guess first
Pick an answer. You will see if you were right at the end of the lesson.
What does the constraint where T : class, IEntity let you do inside the class?
Real-world analogy
What is it?
Generics let you write a class or method with a placeholder type, written T. It is filled in when you use it. The compiler checks every use, so you get safety without casts, and value types avoid boxing. Constraints tell the compiler what T must be able to do, which is what lets you call members on it.
Real-world relevance
A service layer returns Result<Order> instead of throwing for expected failures like "not found". The same Repository<T> serves customers, products and invoices, and the constraint IEntity guarantees each has an Id. Old code that still uses ArrayList is a common source of invalid-cast crashes.
Key points
- Type safety without casting — A List<int> only accepts int. The compiler stops a wrong type, so you never write (string)item and hope.
- No boxing for value types — A non-generic ArrayList wraps each int in an object on the heap. This is called boxing, and it costs memory and time. List<int> stores raw ints.
- Generic classes and methods — Write class Box<T> or T First<T>(List<T> items) once. The runtime builds a fitted version for each value type, and reference types share one.
- Constraints say what T can do — where T : class means a reference type, struct a value type, new() a public empty constructor. where T : IEntity lets you read Id inside the class.
- Writable lists are not interchangeable — List<string> is not a List<object>. If it were, you could add an int to a list of strings. The compiler blocks it.
- Covariance in one sentence — IEnumerable<out T> only reads T, so an IEnumerable<string> can be used as IEnumerable<object>. Only interfaces marked out allow it, and only for reference types.
- A real use: Result of T — A Result<T> holds either a value or an error. One type then works for orders, users and invoices, and callers must check success.
- Generic repository trap — Repository<T> with where T : class, IEntity removes repeated code. Do not stretch it until every query needs a special case.
Quick check
Answer from memory — scrolling up first makes it stick less.
Why is List<string> not assignable to List<object>?
What is the main cost of using a non-generic ArrayList with ints?
Code example
public interface IEntity
{
int Id { get; }
}
public class Result<T>
{
public T? Value { get; }
public string? Error { get; }
public bool IsSuccess => Error is null;
private Result(T? value, string? error)
{
Value = value;
Error = error;
}
public static Result<T> Ok(T value) => new(value, null);
public static Result<T> Fail(string error) => new(default, error);
}
public class Repository<T> where T : class, IEntity
{
private readonly List<T> _items = new();
public void Add(T item) => _items.Add(item);
public Result<T> Find(int id)
{
var found = _items.FirstOrDefault(x => x.Id == id);
return found is null ? Result<T>.Fail("Not found") : Result<T>.Ok(found);
}
}Line-by-line walkthrough
- 1. IEntity promises that anything stored has an Id. The repository relies on this.
- 2. Result of T holds either a Value or an Error. The constructor is private, so only Ok and Fail can create one.
- 3. Ok and Fail are the two doors. IsSuccess is true when Error is null.
- 4. Repository of T has a where clause: T must be a class and must implement IEntity.
- 5. Because of that constraint, Find can write x.Id inside the lambda. Without it the compiler would reject the line.
- 6. Find returns a Result of T, so callers must look at IsSuccess before using Value.
Spot the bug
using System.Collections;
var ids = new ArrayList();
ids.Add(1);
ids.Add(2);
ids.Add("three");
int total = 0;
foreach (object id in ids)
{
total += (int)id;
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Generics in .NET (Microsoft Learn)
- Constraints on type parameters (Microsoft Learn)
Lock it in