Module 5 · 5. Collections, Generics, LINQ, and Data Transformation · Lesson 14 of 24
Generics, Constraints, Variance, and Type-Safe Reuse
Learning outcomes
Keep type relationships intact with generics, justify constraints, trace both directions of variance, and design a Result<T> whose public construction paths enforce a value-or-errors invariant.
The runnable examples were verified in Release mode with .NET SDK 10.0.401 and runtime 10.0.12 on Windows x64.
What a type parameter buys you
A generic method is checked against the operations its contract permits. Returning T preserves the caller's type; returning object often makes the caller recover it with a cast. Type inference can choose T from the arguments, but it does not remove the method's constraints. Prefer a meaningful relationship over adding a type parameter merely to make an API look reusable.
Constraints describe required capabilities or a deliberate domain boundary. An interface constraint makes its members available. class restricts T to reference types; struct selects non-nullable value types; new() permits construction using a public parameterless constructor. Add only what the implementation or documented domain requires. notnull is a nullable-analysis constraint, not runtime input validation; violations produce warnings in a nullable context.
References: generic type parameters and constraints.
Worked example: typed entity lookup
The original lookup idea is useful: obtain a Customer directly, with no cast. This version also checks that the stored entity's Id matches the lookup key. IEntity now provides a capability the implementation actually uses. class is an explicit reference-entity domain boundary; it is not required by dictionaries themselves. A store for value-type entities could choose a different contract.
Run each full listing separately as Program.cs in a .NET 10 console project with nullable analysis enabled. No packages or external services are used.
using System;
using System.Collections.Generic;
public static class Program
{
public static void Main()
{
var id = Guid.Parse("e0db78e4-84ba-4f52-a909-463de8ec2148");
IReadOnlyDictionary<Guid, Customer> customers =
new Dictionary<Guid, Customer> { [id] = new Customer(id, "Ada") };
Customer customer = EntityLookup.Require(customers, id);
Console.WriteLine(customer.Name);
Console.WriteLine($"Larger: {Larger(3, 7)}");
}
public static T Larger<T>(T left, T right) where T : IComparable<T>
{
ArgumentNullException.ThrowIfNull(left);
ArgumentNullException.ThrowIfNull(right);
return left.CompareTo(right) >= 0 ? left : right;
}
}
public interface IEntity
{
Guid Id { get; }
}
public sealed record Customer(Guid Id, string Name) : IEntity;
public static class EntityLookup
{
public static T Require<T>(IReadOnlyDictionary<Guid, T> items, Guid id)
where T : class, IEntity
{
ArgumentNullException.ThrowIfNull(items);
if (!items.TryGetValue(id, out var value))
throw new KeyNotFoundException($"{typeof(T).Name} {id} was not found.");
if (value is null || value.Id != id)
throw new InvalidOperationException("Stored entity does not match its key.");
return value;
}
}Verified output
Ada Larger: 7
Follow the successful and failed paths
- The compiler infers T as Customer from the dictionary, so the returned value has a Name property.
- A missing key throws KeyNotFoundException. The method is named Require because absence is exceptional under this contract.
- A null dictionary throws ArgumentNullException. A present null value or mismatched Id throws InvalidOperationException: each represents a broken store invariant.
- Larger uses IComparable<T> because it calls CompareTo. It returns left on a comparison tie and rejects null inputs. That capability is different from “can be stored in a dictionary.”
If a lookup merely returned the value without reading Id, IEntity would need an explicit domain reason or should be removed. Constraints are not decoration. Likewise, requiring new() would be unjustified here because neither operation constructs T.
Variance: follow the direction of information
For reference types, covariance lets a specific-type producer serve as a general-type producer; contravariance lets a general-type consumer serve as a specific-type consumer. The interface or delegate must declare the parameter variant. Boxing does not make IEnumerable<int> convertible to IEnumerable<object>.
using System;
using System.Collections.Generic;
public static class Program
{
public static void Main()
{
IEnumerable<Dog> dogs = new List<Dog> { new("Milo") };
IEnumerable<Animal> animals = dogs;
foreach (Animal animal in animals)
Console.WriteLine($"Producer: {animal.Name}");
IConsumer<Animal> allAnimals = new AnimalPrinter();
IConsumer<Dog> dogConsumer = allAnimals;
dogConsumer.Accept(new Dog("Rex"));
// These assignments are intentionally NOT part of the executable code:
// List<Animal> unsafeList = new List<Dog>();
// IEnumerable<object> boxed = new List<int> { 1 };
}
}
public class Animal
{
public Animal(string name) { Name = name; }
public string Name { get; }
}
public sealed class Dog : Animal
{
public Dog(string name) : base(name) { }
}
public interface IConsumer<in T>
{
void Accept(T item);
}
public sealed class AnimalPrinter : IConsumer<Animal>
{
public void Accept(Animal item)
{
Console.WriteLine($"Consumer: {item.Name}");
}
}Verified output
Producer: Milo Consumer: Rex
Every Dog produced is an Animal, so reading animals is safe. The AnimalPrinter accepts any Animal, so it can accept every Dog handed to dogConsumer. Reversing that consumer assignment would allow a non-Dog Animal into code that only understands dogs.
List<Dog> cannot become List<Animal>: that would permit adding an unrelated Animal. IList<T> accepts and returns T; it is invariant. A List<Dog> can still be viewed through IEnumerable<Animal>. Mutability alone does not determine every type’s conversions.
Reference: variance in generic interfaces. The two commented assignments are deliberately invalid. Uncomment one at a time to observe the compiler rejection.
Solved practice: a result with controlled construction
Problem: carry either a successful T or one or more structured errors without exposing a contradictory combination.
Chosen contract: a success has one non-null value and zero errors. A failure has at least one error, each with a nonblank code and message; reading its Value throws. Zero and empty string are legitimate success values. A null success value is intentionally unsupported. A domain that needs “successful but absent” should model that state explicitly instead of passing null.
The sealed class has a private constructor and two factories. Callers cannot set IsSuccess, Value, or Errors. Failure copies the caller's array before validating and storing it. Its read-only wrapper prevents writes through collection interfaces; the contained error records hold immutable strings. These choices protect this result's error state without pretending that arbitrary successful objects are deeply immutable.
using System;
using System.Collections.Generic;
public static class Program
{
public static void Main()
{
var success = Result<int>.Success(0);
Console.WriteLine($"Success: {success.IsSuccess}, value: {success.Value}");
Console.WriteLine($"Success errors: {success.Errors.Count}");
Error[] supplied = { new("name.required", "A name is required.") };
var failure = Result<string>.Failure(supplied);
supplied[0] = new Error("changed", "Caller changed the array.");
Console.WriteLine($"Failure: {failure.IsSuccess}");
Console.WriteLine($"Error: {failure.Errors[0].Code}");
try { Console.WriteLine(failure.Value); }
catch (InvalidOperationException) { Console.WriteLine("No failure value"); }
}
}
public sealed record Error(string Code, string Message);
public sealed class Result<T> where T : notnull
{
private readonly T? value;
public bool IsSuccess { get; }
public IReadOnlyList<Error> Errors { get; }
public T Value => IsSuccess
? value!
: throw new InvalidOperationException("A failure has no value.");
private Result(bool isSuccess, T? value, IReadOnlyList<Error> errors)
{
IsSuccess = isSuccess;
this.value = value;
Errors = errors;
}
public static Result<T> Success(T value)
{
if (value is null) throw new ArgumentNullException(nameof(value));
return new Result<T>(true, value, Array.AsReadOnly(Array.Empty<Error>()));
}
public static Result<T> Failure(params Error[] errors)
{
ArgumentNullException.ThrowIfNull(errors);
var copy = (Error[])errors.Clone();
if (copy.Length == 0)
throw new ArgumentException("At least one error is required.", nameof(errors));
foreach (var error in copy)
{
if (error is null || string.IsNullOrWhiteSpace(error.Code) ||
string.IsNullOrWhiteSpace(error.Message))
throw new ArgumentException("Error code and message are required.", nameof(errors));
}
return new Result<T>(false, default, Array.AsReadOnly(copy));
}
}Verified output
Success: True, value: 0 Success errors: 0 Failure: False Error: name.required No failure value
Why the invalid combinations are inaccessible
- Success(null) is rejected at runtime even if a caller suppresses nullable warnings. Success(0) remains valid: “default value” is not the same as “missing value.”
- Failure() and failures containing null or blank error information are rejected. All supplied errors are retained in order; the factory does not silently discard a bad entry.
- Replacing an element of supplied after construction does not alter failure.Errors, because the factory stores a copy.
- Value checks the discriminating flag before exposing the stored value. A failure's internal default slot is never returned as a fake success.
- This is a class, so default(Result<T>) is a null reference, not a manufactured invalid result instance. APIs accepting a Result must still handle or reject a null reference. The guarantee covers instances created through these factories and normal managed use, not arbitrary reflection or deserializer bypasses.
The factories enforce the result's shape. They do not validate the business meaning of every T, clone a successful mutable value, make a consumer handle a failure, or provide thread synchronization. Decide those separately at the calling boundary.
Failure modes and interview answers
- Returning object: the caller loses the useful relationship between input T and output T. Preserve the type when the operation supports it.
- Unexplained constraints: point to a used member or a documented domain restriction. For Require, Id is used and reference entities are intentional.
- Direction confusion: ask what values can be read or passed. An animal consumer can consume dogs; a dog-only consumer cannot consume arbitrary animals.
- Public flags plus public payload setters: they allow “success with errors” or “failure without errors.” Controlled factories make those combinations unavailable in this design.
- A result struct with no default-state plan: default construction bypasses ordinary custom constructors. This solution chooses a class and explicitly accounts for a null result reference.
Check your understanding
Question: should Require add new()? Answer: no; it returns an existing stored object. A constructor constraint would exclude valid callers without enabling an operation used here.
Question: does a successful Result<List<int>> freeze the list? Answer: no. Its payload is the same list reference. The invariant is about success versus errors, not ownership of an arbitrary payload.
Test checklist: missing/null/mismatched stores; comparison direction and ties; compiler rejection of list invariance and value-type variance; zero and empty-string successes; rejected null success; null/empty/bad errors; copied input; failed writes through IList<Error>; failure Value access; and mutable-success aliasing.
Analogy
A printshop dispatch window hands out only posters. Someone collecting printed items can use it: every poster qualifies. A recycling desk accepts every kind of printed item, so it can take a delivery consisting only of posters. But relabeling a poster drawer as a drawer for all printed items would invite someone to insert a booklet, breaking its poster-only promise.
Mapping. The dispatch window follows IEnumerable<Dog> serving as IEnumerable<Animal>: a specific producer meets a broader output promise. The recycling desk follows IConsumer<Animal> serving as IConsumer<Dog>: a general consumer handles the narrower input. The drawer explains why List<Dog> cannot become List<Animal>; callers could otherwise add an unrelated animal.
Where it stops. Physical relabeling does not grant a C# conversion. These variance conversions require reference types and an appropriately declared variant interface or delegate; a generic type is not automatically variant. Boxing does not make IEnumerable<int> convertible to IEnumerable<object>. The story describes permitted directions of use, not runtime speed or validation of individual values.