Back to Blog
C#

C# Generic Class: Type-Safe Reusable Code

c# generic class: Learn how to declare and use generic classes in C#, apply type constraints, avoid common pitfalls, and understand runtime behavior.

C# genericstype parametersgeneric constraintstype safetycode reuse
Illustration of a C# generic class with type parameters and constraints, showing reusable type-safe code.

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 deferring the concrete type until 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 then generates a specialized type for each distinct type argument, so the code inside the class is type-checked against the actual type at compile time.

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. The compiler replaces every T with that type, and the resulting class is fully type-safe.

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 across all instantiations. 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 and later requires unboxing. With a generic class, the JIT compiler 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 shares a single compiled version because all references have the same representation. This means the memory overhead of generics is negligible for reference types, while value types get the performance benefit of specialization without paying a boxing cost.

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 type parameters can be used in all contexts where a concrete type can. For example, you cannot use T in a switch pattern directly, and you cannot create an array of T without a constraint that guarantees it is a reference type or use Array.CreateInstance. The compiler needs to know the runtime behavior of T.

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).

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. You can also make a derived class generic and pass its own type parameter to the base, as long as the base constraint is satisfied.

Generic classes cannot be used as a base class without specifying type arguments. You cannot write class MyRepo<T> : Repository<T> and then later instantiate MyRepo<Customer> without the base also being generic. That is allowed, but the base class is a constructed generic type at runtime.

Understanding how generic classes interact with inheritance helps you design reusable base classes without falling into the trap of trying to make a generic class itself a base for non-generic code without closing the type parameter.

c# generic class: Practical Usage and Code Examples | RYUSLOG DEV