Back to Blog
C#

C# Generic Interface: Definition, Usage, and Patterns

c# generic interface: Learn how to define and use generic interfaces in C# for type-safe, reusable abstractions, including constraints, variance, and practical patterns.

C# genericsgeneric interfacestype safetycovariancecontravariancerepository pattern
Illustration of a generic interface in C# with type parameters and type safety.

c# generic interface requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

A generic interface in C# lets you define a contract where one or more of the types used in its members are specified by the consumer. Instead of writing separate interfaces for each concrete type, you write one interface with a type parameter and then implement it for the types you need. This is a common pattern for repositories, validators, and serializers where the same operations apply to many different types.

Defining a Generic Interface

The syntax for a generic interface is straightforward. You add a type parameter in angle brackets after the interface name, then use that parameter in the method signatures. For example:

public interface IRepository<T> { T GetById(int id); void Add(T entity); void Remove(T entity); }

This interface states that any implementing class must provide a way to retrieve, add, and remove entities of type T. The consumer of the interface decides what T is when they declare a variable or implement the interface. For instance, a CustomerRepository might implement IRepository<Customer> while an OrderRepository implements IRepository<Order>. The compiler enforces that all methods use the correct type, so you get compile-time type safety without casting or runtime checks.

Type Parameters and Constraints

You can restrict what types can be used as type arguments by adding constraints. The where clause follows the interface declaration and can require that T be a class, a struct, have a parameterless constructor, or derive from a specific base type or interface. Here are a few examples:

public interface IEntityRepository<T> where T : class, IEntity, new() { T GetById(int id); void Add(T entity); } public interface IValueRepository<T> where T : struct { T GetDefault(); }

The first interface requires T to be a reference type, implement IEntity, and have a parameterless constructor. This is useful when the implementation needs to create new instances of T. The second interface restricts T to value types, which can be important for performance when working with int, double, or custom structs. Constraints are part of the contract; they let the compiler and the implementation rely on certain capabilities of T.

Covariance and Contravariance

Generic interfaces can be marked as covariant or contravariant using the out and in keywords. Covariance allows a method to return a more derived type than specified in the interface, while contravariance allows a method to accept a less derived type. This is only safe when the type parameter appears in output or input positions respectively.

public interface IProducer<out T> { T Produce(); } public interface IConsumer<in T> { void Consume(T item); }

With out T, you can assign an IProducer<Dog> to an IProducer<Animal> because every Dog is an Animal. With in T, you can assign an IConsumer<Animal> to an IConsumer<Dog> because a consumer that can handle any Animal can also handle a Dog. The .NET base class library uses this in IEnumerable<out T> and IComparer<in T>. Variance only works for reference types, and the compiler enforces that T appears only in the correct positions.

Practical Example: A Generic Repository Interface

A typical use case for a generic interface is the repository pattern. Instead of writing a separate repository for each entity, you define one generic interface and then implement it for each entity type. Here is a minimal in-memory implementation:

public interface IRepository<T> { T GetById(int id); void Add(T entity); void Remove(T entity); } public class InMemoryRepository<T> : IRepository<T> { private readonly Dictionary<int, T> _items = new(); private int _nextId = 1; public T GetById(int id) { return _items.TryGetValue(id, out var item) ? item : default; } public void Add(T entity) { _items[_nextId++] = entity; } public void Remove(T entity) { // Find and remove by matching reference or equality var key = _items.FirstOrDefault(kvp => EqualityComparer<T>.Default.Equals(kvp.Value, entity)).Key; if (key != 0) { _items.Remove(key); } } }

This implementation works for any type T. The Add method assigns a new ID based on an internal counter, and GetById returns default if the ID is not found. The Remove method uses EqualityComparer<T>.Default to compare values, which respects IEquatable<T> if implemented. You can now use this repository for customers, orders, or any other entity without duplicating the storage logic.

Runtime Behavior and Type Safety

Unlike some languages where generics are erased at compile time, C# generics are reified. The runtime knows the actual type argument, and the JIT compiler generates specialized code for value types. This means that using a generic interface with a value type like int does not cause boxing when you call methods like Add or GetById. The type checks happen at compile time, so you cannot accidentally pass a string to a method that expects an int without an explicit cast. This is a significant advantage over non-generic interfaces that use object, where every value type is boxed and unboxed, causing allocations and potential InvalidCastException at runtime.

Performance and Allocation Considerations

The main performance benefit of generic interfaces is the elimination of boxing for value types. When you use a non-generic interface like IList with an int, the value is boxed into a reference type on the heap. Each boxing operation allocates memory, and unboxing copies the value back. Over many operations, this can add noticeable overhead. With a generic interface such as IList<int>, the runtime can use the actual value type directly, avoiding both the allocation and the copy. This matters in high-throughput code like parsing, serialization, or numerical processing. Generic interfaces also reduce the need for runtime type checks because the compiler enforces the correct type at compile time, which can improve both speed and code clarity.

Choosing Between Generic and Non-Generic Interfaces

Not every interface needs to be generic. If the interface only ever works with a single type, or if the type is not relevant to the contract, a non-generic interface is simpler. For example, an ILogger interface with a Log(string message) method does not need a type parameter. Use a generic interface when the same set of operations applies to many different types and you want to preserve type safety without duplicating code. Also consider variance: if you need to treat a collection of derived types as a collection of base types, a covariant interface like IEnumerable<out T> is the right tool. If you need to accept a base type and process derived types, a contravariant interface like IComparer<in T> works well. The decision should be based on whether the type parameter is a core part of the contract and whether you need compile-time safety for multiple types.

Common Pitfalls and Edge Cases

Generic interfaces have a few limitations that can trip up developers. Variance is only allowed for reference types, so you cannot use out or in with a value type parameter. Also, variance is not allowed when the type parameter appears in both input and output positions; the compiler will reject such a declaration. Another edge case is that a generic interface cannot have static members because the interface itself is not instantiated; any static state would be shared across all type arguments, which is rarely what you want. When implementing a generic interface, you must ensure that the type argument satisfies all constraints. For example, if you declare where T : new(), the implementing class must be able to call new T(), which is only possible if T has a public parameterless constructor. Finally, be careful with default in generic methods: for reference types it returns null, but for value types it returns the zero-initialized value. This is often useful but can be surprising if you expect null for a nullable type.

A more subtle issue arises with variance and mutable collections. An interface like IList<T> is not covariant because it has both Add and Indexer methods that take T as input. Attempting to mark it out T would fail compilation. This is by design: allowing covariance would let you add an Animal to a list of Dog, breaking type safety. Understanding these constraints helps you design interfaces that are both flexible and safe.

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