Skip to main content

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.