Back to Blog
C#

C# Generics: Type Safety Without Duplication

c# generics: Learn how C# generics provide compile-time type safety while avoiding code duplication. Explore generic classes, methods, constraints, and practical trade...

generic classesgeneric methodstype constraintsvariancetype safety
Illustration of C# generics concept with type parameters and safety.

When you write the same logic for multiple types, C# generics let you define that logic once and keep compile-time type information. Instead of casting from object or duplicating methods, a generic type parameter preserves the exact type at the call site. This article explains how C# generics work in practice, including constraints, runtime behavior, variance, and the tradeoffs that matter when you use them in production code.

The Core Problem Generics Solve

Without generics, you often end up with two options: duplicate a method for each type, or accept object and lose type safety. Duplication is hard to maintain; object requires casting and risks runtime exceptions. Generics provide a third path: a single definition that works with any type, while the compiler tracks the specific type used.

For example, a simple swap function:

public static void Swap<T>(ref T left, ref T right) { T temp = left; left = right; right = temp; }

The caller specifies the type, and the compiler ensures both arguments match. No casting, no runtime type checks.

Generic Classes and Methods

Generic classes are the most common form. A generic collection like List<T> is a familiar example. You can define your own:

public class Repository<T> { private readonly List<T> _items = new(); public void Add(T item) => _items.Add(item); public T Get(int index) => _items[index]; }

Generic methods allow a method to have its own type parameter, independent of the class. This is useful for utility methods that work across many types:

public static T? FindOrDefault<T>(IEnumerable<T> source, Func<T, bool> predicate) { foreach (T item in source) { if (predicate(item)) { return item; } } return default; }

The type parameter is inferred from the arguments in most calls, so you rarely need to spell it out.

Type Constraints and Their Effect

Constraints tell the compiler what capabilities a type parameter must have. Without constraints, you can only use members of object. Constraints allow you to call methods, access properties, or enforce a base class or interface.

public static T Max<T>(T left, T right) where T : IComparable<T> { return left.CompareTo(right) > 0 ? left : right; }

Common constraints include where T : class, where T : struct, where T : new(), and where T : SomeBaseClass. Each constraint narrows the set of acceptable types, which lets you write more specific code. Over-constraining, however, reduces reusability. For instance, requiring new() prevents using types that don't have a parameterless constructor, even if you never create one.

How the Runtime Handles Generics

C# generics are reified: the runtime creates a specialized version of the generic type for each reference type, and a distinct version for each value type. This means a List<int> is a different closed type from a List<string>. For value types, the JIT generates code that avoids boxing, so List<int> stores ints directly. This is a major performance advantage over non-generic collections like ArrayList.

The tradeoff is that generic code with many value type instantiations can increase JIT compilation time and memory usage. In most applications this is negligible, but in high-throughput services with many generic types, the impact is measurable. Profiling is the only reliable way to decide whether it matters.

Variance in Interfaces and Delegates

Variance controls whether a generic type can be used as a base type. Covariance (out T) allows a IEnumerable<string> to be assigned to IEnumerable<object>. Contravariance (in T) allows an Action<object> to be used where Action<string> is expected.

IEnumerable<string> strings = new List<string>(); IEnumerable<object> objects = strings; // covariance Action<object> printObject = obj => Console.WriteLine(obj); Action<string> printString = printObject; // contravariance

Only interfaces and delegates can declare variance, and it only works for reference types. Value types do not participate in variance because they cannot be implicitly converted to object without boxing.

Common Mistakes and Maintainability Concerns

One frequent mistake is ignoring constraints and using object inside a generic method, which defeats the purpose. Another is overusing generics for every abstraction, making code harder to read. Generics are a tool for preserving type information, not for hiding complexity.

When you add a generic type parameter, you increase the surface area of your API. Every consumer must either specify the type or rely on inference. If inference fails, the call site becomes noisy. Consider whether a non-generic interface or a base class would be simpler.

When to Avoid Generics

Generics are not always the right choice. If you need to store heterogeneous types in a single collection, a non-generic base type or object may be necessary. If the logic depends heavily on runtime type checks, a generic implementation often becomes a series of if (typeof(T) == ...) branches, which is a sign that the abstraction is wrong.

Performance is another consideration. While generics avoid boxing for value types, they also prevent certain optimizations. For example, a generic method that uses EqualityComparer<T>.Default adds a virtual call. In hot paths, a non-generic implementation specialized for a known type can be faster. Measure before optimizing.

c# generics: Practical Usage and Code Examples | RYUSLOG DEV