Back to Blog
C#

C# Generic Interface: Definition, Usage, and Patterns

Learn how to define and use C# generic interfaces for type-safe, reusable abstractions. Includes constraints, covariance and contravariance, and practical repository examples.

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

A generic interface in C# is a contract that includes one or more type parameters. The consumer chooses the concrete type when implementing the interface or declaring a variable, which gives compile-time type safety without duplicating code for each type. Generic interfaces are common for repositories, validators, and serializers because the same set of operations applies to many different types.

Defining a Generic Interface

The syntax for a generic interface is straightforward. Add a type parameter in angle brackets after the interface name, then use that parameter in member 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 declaring a variable or implementing the interface. For instance, a CustomerRepository might implement IRepository<Customer> while an OrderRepository implements IRepository<Order>. The compiler enforces that all members use the correct type, so you get compile-time type safety without casting or runtime checks.

Type Parameters and Constraints

You can restrict the types that can be used as type arguments by adding constraints. The where clause follows the interface declaration and can require that T be a reference type, a value type, have a parameterless constructor, or derive from a specific base type or interface. Here are two 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 the type named by the interface variable; contravariance allows a method to accept a less derived type. This is safe only when the type parameter appears in output positions for out and input positions for in.

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 conversions apply only when the type argument is a reference type, 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, define one generic interface and 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 Dictionary<int, T>(); 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) { int? keyToRemove = null; foreach (var pair in _items) { if (EqualityComparer<T>.Default.Equals(pair.Value, entity)) { keyToRemove = pair.Key; break; } } if (keyToRemove.HasValue) { _items.Remove(keyToRemove.Value); } } }

This implementation works for any type T. The Add method assigns a new ID based on an internal counter, and GetById returns default when the ID is not found. The Remove method uses EqualityComparer<T>.Default to compare values, which respects IEquatable<T> when implemented. You can 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 using a generic interface with a value type like int does not box values when you call methods such as Add or GetById. 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 value types are boxed and unboxed, causing allocations and potential InvalidCastException at runtime.

Performance and Allocation Considerations

The main performance benefit of generic interfaces is eliminating 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 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 casts because the compiler knows the exact 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. Consider variance as well: 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 allowed only in the direction declared: an out parameter must appear only in output positions, and an in parameter only in input positions. Variance conversions also apply only when the type argument is a reference type, so IEnumerable<int> cannot be converted to IEnumerable<object>. When implementing a generic interface, the type argument must satisfy all constraints; for example, where T : new() requires a public parameterless constructor. Finally, be careful with default in generic code: for reference types it returns null, for ordinary value types it returns the zero-initialized value, and for nullable value types it returns an instance with HasValue == false. If you need shared static state, prefer a non-generic helper class or a static class instead of trying to store it on the generic interface.

A more subtle issue arises with variance and mutable collections. An interface like IList<T> is not covariant because it has both Add methods and indexers 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