DEV Community

Cover image for Generic Class & Constraints in C#
Mo
Mo

Posted on

Generic Class & Constraints in C#

Generics are one of the most powerful tools in C#, enabling reusable, type-safe, and flexible code. But with great power comes great responsibility, and that’s where constraints on generics come into play. In this article, we’ll dive deep into generic constraints in C#, starting from the basics and progressing to advanced use cases.

Why Do We Need Generics?

Let’s first understand why generics exist. Imagine you’re writing a class to process integers and strings. Without generics, you’d need to write separate methods or overloads for each type:

public class Processor
{
    public void Process(int number)
    {
        Console.WriteLine($"Processing integer: {number}");
    }

    public void Process(string text)
    {
        Console.WriteLine($"Processing string: {text}");
    }
}
Enter fullscreen mode Exit fullscreen mode

While this works, it quickly becomes unmanageable if you need to handle multiple types like float, bool, or custom objects. The code becomes verbose, error-prone, and harder to maintain.

Enter Generics

Generics solves this problem by allowing you to write a single class or method that works with any type. Here’s how the same Processor class looks with generics:

public class Processor<T>
{
    public void Process(T item)
    {
        Console.WriteLine($"Processing: {item}");
    }
}

// Usage:
var intProcessor = new Processor<int>();
intProcessor.Process(42);

var stringProcessor = new Processor<string>();
stringProcessor.Process("Hello, world!");
Enter fullscreen mode Exit fullscreen mode

With generics, the Processor class works with any type, eliminating redundancy and ensuring type safety.


Class-Level vs Method-Level Generics

When working with generics, you can define them at the class level or method level.

  • Class-Level Generics: Use when the type parameter applies to the entire class.
  • Method-Level Generics: Use when you need type flexibility for only a specific method.

Here’s an example of both:

// Class-level generic
public class Storage<T>
{
    private List<T> _items = new List<T>();

    public void Add(T item)
    {
        _items.Add(item);
        Console.WriteLine($"{item} added to storage.");
    }
}

// Method-level generic
public class Utility
{
    public void Print<T>(T item)
    {
        Console.WriteLine($"Printing: {item}");
    }
}

// Usage:
var storage = new Storage<string>();
storage.Add("Hello, storage!");

var utility = new Utility();
utility.Print(42);
Enter fullscreen mode Exit fullscreen mode

Understanding Generic Constraints

While generics are flexible, sometimes you need to restrict what types can be used. Constraints allow you to enforce specific requirements, such as the type being a value type, a reference type, or implementing an interface.

Let’s explore the various constraints and their usage.


1. Value Type Constraint (struct)

The struct constraint ensures that the type parameter is a value type, such as int, float, or a custom struct.

public class ValueProcessor<T> where T : struct
{
    public void ProcessValue(T value)
    {
        Console.WriteLine($"Processing value: {value}");
    }
}

// Usage:
var intProcessor = new ValueProcessor<int>();
intProcessor.ProcessValue(100);

var floatProcessor = new ValueProcessor<float>();
floatProcessor.ProcessValue(99.99f);
Enter fullscreen mode Exit fullscreen mode

This is perfect for scenarios where you’re working with numeric types, enums, or other non-nullable structures.


2. Reference Type Constraint (class)

The class constraint ensures that the type parameter is a reference type, such as string or a custom object.

public class ReferenceProcessor<T> where T : class
{
    public void ProcessReference(T reference)
    {
        Console.WriteLine($"Processing reference: {reference}");
    }
}

// Usage:
var stringProcessor = new ReferenceProcessor<string>();
stringProcessor.ProcessReference("Hello, Reference!");

var objectProcessor = new ReferenceProcessor<object>();
objectProcessor.ProcessReference(new { Name = "C#", Version = 12 });
Enter fullscreen mode Exit fullscreen mode

This is ideal for working with objects that support nullability.


3. Nullable Types

If you want to allow nullable value types, you can use a nullable type in your constraint.

public class NullableProcessor<T> where T : struct
{
    public void ProcessNullable(T? value)
    {
        Console.WriteLine($"Processing nullable value: {value}");
    }
}

// Usage:
var nullableIntProcessor = new NullableProcessor<int>();
nullableIntProcessor.ProcessNullable(null);
nullableIntProcessor.ProcessNullable(42);
Enter fullscreen mode Exit fullscreen mode

This is great for handling scenarios like optional parameters or database fields that may contain null values.


4. Constructor Constraint (new())

The new() constraint ensures that the type parameter has a parameterless constructor, allowing you to create new instances.

public class InstanceCreator<T> where T : new()
{
    public T CreateInstance()
    {
        return new T();
    }
}

// Usage:
var creator = new InstanceCreator<StringBuilder>();
var instance = creator.CreateInstance();
instance.Append("Hello from InstanceCreator!");
Console.WriteLine(instance.ToString());
Enter fullscreen mode Exit fullscreen mode

This is especially useful when you need to instantiate objects dynamically.


5. Interface and Multiple Constraints

You can combine multiple constraints to enforce stricter rules.

public interface IEntity
{
    int Id { get; }
}

public class Repository<T> where T : class, IEntity, new()
{
    private readonly List<T> _items = new List<T>();

    public void Add(T item)
    {
        _items.Add(item);
        Console.WriteLine($"Added entity with ID: {item.Id}");
    }
}

// Usage:
public class Product: IEntity
{
    public int Id { get; set; }
    public string Name { get; set; }
}

var repository = new Repository<Product>();
repository.Add(new Product { Id = 1, Name = "Laptop" });
Enter fullscreen mode Exit fullscreen mode

Here, T must:

  1. Be a reference type (class).
  2. Implement the IEntity interface.
  3. Have a parameterless constructor (new()).

This combination provides structure and consistency for your types.


Conclusion

Generics and their constraints are indispensable tools for writing reusable, type-safe, and maintainable code in C#. Whether you’re enforcing type safety or structuring your code better, constraints help you achieve flexibility without sacrificing reliability.

Take some time to experiment with these constraints in your projects, and see how they can simplify your codebase!

What’s your favourite use case for generics? Let me know in the comments!

Top comments (0)