C# Generic Class: Type-Safe Reusable Code
Learn how to declare and use C# generic classes, apply type constraints, avoid common pitfalls, and understand runtime behavior.
A C# generic class lets you write a single implementation that works with multiple types while preserving compile-time type safety. Without generics, you either duplicate code for each type or fall back to object, which forces casts and can hide errors until runtime. The generic class solves that by letting the concrete type be supplied when the class is instantiated.
Why a Generic Class Prevents Code Duplication
The most immediate benefit of a generic class is that it removes the need to write near-identical versions of the same logic for different types. Consider a simple repository that stores entities by integer ID. Without generics, you would create a separate class for each entity type, or you would use object and cast on every read. The first approach multiplies code, and the second sacrifices compile-time checks.
A generic class captures the common shape of the operation. The type parameter stands in for the concrete type until the caller supplies it. The compiler type-checks the body against T under the declared constraints, and at runtime the JIT creates a constructed generic type for each type argument. For value type arguments it specializes code; for reference type arguments it can share the same compiled code because reference values have the same representation.
Declaring a Generic Class with Type Parameters
Declaring a generic class uses angle brackets after the class name. The type parameter T is a placeholder that can be used inside the class for fields, properties, method parameters, and return types.
public class Repository<T> { private readonly Dictionary<int, T> _items = new(); public void Add(int id, T item) { _items[id] = item; } public T Get(int id) { return _items[id]; } }
When you instantiate Repository<T>, you supply the concrete type. Every T in the API is bound to that type, so the resulting member signatures are checked against it.
var customerRepo = new Repository<Customer>(); customerRepo.Add(1, new Customer("Alice")); Customer c = customerRepo.Get(1); // no cast needed
If you tried to pass a string where a Customer is expected, the compiler rejects it. That is the core value of the generic class: the type constraint is enforced without runtime checks.
Applying Constraints to Type Parameters
Sometimes the generic class needs to call methods or access members on T. Without a constraint, T is treated as object, so you can only call object members. Constraints tell the compiler what capabilities T must have.
The most common constraints are where T : class, where T : struct, where T : SomeBaseClass, and where T : ISomeInterface. You can also require a parameterless constructor with where T : new().
public class Factory<T> where T : new() { public T Create() { return new T(); } }
Without the new() constraint, the compiler would not allow new T() because it cannot guarantee that every possible T has a public parameterless constructor. Constraints are part of the class contract; callers must supply a type that satisfies them.
Constraints also enable operations like comparisons. For example, where T : IComparable<T> allows calling CompareTo on two instances. The compiler only permits the method call when the constraint guarantees the member exists.
Generic Classes and Static Members
A subtle behavior of generic classes is that static members are shared per constructed type, not shared between different constructed types. Each distinct type argument produces its own set of static fields.
public class Counter<T> { public static int Count; }
Counter<int>.Count and Counter<string>.Count are separate fields. Incrementing one does not affect the other. This is a common source of confusion when developers expect a single static field shared by all generic instantiations. If you need a shared counter, place it in a non-generic static class instead.
Performance Characteristics of Generic Classes
Generic classes avoid boxing for value types. When you use object to store an int, the value is boxed on the heap, which allocates an object and later requires unboxing. With a generic class, the JIT creates a specialized version of the class for each value type, so the value can be stored directly without boxing.
For reference types, the JIT can share a single compiled version because all references have the same representation. This means generic code for reference types does not multiply compiled code per type, while value types get the benefit of specialization without boxing.
This is not a claim about specific benchmark numbers; it is the underlying mechanism. The practical effect is that generic collections like List<T> are more efficient than their non-generic predecessors such as ArrayList, which stored object and boxed value types.
Common Pitfalls When Working with Generic Classes
One pitfall is assuming that T supports the same operations as the eventual concrete type. For example, new T() compiles only when a constraint guarantees a public parameterless constructor, and as T compiles only when T is constrained to a reference type because T could otherwise be a value type. Use constraints when the implementation genuinely needs those operations.
Another issue is over-constraining. Adding where T : class when the class only uses T as a return type limits the caller unnecessarily. Only add constraints that the implementation actually requires.
A third pitfall is forgetting that generic classes are not covariant. Repository<Customer> is not a subtype of Repository<Person> even if Customer derives from Person. Generic variance only applies to interfaces and delegates, and it must be declared explicitly with in or out.
Generic Classes in Inheritance Hierarchies
A generic class can inherit from a non-generic base class, and a non-generic class can inherit from a closed generic class (one with a fixed type argument).
Assuming Customer derives from BaseEntity, a closed generic base looks like this:
public class BaseEntity { public int Id { get; set; } } public class Repository<T> where T : BaseEntity { public T Get(int id) { /* ... */ } } public class CustomerRepository : Repository<Customer> { public Customer GetByName(string name) { /* ... */ } }
Here, CustomerRepository inherits all members of Repository<Customer>. The type argument is fixed, so the derived class is not generic. A generic derived class can pass its own type parameter to the base as long as the base constraint is satisfied:
public class MyRepo<T> : Repository<T> where T : BaseEntity { }
A generic class cannot be used as a base class without supplying its type arguments. The example above is allowed because MyRepo<T> declares its own T and satisfies Repository<T>'s constraint. To create a non-generic derived class, you must close the base type argument, as in Repository<Customer>.