Exploring Advanced Generics in C#: A Practical Guide with Examples

Generics in C# are one of the most powerful features of the language, enabling developers to write flexible, type-safe, and reusable code. This article aims to provide a guided tour of some advanced topics in C# generics, such as constraints, covariance and contravariance, and custom generic interfaces.

  1. Generic Constraints
  2. Generic Constraints with Multiple Types
  3. Covariance and Contravariance
  4. Custom Generic Interfaces
  5. Generic Methods
  6. Generic Collections Customization

1. Generic Constraints

We use generic constraints to restrict the types that can be used as arguments for a generic type parameter.
For example, let’s say my company has employees and products, and I need different printing functions to print employee and product information, respectively.
By using constraints, I can ensure that each printing function only accepts the correct class.

Now, let’s take a look at my printing classes for employees and products.

public class PrintEmployeeInformation<T> where T : class, IEmployee
{ 
  public  void DisplayIdAndName(T Entity)
    {
        Console.WriteLine($"Employee ID: {Entity.Id} , Employee Name: {Entity.Name}, Description: {Entity.Description}");
    }

}
public class PrintProductInformation<T> where T : class, IProduct
{
    public void DisplayIdAndName(T Entity)
    {
        Console.WriteLine($"Product ID: {Entity.Id} , Product Name: {Entity.Name}, Description: {Entity.Description}");
    }

}

As you can see, I set a constraint for the employee printing class to only accept types that implement the IEmployee interface, and similarly, the product printing class only accepts types that implement the IProduct interface.

For example, let’s say I have different employee classes, but both implement the IEmployee interface. Since they meet the constraint, both classes can be passed into the PrintEmployeeInformation class.

public class EmployeeIT : IEmployee
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }

    public void Programming()
    {
        Console.WriteLine("I can do programming");
    }
}

public class EmployeeFinance : IEmployee
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }

    public void FInanceReport()
    {
        Console.WriteLine("I can do finance report");
    }
}

//Main program

EmployeeIT emp = new EmployeeIT
{
    Id = 101,
    Name = "Alice",
    Description = "IT Department" 
};

EmployeeFinance emp2 = new EmployeeFinance
{
    Id = 102,
    Name = "Bob",
    Description = "Finance Department"
};

PrintEmployeeInformation<EmployeeIT> printer1 = new PrintEmployeeInformation<EmployeeIT>();
printer1.DisplayIdAndName(emp);

PrintEmployeeInformation<EmployeeFinance> printer2 = new PrintEmployeeInformation<EmployeeFinance>();
printer2.DisplayIdAndName(emp2);

Output

Generic constants

Same for product

public class Product : IProduct
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; } 
    public double Price { get; set; }

}

Product product = new Product
{
    Id = 2001,
    Name = "T-Shirt",
    Description="Green Colour",
    Price=30.0
};

//Main program

PrintProductInformation<Product> printer3 = new PrintProductInformation<Product>();
printer3.DisplayIdAndName(product);

Output

Product

It wouldn’t work if I passed a class that didn’t meet the constraint. This OOP feature ensures the code is safer and helps prevent unpredictable errors.

Product Class

2. Generic Constraints with Multiple Types

Let’s try to add more than one constraint.

First, I create the Employee class, the Manager class that implements the Employee class, and an Iwork interface and the CodingClass that implements it.

public class Employee
{
    public string Name { get; set; }
}

public class Manager : Employee
{
    public int TeamSize { get; set; }
}

public interface IWork
{
    void PerformWork();
}

public class CodingTask : IWork
{
    public void PerformWork()
    {
        Console.WriteLine("Writing code...");
    }
}

This is generic class that has multiple constraints.

public class Organization<TEmployee, TWork>
    where TEmployee : Employee, new()      

    where TWork : IWork                     

{
    public TEmployee Leader { get; set; }
    public TWork Task { get; set; }

    public Organization()
    {
        Leader = new TEmployee(); // Using parameterless constructor constraint
    }

    public void AssignTask()
    {
        Console.WriteLine($"{Leader.Name} is assigning the task.");
        Task.PerformWork(); 
    }

}
  • It requires two generic parameters, TEmployee and TWork
  • where TEmployee must implement Employee and new() indicates it must have no parameter
  • where TWork must implement IWork

The main program

var org = new Organization<Manager, CodingTask>
{
    Leader = new Manager { Name = "Alice", TeamSize = 5 },
    Task = new CodingTask()
};
org.AssignTask();
Console.ReadLine();

 //Output: Alice is assigning the task.

If I created another class of InternStaff that does not implement anything, another class AccountingTask, that does not implement IWork.

public class InternStaff 
{
    public int TeamSize { get; set; }
}

public class AccountingTask  
{
    public void PerformWork()
    {
        Console.WriteLine("Do some accounting report...");
    }
}

So, these classes will not be able to pass as arguments to the Organization class because they do not meet the constraints; it will cause a syntax error.

Generic constants in C#

3. Covariance and Contravariance

So, in simple English,

  • Covariance allows you to use a more derived type than originally specified.
  • Contravariance allows you to use a less derived type than originally specified.

Let’s see how miserable life is without Covariance and Contravariance.

Imagine a company where managers derived from base employees.

Without Covariance

So, in simple English,

  • Covariance allows you to use a more derived type than originally specified.
  • Contravariance allows you to use a less derived type than originally specified.

Let’s see how miserable life is without Covariance and Contravariance.

Imagine a company where managers derived from base employees.

Without Covariance

public class Employee
{
    public string Name { get; set; }
}

public class Manager : Employee//Manager derived from Employee
{
    public string Department { get; set; }
}

Then, we have ManagerPrinter and EmployeePrinter; both accept specific parameters and print information for certain roles.

void EmployeePrinter(List<Employee> employees)
{
    foreach (var employee in employees)
    {
        Console.WriteLine(employee.Name);
    }
}

void ManagerPrinter(List<Employee> employees)
{
    foreach (var employee in employees)
    {
        Console.WriteLine(employee.Name);
    }
}

It will not compile because the type is not correct.

var managers = new List<Manager>
        {
            new Manager { Name = "Alice", Department = "HR" },
            new Manager { Name = "Bob", Department = "IT" }
        };

EmployeePrinter(managers);

With Covariance

But with covariance, we can use IEnumerable<T> since List<T> is inherited from IEnumerable<T>. So, we can change the method to

void CovariancePrinter(IEnumerable<Employee> employees)
{
    foreach (var employee in employees)
    {
        Console.WriteLine(employee.Name);
    }

}

//In main program

var managers = new List<Manager>
        {
            new Manager { Name = "Alice", Department = "HR" },
            new Manager { Name = "Bob", Department = "IT" }
        };

CovariancePrinter(managers);//print Alice \nBob

Without Contravariance

I have a PrintEmployeeInfo that accepts employee-derived class managers are not able to pass into it.

public static void PrintEmployeeInfo(Employee employee)
    {
        Console.WriteLine($"Employee: {employee.Name}");
    }

// This will not work
//  PrintEmployeeInfo(new Manager { Name = "Alice", Department = "HR" });

This can be solved by changing the method to contravariant to accept Employee or any derived type.

With Contravariance

public static void PrintInfo<T>(T obj) where T : Employee
    {
        if (obj is Manager manager)
        {
            Console.WriteLine($"Manager: {manager.Name}, Department: {manager.Department}");
        }
        else
        {
            Console.WriteLine($"Employee: {obj.Name}");
        }
    }

You may realize both my examples of covariance and contravariance are quite the same; you may confuse it with each other. This is because they are similar concepts but just work opposite directions.

A simple rule of thumb will look like this

Key Differences between Covariance and Contravariance:

  • Covariance allows you to assign a more derived type to a more generic or base type. It applies to return types or output types. 
  • Contravariance, on the other hand, allows you to assign a more generic or base type to a more derived type. It applies to input parameters. 

4. Custom Generic Interfaces

Suppose we have two classes, Product and Customer. We need to use a repository class to store those entities.

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
}

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Email { get; set; }
}

We created an Interface for each entity, IProductRepository and ICustomerRepository.

public interface IProductRepository
{
    void Add(Product product);
    Product GetById(int id);
    IEnumerable<Product> GetAll();
    void Update(Product product);
    void Delete(int id);
}

public interface ICustomerRepository
{
    void Add(Customer customer);
    Customer GetById(int id);
    IEnumerable<Customer> GetAll();
    void Update(Customer customer);
    void Delete(int id);
}

Then, the code implementation of each interface.

public class ProductRepository : IProductRepository
{
    private readonly List<Product> _products = new();

    public void Add(Product product) => _products.Add(product);
   //...other implementation codes

// Implement the ICustomer repository
public class CustomerRepository : IRepository<Customer>
{
//...other implementation codes

Or, we can create one generic repository interface.

public interface IRepository<T>
{
    // Add a new item to the repository
    void Add(T item);

    // Get an item by its ID
    T GetById(int id);

    // Get all items
    IEnumerable<T> GetAll();

    // Update an existing item
    void Update(T item);

    // Remove an item by its ID
    void Delete(int id);
}

 And the implementation.

// Implement the IRepository for Product

public class GenericProductRepository : IRepository<Product>
{
    private readonly List<Product> _products = new();

    public void Add(Product item) => _products.Add(item);

    public Product GetById(int id) => _products.FirstOrDefault(p => p.Id == id);

   // other codes...
}

// Implement the IRepository for Customer
public class GenericCustomerRepository : IRepository<Customer>
{
//...other implementation codes
//Main Program

GenericProductRepository<Product> productRepository= new  GenericProductRepository();

productRepository.Add(new Product { Id = 1, Name = "Laptop", Price = 1500.00m });
productRepository.Add(new Product { Id = 2, Name = "Phone", Price = 800.00m });

foreach (var product in productRepository.GetAll())
{
    Console.WriteLine($"ID: {product.Id}, Name: {product.Name}, Price: {product.Price}");
}

So, this is the benefit of custom generic interfaces: you can avoid code duplication by creating a lot of Interfaces.

Adding new entities (e.g., Supplier, Invoice) becomes easier because you only implement the repository methods for the new type without altering the IRepository<T> interface.

5. Generic Methods

Let's say I have two methods that switch integer and string

void SwapInteger(ref int x , ref int y)
{
    int temp = x;
    x = y; 
    y = temp;
}

void SwapString(ref string x,ref string y)
{
    string temp = x;
    x = y;
    y = temp;
}

The codes will expand if I have a new request to swap different data types, like decimal, double, long, etc.

I can use a generic method to code a single method that works for any data type.

void GenericSwap<T>(ref T a, ref T b)
{
    T temp = a;
    a = b;
    b = temp;
}

//Main Program
int x = 1; int y = 2;
string a = "Hello",  b="World";

GenericSwap(ref x, ref y);
GenericSwap(ref a, ref b);

Console.WriteLine("After Swap");
Console.WriteLine($"x: {x}, y: {y}");
Console.WriteLine($"a: {a}, b: {b}");

6. Generic Collections Customization

We can also customize the C# collection.

Consider the following code using ArrayList. We need to check and cast the object type before print.

public class Employee
{
    public string Name { get; set; }
}

//Main program
ArrayList employees = new ArrayList();

// Adding different types of objects
employees.Add(new Employee { Name = "Alice" });
employees.Add("Invalid Entry"); // No type safety

foreach (var item in employees)
{
    // Type casting required
    if (item is Employee emp)
    {
        Console.WriteLine(emp.Name);
    }
}

Or we can use a customized generic collection. Create a class EmployeeList that implements List<Employee>, so we can ensure this customized collection only accepts Employee objects.

class EmployeeList : List<Employee>
{
    // Add custom behavior or constraints if needed
    public void DisplayAll()
    {
        foreach (var emp in this)
        {
            Console.WriteLine(emp.Name);
        }
    }
}

// Main Program

       EmployeeList employeesGeneric = new EmployeeList();

// Type-safe addition
employeesGeneric.Add(new Employee { Name = "Alice" });
employeesGeneric.Add(new Employee { Name = "Bob" });
// employeesGeneric.Add("Invalid Entry");//Invalid, compile error

employeesGeneric.DisplayAll();

The sample source code of this article can be found on my Github


Similar Articles