Generics
Definition
Generics allow you to define type-parameterized classes, interfaces, methods, and delegates. By introducing type parameters (e.g., <T>), you write code that works with any data type while preserving compile-time type safety and avoiding the performance cost of boxing/unboxing.
Why Generics Exist
| Problem without Generics | Solution with Generics |
|---|---|
ArrayList stores object — boxing value types causes overhead | List<T> stores values directly — no boxing |
Casting from object can fail at runtime | Type errors are caught at compile time |
| Duplicate code for each data type | One implementation for all types |
// Without generics — runtime risk, boxing overhead
ArrayList list = new ArrayList();
list.Add(42); // boxes int to object
int value = (int)list[0]; // unboxes, runtime cast
// With generics — compile-time safety, no boxing
List<int> list = new List<int>();
list.Add(42); // no boxing
int value = list[0]; // no cast needed
- Type safety at compile time
- Code reuse without duplication
- Better performance — no boxing for value types
- Self-documenting code via explicit type parameters
Core Concepts
Generic Classes
Generic classes use one or more type parameters in their definition. The .NET Base Class Library ships with many generic collections.
// Built-in generic classes
List<string> names = new List<string> { "Alice", "Bob" };
Dictionary<string, int> ages = new Dictionary<string, int>
{
["Alice"] = 30,
["Bob"] = 25
};
// Custom generic class
public class Repository<T>
{
private readonly List<T> _items = new();
public void Add(T item) => _items.Add(item);
public T? FindById(Func<T, bool> predicate) =>
_items.FirstOrDefault(predicate);
public IReadOnlyList<T> GetAll() => _items.AsReadOnly();
}
Generic Methods
Methods can introduce their own type parameters independent of the containing class. The compiler often infers the type arguments from usage.
public class Utilities
{
// Generic method with type inference
public T GetDefault<T>() => default(T);
// Explicit type arguments when inference is not possible
public T ConvertTo<T>(object value) => (T)value;
// Generic method with constraint
public T Max<T>(T a, T b) where T : IComparable<T>
=> a.CompareTo(b) >= 0 ? a : b;
}
// Usage
Utilities util = new Utilities();
int max = util.Max(10, 20); // type inferred as int
string def = util.GetDefault<string>(); // explicit — returns null
Generic Interfaces
// Built-in
public interface IComparable<T>
{
int CompareTo(T? other);
}
// Custom generic interface
public interface IRepository<T> where T : class
{
T GetById(int id);
void Add(T entity);
void Delete(T entity);
IEnumerable<T> GetAll();
}
public class InMemoryRepository<T> : IRepository<T> where T : class
{
private readonly List<T> _store = new();
public T GetById(int id) => throw new NotImplementedException();
public void Add(T entity) => _store.Add(entity);
public void Delete(T entity) => _store.Remove(entity);
public IEnumerable<T> GetAll() => _store;
}
Generic Delegates
// Built-in generic delegates
Func<int, int, int> add = (a, b) => a + b; // returns int
Action<string> log = msg => Console.WriteLine(msg); // returns void
Predicate<int> isEven = n => n % 2 == 0; // returns bool
// Custom generic delegate
public delegate TResult Transformer<TInput, TResult>(TInput input);
Constraints
Constraints restrict the types that can be used as type arguments, enabling the compiler to know what operations are available.
| Constraint | Description |
|---|---|
where T : struct | T must be a value type |
where T : class | T must be a reference type |
where T : new() | T must have a parameterless constructor |
where T : BaseClass | T must derive from BaseClass |
where T : IInterface | T must implement IInterface |
where T : unmanaged | T must be an unmanaged type (no reference fields) |
where T : notnull | T must be a non-nullable type |
where T : Enum | T must be an enum |
// Multiple constraints
public class Factory<T> where T : class, new()
{
public T Create() => new T();
}
// Constraint on multiple type parameters
public class Pair<TFirst, TSecond>
where TFirst : struct
where TSecond : class
{
public TFirst First { get; }
public TSecond Second { get; }
}
Default Value Expression
T value = default(T); // null for reference types, zero-initialized for value types
// In generic context
public T? FindOrDefault<T>(IEnumerable<T> source, Func<T, bool> predicate)
{
foreach (T item in source)
if (predicate(item)) return item;
return default; // default(T) — shorthand
}
Covariance and Contravariance
Variance annotations allow generic type parameters to be used polymorphically.
// Covariance (out) — type can only appear as output
IEnumerable<Derived> derived = new List<Derived>();
IEnumerable<Base> bases = derived; // valid because IEnumerable<out T>
// Contravariance (in) — type can only appear as input
IComparer<Base> baseComparer = new BaseComparer();
IComparer<Derived> derivedComparer = baseComparer; // valid because IComparer<in T>
// Custom covariant interface
public interface IProducer<out T>
{
T Produce();
}
// Custom contravariant interface
public interface IConsumer<in T>
{
void Consume(T item);
}
- Variance only applies to interfaces and delegate types, not classes or structs.
outparameters are output-only;inparameters are input-only.- Value types do not support variance —
IEnumerable<int>cannot be assigned toIEnumerable<object>.
Generic Type Inference
The compiler can infer type arguments from method arguments, making calls cleaner.
// Compiler infers T from arguments
var result = Max(3, 5); // T inferred as int
var names = new[] { "a", "b" };
var first = names.FirstOrDefault(); // T inferred as string
When to Use
- Collections and data structures — any time you need a container that works with multiple types.
- Repositories and services —
IRepository<T>pattern for data access layers. - Utility and helper methods — swap, max, min, convert, and other type-agnostic operations.
- Event handlers and callbacks —
EventHandler<TEventArgs>,Func<T>,Action<T>. - Avoid when there are only one or two concrete types and no reuse benefit — simplicity wins.
Common Pitfalls
Over-Constraining
// Bad — too restrictive, limits reusability
public class Cache<T> where T : class, IEntity, IComparable, new()
// Better — only constrain what you actually need
public class Cache<T> where T : class
Generic Type Casting
// This does NOT compile — cannot cast to type parameter
T Convert(object value) => (T)value;
// Correct approach
T Convert<T>(object value) => (T)(dynamic)value;
// Or use Convert.ChangeType
T Convert<T>(object value) => (T)System.Convert.ChangeType(value, typeof(T));
Reflection with Generics
// Getting the open generic type
Type openType = typeof(List<>);
Type closedType = openType.MakeGenericType(typeof(int));
// Checking generic type at runtime
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>))
{
Type elementType = type.GetGenericArguments()[0];
}
new() Constraint Limitations
// new() only works with public parameterless constructors
public T Create<T>() where T : new() => new T();
// Cannot use new() with types that have required parameters or internal constructors
The new() constraint requires a public parameterless constructor. It cannot be satisfied by types with only internal/private constructors or types with required constructor parameters.
Key Takeaways
- Generics provide type safety, code reuse, and performance by eliminating boxing and runtime casts.
- Use constraints to restrict type parameters and access specific members.
- Covariance (
out) and contravariance (in) enable polymorphic usage of generic interfaces. - The compiler can often infer type arguments — prefer letting it do so for cleaner code.
- Avoid over-constraining; only apply constraints your implementation actually needs.
Interview Questions
Q: What are generics and why use them? Generics let you define type-parameterized types and methods. They provide compile-time type safety, eliminate boxing/unboxing for value types, and enable reusable code without duplication.
Q: What are generic constraints?
Constraints (where T : ...) restrict which types can be used as arguments, allowing the compiler to verify that specific members (methods, constructors, properties) are available on T.
Q: What is covariance and contravariance?
Covariance (out) lets a generic interface accept more derived types as outputs. Contravariance (in) lets it accept more base types as inputs. Both enable polymorphic assignment of generic types.
Q: What is the difference between List<T> and ArrayList?
ArrayList stores object, causing boxing for value types and requiring runtime casts. List<T> is generic — it stores values directly with no boxing and provides compile-time type safety.
Q: Can you inherit from a generic type?
Yes. You can inherit from a closed generic type (class MyList : List<int>), or keep the parameter open (class MyList<T> : List<T>). You can also constrain the type parameter in the derived class.