Generics
Generics allow you to define type-safe data structures and methods that work with any data type while maintaining compile-time type safety. Think of them as templates that get filled in with specific types when you use them.
The Problem
Without generics, you're forced to choose between type safety and reusability. Consider a simple stack implementation:
// Type-specific version - safe but not reusable
public class IntStack
{
private int[] items = new int[10];
private int count = 0;
public void Push(int item)
{
items[count++] = item;
}
public int Pop()
{
return items[--count];
}
}
// Object-based version - reusable but not type-safe
public class ObjectStack
{
private object[] items = new object[10];
private int count = 0;
public void Push(object item)
{
items[count++] = item;
}
public object Pop()
{
return items[--count];
}
}
The IntStack is type-safe but you need separate implementations for strings, doubles, etc. The ObjectStack works for any type but requires casting and allows runtime errors:
var stack = new ObjectStack();
stack.Push(42);
stack.Push("hello");
int value = (int)stack.Pop(); // Runtime error - we got a string!
The Solution
Generics give you both type safety and reusability:
public class Stack<T>
{
private T[] items = new T[10];
private int count = 0;
public void Push(T item)
{
items[count++] = item;
}
public T Pop()
{
return items[--count];
}
}
Now you get compile-time type checking with full reusability:
var intStack = new Stack<int>();
intStack.Push(42);
int value = intStack.Pop(); // No casting needed, fully type-safe
var stringStack = new Stack<string>();
stringStack.Push("hello");
string text = stringStack.Pop(); // Compiler enforces type safety
Generic Classes
Generic classes use type parameters (typically T for "type") that get replaced with actual types when instantiated:
public class Repository<T> where T : class
{
private readonly DbContext _context;
private readonly DbSet<T> _dbSet;
public Repository(DbContext context)
{
_context = context;
_dbSet = context.Set<T>();
}
public async Task<IEnumerable<T>> GetAllAsync()
{
return await _dbSet.ToListAsync();
}
public async Task<T> GetByIdAsync(int id)
{
return await _dbSet.FindAsync(id);
}
public async Task AddAsync(T entity)
{
await _dbSet.AddAsync(entity);
await _context.SaveChangesAsync();
}
}
Usage:
var platformRepo = new Repository<Platform>(dbContext);
var platforms = await platformRepo.GetAllAsync();
var commandRepo = new Repository<Command>(dbContext);
var commands = await commandRepo.GetAllAsync();
Generic Methods
Methods can also be generic, independent of their containing class:
public class DataConverter
{
public T Convert<T>(string json)
{
return JsonSerializer.Deserialize<T>(json);
}
public List<TOutput> Transform<TInput, TOutput>(
List<TInput> items,
Func<TInput, TOutput> mapper)
{
return items.Select(mapper).ToList();
}
}
Usage:
var converter = new DataConverter();
// Type inference - compiler figures out T is Platform
var platform = converter.Convert<Platform>(jsonString);
// Multiple type parameters
var ids = converter.Transform<Platform, int>(
platforms,
p => p.Id
);
Generic Interfaces
Interfaces commonly use generics to define contracts that work with any type:
public interface IRepository<T> where T : class
{
Task<IEnumerable<T>> GetAllAsync();
Task<T> GetByIdAsync(int id);
Task AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(int id);
}
Implementation:
public class PlatformRepository : IRepository<Platform>
{
// Implement methods specifically for Platform
}
public class CommandRepository : IRepository<Command>
{
// Implement methods specifically for Command
}
Constraints
Constraints limit what types can be used with generics, enabling more specific operations:
// Must be a reference type (class)
public class Repository<T> where T : class { }
// Must be a value type (struct)
public class ValueRepository<T> where T : struct { }
// Must have a parameterless constructor
public class Factory<T> where T : new()
{
public T Create() => new T();
}
// Must inherit from a base class
public class EntityRepository<T> where T : BaseEntity { }
// Must implement an interface
public class SortableList<T> where T : IComparable<T>
{
public void Sort(List<T> items)
{
items.Sort(); // Can call CompareTo because of constraint
}
}
// Multiple constraints
public class Manager<T> where T : class, IEntity, new() { }
Common constraint combinations:
// Entity Framework repositories often use this
public class EFRepository<T> where T : class
{
private readonly DbSet<T> _dbSet;
// ...
}
// Domain entities with base class
public class DomainRepository<T> where T : BaseEntity, IEntity
{
// Can access properties from BaseEntity and IEntity
}
Common Generic Types in .NET
You'll encounter these generic types frequently:
Collections
List<Platform> platforms = new List<Platform>();
Dictionary<int, Command> commandLookup = new Dictionary<int, Command>();
HashSet<string> uniqueNames = new HashSet<string>();
Queue<Task> taskQueue = new Queue<Task>();
LINQ and Func/Action
// Func<T, TResult> - function that returns a value
Func<Platform, string> getName = p => p.Name;
// Action<T> - function that returns void
Action<Platform> printPlatform = p => Console.WriteLine(p.Name);
// Predicate<T> - function that returns bool
Predicate<Platform> isActive = p => p.IsActive;
// LINQ methods are all generic
IEnumerable<string> names = platforms
.Where(p => p.IsActive)
.Select(p => p.Name);
Nullable Value Types
int? nullableInt = null;
DateTime? optionalDate = DateTime.Now;
// Under the hood: Nullable<T> where T : struct
Nullable<int> explicitNullable = null;
Covariance and Contravariance
These concepts allow for more flexible type conversions with generics:
Covariance (out)
Allows a more derived type to be used:
// IEnumerable<T> is covariant
IEnumerable<string> strings = new List<string> { "a", "b" };
IEnumerable<object> objects = strings; // Valid - strings are objects
Contravariance (in)
Allows a less derived type to be used:
// Action<T> is contravariant
Action<object> printObject = obj => Console.WriteLine(obj);
Action<string> printString = printObject; // Valid - can print any object
Real-World Example: Generic Service Layer
Combining generic repositories with a service layer:
public interface IService<TEntity, TDto>
where TEntity : class
where TDto : class
{
Task<IEnumerable<TDto>> GetAllAsync();
Task<TDto> GetByIdAsync(int id);
Task<TDto> CreateAsync(TDto dto);
}
public class PlatformService : IService<Platform, PlatformDto>
{
private readonly IRepository<Platform> _repository;
private readonly IMapper _mapper;
public PlatformService(
IRepository<Platform> repository,
IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<IEnumerable<PlatformDto>> GetAllAsync()
{
var entities = await _repository.GetAllAsync();
return _mapper.Map<IEnumerable<PlatformDto>>(entities);
}
public async Task<PlatformDto> GetByIdAsync(int id)
{
var entity = await _repository.GetByIdAsync(id);
return _mapper.Map<PlatformDto>(entity);
}
public async Task<PlatformDto> CreateAsync(PlatformDto dto)
{
var entity = _mapper.Map<Platform>(dto);
await _repository.AddAsync(entity);
return _mapper.Map<PlatformDto>(entity);
}
}
Best Practices
Use meaningful type parameter names beyond T when you have multiple:
// Good
public interface IMapper<TSource, TDestination> { }
// Less clear
public interface IMapper<T, U> { }
Apply constraints when you need specific capabilities:
// Enable comparison operations
public T GetMax<T>(T a, T b) where T : IComparable<T>
{
return a.CompareTo(b) > 0 ? a : b;
}
Prefer generic methods over object parameters:
// Good - type-safe
public void Log<T>(T item)
{
Console.WriteLine(item?.ToString());
}
// Avoid - requires casting
public void Log(object item)
{
Console.WriteLine(item?.ToString());
}
Don't over-genericize - if a method only works with strings, don't make it generic:
// Pointless - only works with strings anyway
public string ToUpper<T>(T value) => value.ToString().ToUpper();
// Better
public string ToUpper(string value) => value.ToUpper();
Performance Benefits
Generics provide performance advantages over object-based approaches:
- No boxing/unboxing: Value types stay as value types
- Type-specific code generation: The JIT compiler creates optimized code for each type
- Better memory usage: No unnecessary object wrappers
// Object-based - boxing occurs
var objectList = new ArrayList();
objectList.Add(42); // int boxed to object
// Generic - no boxing
var genericList = new List<int>();
genericList.Add(42); // stays as int
Further Reading
For comprehensive documentation on generics in C#, see the official Microsoft documentation.