C# Generics Explained: Create Reusable and Type-Safe Code

Generics allow C# developers to create reusable components that work with different data types while maintaining compile-time type safety. They are widely used in collections, methods, classes, interfaces, and modern .NET APIs.

What are Generics in C#?

Generics allow a type or method to work with a placeholder type that is specified when the code is used. Instead of writing separate implementations for int, string, decimal, or custom objects, one generic implementation can support many types.

CSHARP
List<int> numbers = new List<int>();
List<string> names = new List<string>();

numbers.Add(10);
names.Add("Alice");

Why Use Generics?

  • Improve type safety
  • Reduce duplicate code
  • Create reusable components
  • Avoid unnecessary type conversions
  • Improve code readability
  • Build flexible libraries and APIs

Generic Methods

A generic method uses a type parameter that is defined between angle brackets. The compiler can often infer the type automatically from the arguments.

CSHARP
static void PrintValue<T>(T value)
{
    Console.WriteLine(value);
}

PrintValue(100);
PrintValue("Hello");
PrintValue(25.5);

Generic Classes

A generic class can operate on a type specified when an object is created. This is useful for reusable containers, repositories, services, and data structures.

CSHARP
class Box<T>
{
    public T Value { get; set; }

    public Box(T value)
    {
        Value = value;
    }
}

Box<int> numberBox = new Box<int>(100);
Box<string> textBox = new Box<string>("Hello");

Generic Interfaces

Interfaces can also use generic type parameters. Generic interfaces are commonly used to define reusable contracts.

CSHARP
interface IRepository<T>
{
    T GetById(int id);
    void Add(T item);
}

class UserRepository : IRepository<User>
{
    public User GetById(int id)
    {
        return new User(id);
    }

    public void Add(User item)
    {
        Console.WriteLine($"Added user {item.Id}");
    }
}

class User
{
    public int Id { get; set; }

    public User(int id)
    {
        Id = id;
    }
}

Generic Collections

The .NET collection library makes extensive use of generics. Generic collections provide strong typing and avoid many casts required by older non-generic collections.

CSHARP
List<string> names = new();
Dictionary<int, string> users = new();
Queue<int> queue = new();
Stack<int> stack = new();

names.Add("Alice");
users[1] = "Alice";
queue.Enqueue(10);
stack.Push(20);

Generic Type Parameters

Type parameters are commonly named T, TKey, TValue, TItem, or another descriptive name. They represent the type supplied by the caller.

CSHARP
class Pair<TKey, TValue>
{
    public TKey Key { get; }
    public TValue Value { get; }

    public Pair(TKey key, TValue value)
    {
        Key = key;
        Value = value;
    }
}

var pair = new Pair<int, string>(1, "Alice");

Multiple Generic Parameters

A generic type or method can define more than one type parameter when multiple types are required.

CSHARP
static void DisplayPair<TFirst, TSecond>(
    TFirst first,
    TSecond second)
{
    Console.WriteLine(first);
    Console.WriteLine(second);
}

DisplayPair(10, "Hello");

Generic Constraints

Generic constraints restrict the types that can be used with a type parameter. Constraints allow generic code to safely rely on certain capabilities.

CSHARP
static T CreateInstance<T>() where T : new()
{
    return new T();
}

Person person = CreateInstance<Person>();

class Person
{
}

Common Generic Constraints

ConstraintMeaningExample
where T : classT must be a reference typewhere T : class
where T : structT must be a value typewhere T : struct
where T : new()T must have an accessible parameterless constructorwhere T : new()
where T : BaseClassT must derive from a specific base classwhere T : Animal
where T : InterfaceT must implement an interfacewhere T : IDisposable

Generic Constraints with Interfaces

An interface constraint allows a generic method to use members guaranteed by that interface.

CSHARP
static void Save<T>(T item)
    where T : IDisposable
{
    item.Dispose();
}

class Resource : IDisposable
{
    public void Dispose()
    {
        Console.WriteLine("Disposed");
    }
}

Generic Methods with Return Values

Generic methods can return the same type they receive or transform data while preserving type information.

CSHARP
static T GetFirst<T>(List<T> items)
{
    return items[0];
}

List<string> names = new() { "Alice", "Bob" };

string first = GetFirst(names);

Generics and Type Safety

Generics provide compile-time type checking. This helps catch incompatible values before the program runs.

CSHARP
List<int> numbers = new();

numbers.Add(10);
numbers.Add(20);

// numbers.Add("Hello");
// Compile-time error

Generics vs object

Using object can store values of different types, but it often requires casting when retrieving them. Generics preserve the specific type and usually provide a cleaner and safer design.

FeatureGenericsobject
Type SafetyStrong compile-time typingRequires runtime casting
ReusabilityHighHigh
CastingUsually unnecessaryOften required
ReadabilityClear type intentLess explicit

Generic Delegates

Generic delegates allow delegate definitions to work with different data types. The .NET framework provides several generic delegates, including Func and Action.

CSHARP
Func<int, int> square = number => number * number;

Action<string> print = message =>
{
    Console.WriteLine(message);
};

Console.WriteLine(square(5));
print("Hello");

Generics and LINQ

LINQ heavily uses generics to provide type-safe operations over collections and other data sources.

CSHARP
List<int> numbers = new() { 1, 2, 3, 4, 5 };

IEnumerable<int> evenNumbers = numbers
    .Where(number => number % 2 == 0);

foreach (int number in evenNumbers)
{
    Console.WriteLine(number);
}

Generic Repository Example

A generic repository can provide common data-access operations for multiple entity types. In real applications, repository design should be chosen based on the application's architecture rather than applied automatically.

CSHARP
interface IRepository<T>
{
    void Add(T entity);
    T? Find(int id);
}

class Repository<T> : IRepository<T>
{
    private readonly List<T> items = new();

    public void Add(T entity)
    {
        items.Add(entity);
    }

    public T? Find(int id)
    {
        return default;
    }
}

Common Mistakes to Avoid

  • Using generics when a simple concrete type is clearer
  • Adding unnecessary type parameters
  • Using weak constraints that do not communicate requirements
  • Making generic APIs unnecessarily complicated
  • Using object when a generic solution provides better type safety
  • Ignoring readability in favor of excessive abstraction

Generic Programming Best Practices

  • Use generics when the same logic genuinely works across multiple types
  • Choose descriptive type parameter names when T is not sufficient
  • Add constraints when the implementation requires specific capabilities
  • Prefer type-safe generic collections
  • Keep generic APIs simple and predictable
  • Avoid unnecessary abstraction

Real-World Applications

  • Collections and data structures
  • Repository patterns
  • Reusable services
  • LINQ operations
  • Caching components
  • API response wrappers
  • Utility libraries

Practice Exercises

  • Create a generic Box class
  • Build a generic Swap method
  • Create a generic Pair
  • Practice generic constraints
  • Build a generic repository interface
  • Create a generic collection helper
  • Write a method that works with multiple numeric or reference types

Conclusion

Generics are one of the most important features of C#. They allow developers to create reusable and type-safe code without duplicating implementations for different data types. From List and Dictionary to custom classes and methods, generics are fundamental to modern .NET development.

Note: Note: Use generics when they make code more reusable and type-safe. If a generic design becomes harder to understand than a simple concrete implementation, prefer the simpler solution.