
Master C# generics with this complete tutorial. Learn generic classes, methods, constraints & real-world examples. Start writing type-safe C# code today!
If you've ever used List<T> or Dictionary<TKey, TValue>, you've already used C# generics — but do you know how to build your own? In this C# generics tutorial, you'll learn how generics let you write reusable, type-safe code once and use it with any type, without sacrificing performance or compile-time safety. We'll cover generic classes, generic methods, constraints, and real-world use cases like repositories and result types that you'll actually use in production applications.
Whether you're a beginner wondering what <T> means, or an intermediate developer looking for generics best practices, this guide walks through everything with runnable code examples.
What Are Generics in C#?
Generics, introduced in C# 2.0, allow you to define classes, methods, interfaces, and delegates with a type parameter — a placeholder for a type that's specified later by the caller. Instead of writing separate code for int, string, and Customer, you write it once with T and the compiler fills in the concrete type.
Here's the classic motivating example. Before generics, developers used object-based collections like ArrayList:
// The old, dangerous way (pre-generics)
var list = new System.Collections.ArrayList();
list.Add(42);
list.Add("hello"); // Compiles fine — but is it what you wanted?
int number = (int)list[1]; // 💥 InvalidCastException at RUNTIME
The problem: the compiler can't help you. Errors show up at runtime, in production, at 2 AM. Compare that with a generic collection:
// The type-safe way with generics
var numbers = new List<int>();
numbers.Add(42);
// numbers.Add("hello"); // ❌ Compile-time error — caught before you even run
int number = numbers[0]; // No cast needed, no runtime surprise
Why Generics Beat the Alternatives
- Type safety: Mistakes are caught at compile time, not runtime. This is the single biggest reason generics exist.
- Performance: With value types like
int, generics avoid boxing — the costly process of wrapping a value type in an object on the heap.List<int>stores raw integers;ArrayListboxes every single one. - Code reuse: One implementation works for every type, so there's no copy-pasted
IntStack,StringStack,CustomerStack. - Cleaner code: No casts scattered everywhere, and IntelliSense knows exactly what type you're working with.
How to Create a Generic Class in C#
Let's build a generic class from scratch. A type parameter goes in angle brackets after the class name:
public class Box<T>
{
private T _content;
public void Put(T item) => _content = item;
public T Take() => _content;
}
// Usage — T becomes a concrete type at the call site
var intBox = new Box<int>();
intBox.Put(100);
int value = intBox.Take(); // 100, no cast
var stringBox = new Box<string>();
stringBox.Put("C# generics are great");
string text = stringBox.Take();
Why this matters: when you write Box<int>, the .NET runtime generates a specialized version of the class for int. For value types, each gets its own optimized code; for reference types, one shared implementation is reused. You get the flexibility of object with the speed and safety of hand-written, type-specific code.
You can use multiple type parameters, too — just like the built-in Dictionary<TKey, TValue>:
public class Pair<TFirst, TSecond>
{
public TFirst First { get; }
public TSecond Second { get; }
public Pair(TFirst first, TSecond second)
{
First = first;
Second = second;
}
}
var coordinates = new Pair<double, double>(51.5074, -0.1278);
var userScore = new Pair<string, int>("alice", 2450);
Generic Methods in C#
You don't need a generic class to use generics — any method can declare its own type parameters:
public static class ArrayHelper
{
public static void Swap<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
public static T FirstOrDefault<T>(T[] items, Func<T, bool> predicate)
{
foreach (T item in items)
{
if (predicate(item))
return item;
}
return default; // default(T): 0 for int, null for reference types
}
}
// The compiler INFERS the type — you rarely need to specify it
int x = 1, y = 2;
ArrayHelper.Swap(ref x, ref y); // Swap<int> inferred
string[] names = { "Ann", "Bob", "Carla" };
string match = ArrayHelper.FirstOrDefault(names, n => n.StartsWith("B"));
Notice type inference: you call Swap(ref x, ref y), not Swap<int>(ref x, ref y). The compiler figures out T from the arguments. This is exactly how LINQ methods like Where and Select feel so seamless — they're all generic methods under the hood.
Generic Constraints: Telling the Compiler What T Can Do
By default, the compiler knows almost nothing about T, so you can only call object members on it. Constraints (the where clause) let you promise what T will be, unlocking its members:
public class Repository<T> where T : class, IEntity, new()
{
private readonly List<T> _items = new();
public T Create()
{
var item = new T(); // allowed because of new()
item.CreatedAt = DateTime.UtcNow; // allowed because of IEntity
_items.Add(item);
return item;
}
public T? FindById(int id) =>
_items.FirstOrDefault(x => x.Id == id);
}
public interface IEntity
{
int Id { get; set; }
DateTime CreatedAt { get; set; }
}
The most common constraints, in order of how often you'll use them:
where T : class— T must be a reference type.where T : struct— T must be a value type (used heavily withNullable<T>).where T : ISomeInterface— T must implement the interface. This is the workhorse constraint.where T : SomeBaseClass— T must inherit from a base class.where T : new()— T must have a public parameterless constructor, so you can writenew T().where T : notnull— T must be non-nullable (great with nullable reference types enabled).
Why constraints matter: they're not a limitation — they're documentation and capability combined. A constraint tells callers exactly what types are acceptable, and tells the compiler exactly what operations are legal. When you find yourself casting T to something inside a generic method, that's a signal you should add a constraint instead.
Real-World Use Cases for C# Generics
1. The Generic Repository Pattern
Almost every business application talks to a database, and the CRUD operations look identical for every entity. Generics let you write them once. This is one of the most searched-for generics patterns, and Entity Framework Core's DbSet<T> is built on exactly this idea:
public interface IRepository<T> where T : class, IEntity
{
Task<T?> GetByIdAsync(int id);
Task<IReadOnlyList<T>> GetAllAsync();
Task AddAsync(T entity);
Task DeleteAsync(int id);
}
public class EfRepository<T> : IRepository<T> where T : class, IEntity
{
private readonly AppDbContext _db;
public EfRepository(AppDbContext db) => _db = db;
public Task<T?> GetByIdAsync(int id) =>
_db.Set<T>().FirstOrDefaultAsync(e => e.Id == id);
public async Task<IReadOnlyList<T>> GetAllAsync() =>
await _db.Set<T>().AsNoTracking().ToListAsync();
public async Task AddAsync(T entity)
{
_db.Set<T>().Add(entity);
await _db.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
var entity = await GetByIdAsync(id);
if (entity is not null)
{
_db.Set<T>().Remove(entity);
await _db.SaveChangesAsync();
}
}
}
// Register once in DI, use with ANY entity:
// services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));
2. A Generic Result Type for Error Handling
Instead of throwing exceptions for expected failures (invalid input, not-found), many teams use a Result<T> type — a pattern popularized in functional programming and now common in modern C# APIs:
public class Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public string? Error { get; }
private Result(bool ok, T? value, string? error)
=> (IsSuccess, Value, Error) = (ok, value, error);
public static Result<T> Success(T value) => new(true, value, null);
public static Result<T> Failure(string error) => new(false, default, error);
}
public Result<Order> PlaceOrder(Cart cart)
{
if (cart.Items.Count == 0)
return Result<Order>.Failure("Cart is empty.");
var order = new Order(cart.Items);
return Result<Order>.Success(order);
}
// The caller is FORCED to consider failure — no forgotten try/catch
var result = PlaceOrder(cart);
if (result.IsSuccess)
Console.WriteLine($"Order placed: {result.Value!.Id}");
else
Console.WriteLine($"Failed: {result.Error}");
3. Generic Caching Helper
public class TypedCache
{
private readonly IMemoryCache _cache;
public TypedCache(IMemoryCache cache) => _cache = cache;
public async Task<T> GetOrCreateAsync<T>(
string key,
Func<Task<T>> factory,
TimeSpan ttl)
{
if (_cache.TryGetValue(key, out T? cached) && cached is not null)
return cached;
T value = await factory();
_cache.Set(key, value, ttl);
return value;
}
}
// Works for any type without casts at the call site
var products = await cache.GetOrCreateAsync(
"products:featured",
() => productService.GetFeaturedAsync(),
TimeSpan.FromMinutes(10));
C# Generics Best Practices
- Use meaningful type parameter names.
Tis fine for a single parameter, but prefer descriptive names with aTprefix when there are several:TKey,TValue,TResult,TEntity. - Prefer constraints over casting. If your generic code needs
Tto do something, express it withwhere T : ISomethingrather than runtime casts and type checks. - Use
defaultcorrectly.default(T)isnullfor reference types but zero-initialized for value types. Combine with nullable annotations (T?) so the compiler warns callers about possible nulls. - Don't over-genericize. If a class only ever works with one type, generics add complexity for nothing. Introduce
Twhen you have (or realistically foresee) at least two concrete uses. - Know your variance. Interfaces can declare
out T(covariant, likeIEnumerable<out T>) andin T(contravariant, likeIComparer<in T>). That's why you can assignIEnumerable<string>toIEnumerable<object>— but notList<string>toList<object>.
Common Pitfalls to Avoid
- Assuming
List<Cat>is aList<Animal>. Generic classes are invariant. If this were allowed, you could add aDogto a list of cats. UseIEnumerable<Animal>for read-only covariant scenarios instead. - Comparing
Tvalues with==. Unconstrained type parameters don't support==. UseEqualityComparer<T>.Default.Equals(a, b), which works for both value and reference types and respects custom equality. - Static fields in generic classes surprise people.
Counter<int>.CountandCounter<string>.Countare separate fields — each closed generic type gets its own statics. - Reflection over generics gets tricky.
typeof(List<>)(open generic) andtypeof(List<int>)(closed generic) are different types. If you're doing DI registration or serialization with reflection, you'll needMakeGenericType. - Overusing
new(). Thenew()constraint uses reflection-like activation under the hood in some scenarios and can't pass constructor arguments. Consider accepting aFunc<T>factory delegate instead.
Conclusion: Key Takeaways on C# Generics
C# generics are the foundation of modern .NET — collections, LINQ, async (Task<T>), dependency injection, and Entity Framework all depend on them. Mastering them changes how you design code, not just how you consume libraries.
- Generics give you compile-time type safety, better performance (no boxing), and genuine code reuse from a single implementation.
- Create generic classes with
class Name<T>and generic methods withMethod<T>(...)— the compiler usually infersTfor you. - Use constraints (
where T : ...) to unlock members onTand document what types your code accepts. - Reach for proven generic patterns: repositories (
IRepository<T>), result types (Result<T>), and caching helpers. - Watch out for invariance,
default(T)semantics, and per-type static fields.
The best way to internalize this C# generics tutorial is to refactor real code: find a class in your project that exists in two nearly-identical versions for different types, and merge them into one generic implementation. You'll immediately feel the payoff in less code, fewer bugs, and a compiler that has your back.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment