Skip to main content

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 GenericsSolution with Generics
ArrayList stores object — boxing value types causes overheadList<T> stores values directly — no boxing
Casting from object can fail at runtimeType errors are caught at compile time
Duplicate code for each data typeOne 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
Key Benefits
  • 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.

ConstraintDescription
where T : structT must be a value type
where T : classT must be a reference type
where T : new()T must have a parameterless constructor
where T : BaseClassT must derive from BaseClass
where T : IInterfaceT must implement IInterface
where T : unmanagedT must be an unmanaged type (no reference fields)
where T : notnullT must be a non-nullable type
where T : EnumT 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 Limitations
  • Variance only applies to interfaces and delegate types, not classes or structs.
  • out parameters are output-only; in parameters are input-only.
  • Value types do not support variance — IEnumerable<int> cannot be assigned to IEnumerable<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 servicesIRepository<T> pattern for data access layers.
  • Utility and helper methods — swap, max, min, convert, and other type-agnostic operations.
  • Event handlers and callbacksEventHandler<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
new() Constraint

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.

References