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.
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.
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.
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.
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.
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.
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.
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.
static T CreateInstance<T>() where T : new()
{
return new T();
}
Person person = CreateInstance<Person>();
class Person
{
}
Common Generic Constraints
| Constraint | Meaning | Example |
|---|---|---|
| where T : class | T must be a reference type | where T : class |
| where T : struct | T must be a value type | where T : struct |
| where T : new() | T must have an accessible parameterless constructor | where T : new() |
| where T : BaseClass | T must derive from a specific base class | where T : Animal |
| where T : Interface | T must implement an interface | where T : IDisposable |
Generic Constraints with Interfaces
An interface constraint allows a generic method to use members guaranteed by that interface.
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.
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.
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.
| Feature | Generics | object |
|---|---|---|
| Type Safety | Strong compile-time typing | Requires runtime casting |
| Reusability | High | High |
| Casting | Usually unnecessary | Often required |
| Readability | Clear type intent | Less 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.
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.
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.
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