Skip to main content

20 - Generics

Generics - <T>

  • Generics introduce to the .NET Framework the concept of type parameters, which make it possible to design classes and methods that defer the specification of one or more types until the class or method is declared and instantiated by client code.
  • Generics are also known as parameterized types or parametric polymorphism.
  • Generics are the foundation of the repository pattern, Unit of Work, and DI registration used throughout this course.
// Declare the generic class.
public class GenericList<T> {
public void Add(T input) {
// Do nothing
}
}

class TestGenericList {
private class ExampleClass { }
static void Main() {
// Declare a list of type int.
var list1 = new GenericList<int>();
list1.Add(1);

// Declare a list of type string.
var list2 = new GenericList<string>();
list2.Add("");

// Declare a list of type ExampleClass.
var list3 = new GenericList<ExampleClass>();
list3.Add(new ExampleClass());
}
}

Generic Methods

  • A method can have its own type parameter(s), independent of the enclosing class
  • The compiler can often infer the type argument from the method arguments
public class Helpers {
// Generic method in a non-generic class
public T FindById<T>(List<T> items, Func<T, bool> predicate) {
return items.First(predicate);
}
}

// Usage -- type is inferred from the argument
var people = new List<Person>();
var person = helpers.FindById(people, p => p.Id == id);

// Explicit type argument -- same result
var person = helpers.FindById<Person>(people, p => p.Id == id);
  • You will see generic methods everywhere: DbContext.Set<TEntity>(), mapper.Map<PersonDto>(person), services.AddScoped<IRepo, EFRepo>()

Generic Interfaces

  • Interfaces can be generic -- the implementing class specifies the concrete type
  • The generic interface becomes a contract. The consumer codes against IRepository<Person>, not PersonRepository.
public interface IRepository<TEntity> {
TEntity? Find(int id);
IEnumerable<TEntity> All();
void Add(TEntity entity);
void Remove(TEntity entity);
}

// Concrete implementation -- closes the type parameter to Person
public class PersonRepository : IRepository<Person> {
public Person? Find(int id) { /* ... */ }
public IEnumerable<Person> All() { /* ... */ }
public void Add(Person entity) { /* ... */ }
public void Remove(Person entity) { /* ... */ }
}

Generic Classes

  • A generic class that implements a generic interface -- the pattern used in repositories
public class EFRepository<TEntity> : IRepository<TEntity> {
protected DbSet<TEntity> DbSet;

public TEntity? Find(int id) => DbSet.Find(id);
public IEnumerable<TEntity> All() => DbSet.ToList();
public void Add(TEntity entity) => DbSet.Add(entity);
public void Remove(TEntity entity) => DbSet.Remove(entity);
}
  • Write the CRUD logic once. It works for Person, ContactType, Order -- any entity type.
  • This is DRY at the architectural level -- one generic class replaces N concrete repository classes.

Generic Inheritance

Three inheritance patterns with generic types:

1. Closing the type parameter -- Person is now fixed

public class PersonRepo : EFRepository<Person> { }

2. Passing through the type parameter -- still generic

public class CachedRepository<T> : IRepository<T> { /* adds caching layer */ }

3. Extending a closed generic interface -- add custom methods

public interface IPersonRepository : IRepository<Person> {
IEnumerable<Person> FindByLastName(string lastName);
}

// Inherits from generic base + implements specific interface
public class PersonEFRepository : EFRepository<Person>, IPersonRepository {
public IEnumerable<Person> FindByLastName(string lastName) { /* ... */ }
}
  • Custom repo methods go in IPersonRepository. Generic CRUD stays in IRepository<TEntity>. Best of both worlds.

Multiple Type Parameters

  • A generic type can have multiple type parameters
// Built-in examples
Dictionary<string, List<Person>> cache;
Func<Person, bool> predicate;
KeyValuePair<Guid, string> pair;

// Method with multiple type parameters
public TDest Map<TSource, TDest>(TSource source) { /* ... */ }
var dto = mapper.Map<Person, PersonDto>(person);
  • You will encounter Expression<Func<TEntity, object>> in repository methods -- read it as "a function that takes TEntity and returns something". The details come later.

Generics - Constraints 1

  • Constraints restrict which types can be used as type arguments. The compiler enforces them at compile time.

  • where T: struct
    • The type argument must be a non-nullable value type. Implies new().
  • where T: class
    • The type argument must be a reference type. This constraint applies also to any class, interface, delegate, or array type. Must be non-nullable.
  • where T: class?
    • Reference type, can be nullable.
  • where T: notnull
    • T must be a non-nullable type
  • where T: unmanaged
    • The type argument must not be a reference type and must not contain any reference type members at any level of nesting.
  • where T: new()
    • The type argument must have a public parameterless constructor. When used together with other constraints, the new() constraint must be specified last.
  • where T: <base class name>
    • The type argument must be or derive from the specified base class. T must be a non-nullable reference type derived from the specified base class.
  • where T: <base class name>?
    • The type argument must be or derive from the specified base class. T may be either a nullable or non-nullable type derived from the specified base class.
  • where T: <interface name>
    • The type argument must be or implement the specified interface. Multiple interface constraints can be specified. The constraining interface can also be generic. T must be a non-nullable type that implements the specified interface.
  • where T: <interface name>?
    • The type argument must be or implement the specified interface. Multiple interface constraints can be specified. The constraining interface can also be generic. T may be a nullable reference type, a non-nullable reference type, or a value type. T may not be a nullable value type.
  • where T: U
    • The type argument supplied for T must be or derive from the argument supplied for U. In a nullable context, if U is a non-nullable reference type, T must be non-nullable reference type. If U is a nullable reference type, T may be either nullable or non-nullable.

Generics - Constraints 2

Some of the constraints are mutually exclusive.

  • All value types must have an accessible parameterless constructor.
  • The struct constraint implies the new() constraint and the new() constraint cannot be combined with the struct constraint.
  • The unmanaged constraint implies the struct constraint.
  • The unmanaged constraint cannot be combined with either the struct or new() constraints.

Unmanaged type

  • sbyte, byte, short, ushort, int, uint, long, ulong, char, float, double, decimal, or bool
  • Enum, pointer, struct that contains only fields of unmanaged types

Constraints in Practice

  • The most common constraint in this course: where TEntity : BaseEntity
public abstract class BaseEntity {
public Guid Id { get; set; }
}

public class Person : BaseEntity {
public string FirstName { get; set; } = default!;
public string LastName { get; set; } = default!;
}
  • The constraint lets the compiler know what TEntity can do:
public class EFRepository<TEntity> where TEntity : BaseEntity {
public TEntity? FindById(Guid id) =>
DbSet.FirstOrDefault(e => e.Id == id);
// ^^^^
// Without the constraint, the compiler wouldn't know TEntity has an Id property
}
  • Without a constraint, T is just object -- you can only call ToString(), Equals(), GetHashCode()
  • Same pattern appears in UoW: EFBaseUOW<TDbContext> where TDbContext : DbContext

Covariance and Contravariance

  • out T = covariance -- the type only comes out (return values)
  • in T = contravariance -- the type only goes in (parameters)
// IEnumerable<out T> is covariant
IEnumerable<Person> people = new List<Person>();
IEnumerable<BaseEntity> entities = people; // OK -- Person derives from BaseEntity

// List<T> is NOT covariant (T is used for both in and out)
List<Person> peopleList = new List<Person>();
// List<BaseEntity> entityList = peopleList; // Compile error!

// Action<in T> is contravariant
Action<BaseEntity> printEntity = e => Console.WriteLine(e.Id);
Action<Person> printPerson = printEntity; // OK -- can handle any BaseEntity, so can handle Person
  • This is why repositories return IEnumerable<TEntity> rather than List<TEntity> -- more flexibility for consumers.

default(T) and Common Patterns

  • default(T) returns the default value for type T: 0 for int, null for reference types, Guid.Empty for Guid
  • Since C# 7.1, just default when the compiler can infer the type
public void InsertOrUpdate(TEntity entity) {
if (entity.Id == default) { // Guid.Empty for Guid
// new entity
DbSet.Add(entity);
} else {
// existing entity
DbSet.Update(entity);
}
}
  • default! -- the ! is the null-forgiving operator, not related to generics. Used to suppress nullable warnings on required properties: public string Name { get; set; } = default!;

How Generics Work (CLR)

  • C# generics are preserved at runtime -- the CLR knows List<int> and List<string> are different types
  • Value types (int, Guid, struct) -- the CLR generates specialized native code for each. List<int> stores actual integers, no boxing.
  • Reference types (Person, string) -- the CLR shares one implementation, since all references are the same size (pointer). List<Person> and List<string> share compiled code.
  • Java comparison -- Java erases generics at compile time (type erasure). At runtime, List<Integer> is just List<Object>. C# does not erase -- this is why C# generics are faster and type-safer at runtime.
  • You can check generic types at runtime: typeof(List<>) (open), typeof(List<int>) (closed)

Built-in Generic Types You Already Use

  • List<T>, IList<T>, ICollection<T> -- collections
  • Dictionary<TKey, TValue> -- key-value store
  • IEnumerable<T>, IQueryable<T> -- used in repos for returning data
  • DbSet<TEntity> -- EF Core's generic set, essentially a built-in repository
  • Task<T> -- async return values
  • Nullable<T> (i.e., int?) -- makes value types nullable
  • Action<T>, Func<T, TResult> -- generic delegates
  • Expression<Func<T, bool>> -- used in LINQ/repo Where clauses

  • When you write DbSet<Person>, you are using a generic class.
  • When you write IRepository<TEntity>, you are creating one.

When NOT to Use Generics

  • Generics are powerful, but not always the right tool
  • Use when: multiple types need the exact same operations (CRUD repos, collections, mappers)
  • Don't use when: every implementation has completely different logic -- you don't have a generic pattern, you have unrelated classes forced into a shared interface
  • If every generic subclass overrides every method differently, the abstraction is not earning its keep
  • YAGNI -- don't make something generic until you have at least 2 concrete cases that share real behavior
  • AI tools love to over-abstract. During defense, you must explain why something is generic, not just that it compiles.

Generics - Benefits

  • Reusability -- create a method or a class that can be reused with different types in several places.
  • Type safety -- the compiler catches type mismatches at compile time. If your repository is IRepository<Person>, you cannot accidentally pass a ContactType to it. No runtime casting needed.
  • No Boxing/Unboxing -- Boxing/Unboxing are costly operations, and it is always better to not rely on them heavily in your code. List<int> stores actual integers, not boxed objects.
  • Naming convention -- T for a single type parameter. Descriptive names with T prefix for multiple: TEntity, TKey, TValue, TDbContext. The naming rule is no different than for naming classes.

Preview: Repository Pattern

In the next lecture, generics enable this entire architecture:

// Generic contract
public interface IRepository<TEntity> where TEntity : BaseEntity { /* CRUD */ }

// Generic implementation -- write once
public class EFRepository<TEntity> : IRepository<TEntity>
where TEntity : BaseEntity { /* ... */ }

// Specific contract -- add custom queries
public interface IPersonRepository : IRepository<Person> { /* ... */ }

// Specific implementation
public class PersonEFRepository : EFRepository<Person>, IPersonRepository { /* ... */ }

// Unit of Work -- also generic
public class EFBaseUOW<TDbContext> where TDbContext : DbContext { /* ... */ }

// DI registration -- generics in action
builder.Services.AddScoped<IPersonRepository, PersonEFRepository>();
  • One generic base class. Dozens of entity types. Zero code duplication.

Self preparation QA

Be prepared to explain topics like these:

  1. Why use generics in repository and service patterns? — Generics let you write CRUD logic once (EFRepository<TEntity>) and reuse it for every entity type. Without generics, you would write nearly identical classes for each entity.
  2. What does the constraint where TEntity : BaseEntity enable? — It tells the compiler that TEntity has an Id property, allowing the generic repository to write methods like FindById(Guid id) without knowing the specific entity type.
  3. What is the difference between covariance (out T) and contravariance (in T)?out T means the type only comes out (return values). in T means the type only goes in (parameters). This is why repositories return IEnumerable<T> rather than List<T>.
  4. How do C# generics differ from Java generics? — C# generics are preserved at runtime (reification). Java erases generics at compile time (type erasure). C# generics are faster and type-safer.
  5. When should you NOT use generics? — When every implementation has completely different logic. If every generic subclass overrides every method differently, the abstraction adds complexity without reducing duplication. Apply YAGNI.
  6. What does default(T) return and how is it used in repositories?default(T) returns 0 for int, null for reference types, Guid.Empty for Guid. In repositories, if (entity.Id == default) checks whether an entity is new vs existing.