C# Generics Explained — Type Safety, Constraints and Real-World Patterns
- Generics move type errors from runtime (InvalidCastException crashes) to compile time — the single biggest reliability win they offer over object-based code.
- The where keyword is not optional decoration — it's the mechanism that lets the compiler unlock interface members on T. Without a constraint, you're back to writing object-level code inside a generic wrapper.
- Covariance (out T) means a producer of Dogs can stand in as a producer of Animals. Contravariance (in T) means a consumer of Animals can stand in as a consumer of Dogs. Both only work on interfaces and delegates, not classes.
Imagine you own a vending machine that only accepts one type of coin — you'd need a separate machine for quarters, dimes, and nickels. That's what non-generic code feels like. Generics let you build ONE vending machine with a adjustable slot that you lock to a specific coin type when you need it. The machine's logic stays the same; you just tell it upfront what type of coin it'll be dealing with. No guessing, no fumbling, no wrong coins jamming the mechanism.
Every production C# codebase leans on generics constantly — List<T>, Dictionary<TKey, TValue>, Task<T> — but most developers use them without truly understanding what's happening under the hood or why they were designed that way. When you understand generics deeply, you stop writing duplicate code for every type you encounter and start writing components that are both flexible and bulletproof at compile time. That matters in real teams where code gets reused, refactored, and extended by people who weren't there when it was written.
Before generics arrived in C# 2.0, developers used ArrayList and cast everything to and from object. This meant the compiler couldn't catch type mismatches — a bug that should fail at compile time instead exploded at runtime with an InvalidCastException. Generics solve this by letting you parameterise a class or method with a type placeholder. The compiler fills in that placeholder when the code is used, giving you full type checking without any runtime overhead from casting.
By the end of this article you'll understand why generics exist at a language-design level, how to write your own generic classes and methods with constraints, how to combine generics with interfaces for real architectural patterns, and how to avoid the three mistakes that trip up even experienced developers. You'll also walk away with sharp answers to the interview questions that separate juniors from mid-level engineers.
The Problem Generics Solve — Why object-Based Code Is a Time Bomb
Before you can appreciate generics, you need to feel the pain they eliminate. The classic approach before C# 2.0 was to write everything against the object type — the root of all C# types. It seemed clever: one method handles everything. In practice, it was a maintenance nightmare.
Every value you pulled out had to be cast back to its real type. The compiler had no idea what was actually in your collection. You could put a string in a list of integers and the code would compile fine — it would just blow up at runtime when some unsuspecting method tried to call .ToString() on what it assumed was an int.
There's also a performance cost. Value types like int and double must be 'boxed' — wrapped in a heap-allocated object — to be stored as object, then 'unboxed' when retrieved. In tight loops processing thousands of items, this garbage pressure is measurable.
Generics eliminate both problems. You declare the type once at the use site, the compiler enforces it everywhere, and value types are stored directly without boxing. You get the flexibility of writing reusable code AND the safety of a strongly-typed language — not a trade-off between them.
using System; using System.Collections; using System.Collections.Generic; class BeforeAndAfterGenerics { static void Main() { // ── BEFORE GENERICS ────────────────────────────────────────── // ArrayList stores everything as 'object' — no type safety. var legacyScores = new ArrayList(); legacyScores.Add(95); // int gets BOXED onto the heap legacyScores.Add(87); legacyScores.Add("oops"); // compiler is totally fine with this string! // This line compiles cleanly but throws InvalidCastException at runtime // because "oops" cannot be unboxed as an int. try { foreach (object item in legacyScores) { int score = (int)item; // UNBOXING — risky cast every single time Console.WriteLine($"Legacy score: {score}"); } } catch (InvalidCastException ex) { Console.WriteLine($"Runtime crash: {ex.Message}"); } Console.WriteLine(); // ── AFTER GENERICS ─────────────────────────────────────────── // List<int> only accepts ints — enforced at COMPILE TIME, not runtime. var modernScores = new List<int>(); modernScores.Add(95); // stored directly, no boxing modernScores.Add(87); // modernScores.Add("oops"); // ← COMPILE ERROR: cannot convert string to int // The bug is caught before you ever run the code. foreach (int score in modernScores) // no cast needed — type is already known { Console.WriteLine($"Modern score: {score}"); } } }
Legacy score: 87
Runtime crash: Specified cast is not valid.
Modern score: 95
Modern score: 87
Writing Your Own Generic Class — Building a Type-Safe Result Wrapper
The best way to deeply understand generics is to build something you'd actually use in production. A Result<T> wrapper is a perfect example — it represents either a successful value or an error, without throwing exceptions for expected failure cases. This pattern is common in functional-leaning C# codebases and in every API layer that needs to communicate failure without polluting control flow with exceptions.
The T in Result<T> is a type parameter — a placeholder that the compiler replaces with a concrete type when you instantiate the class. You can name it anything, but T is the convention for a single generic type. TKey and TValue are conventional for two parameters, as you see in Dictionary.
Notice how the class is defined once, but can hold a string result, an int result, or a complex User object result. The internal logic — storing the value, checking success, returning errors — is written exactly once. That's the core promise of generics: write the shape of the behaviour, defer the type decision to the caller.
The private constructor pattern combined with static factory methods also means you can never accidentally create a Result<T> in an invalid state — a bonus architectural win that generics enable cleanly.
using System; // A generic Result type — T is the type of the success value. // This is a common production pattern for representing operation outcomes // without relying on exceptions for expected failures. public class Result<T> { // The actual value when the operation succeeds. // Nullable because on failure there is no meaningful value. public T? Value { get; } // Human-readable error when the operation fails. public string? ErrorMessage { get; } // Tells callers which state we're in. public bool IsSuccess { get; } // Private constructor — callers must use the factory methods below. // This prevents creating a Result in a half-baked state. private Result(T? value, string? error, bool isSuccess) { Value = value; ErrorMessage = error; IsSuccess = isSuccess; } // Factory method for the happy path. // The type parameter T flows through from the class to this method. public static Result<T> Success(T value) => new Result<T>(value, null, true); // Factory method for the failure path. // No value — just a descriptive error message. public static Result<T> Failure(string errorMessage) => new Result<T>(default, errorMessage, false); // A convenient way to pattern-match on the result. public override string ToString() => IsSuccess ? $"Success: {Value}" : $"Failure: {ErrorMessage}"; } // ── Simulates a real service method ────────────────────────────────────────── public class UserService { // Returns Result<string> — success gives us a username, failure gives us why. // No exceptions thrown for normal 'user not found' cases. public Result<string> FindUsername(int userId) { // Simulate a simple in-memory lookup if (userId == 42) return Result<string>.Success("ada.lovelace"); // Business-rule failure — not an exception, just a typed failure result return Result<string>.Failure($"No user found with ID {userId}"); } } class Program { static void Main() { var service = new UserService(); // Try a valid user ID Result<string> found = service.FindUsername(42); if (found.IsSuccess) Console.WriteLine($"Found user: {found.Value}"); // Value is string — no cast! else Console.WriteLine($"Error: {found.ErrorMessage}"); // Try an invalid user ID Result<string> notFound = service.FindUsername(99); Console.WriteLine(notFound); // calls our ToString() override // The SAME Result<T> class works for any type — here we use int. // We didn't write a new class — we just changed T. Result<int> calculationResult = Result<int>.Success(1337); Console.WriteLine($"Calculation gave us: {calculationResult.Value + 1}"); } }
Failure: No user found with ID 99
Calculation gave us: 1338
Generic Constraints — Teaching the Compiler What T Can Do
Here's the most powerful — and most misunderstood — feature of C# generics: constraints. Without them, T is a complete mystery to the compiler. It could be anything, so you can only call the methods that every single type in C# shares: ToString(), GetHashCode(), and Equals(). That's a pretty short list.
Constraints let you tell the compiler 'T is guaranteed to be at least this kind of thing'. Once you add a constraint, the compiler unlocks every method and property defined by that constraint. You get IntelliSense, type checking, and zero casting.
The where keyword is how you add constraints. The most common ones are: where T : class (T must be a reference type), where T : struct (T must be a value type), where T : new() (T must have a parameterless constructor), and where T : ISomeInterface (T must implement that interface). You can combine multiple constraints on the same type parameter.
The interface constraint is the one you'll use most in real codebases. It's how you write algorithms that are generic over behaviour, not type. A sorting method that works on anything sortable, a repository that works on anything with an ID — these are built with interface constraints.
using System; using System.Collections.Generic; // Define a contract: anything that has an Id and can describe itself. // This is the interface we'll use as a constraint. public interface IEntity { int Id { get; } string Describe(); } // Two completely different entity types — both satisfy IEntity. public class Product : IEntity { public int Id { get; init; } public string Name { get; init; } = string.Empty; public decimal Price { get; init; } // Required by IEntity — gives a human-readable description. public string Describe() => $"Product #{Id}: {Name} at ${Price:F2}"; } public class Employee : IEntity { public int Id { get; init; } public string FullName { get; init; } = string.Empty; public string Department { get; init; } = string.Empty; public string Describe() => $"Employee #{Id}: {FullName} in {Department}"; } // A generic repository constrained to IEntity. // The 'where T : IEntity' tells the compiler that T definitely has .Id and .Describe(). // Without this constraint, accessing .Id would be a compile error. public class InMemoryRepository<T> where T : IEntity { private readonly Dictionary<int, T> _store = new(); public void Save(T entity) { // We can access entity.Id directly — the constraint guarantees it exists. _store[entity.Id] = entity; Console.WriteLine($"Saved: {entity.Describe()}"); // .Describe() also guaranteed } public T? FindById(int id) { _store.TryGetValue(id, out T? entity); return entity; } public void PrintAll() { foreach (var entry in _store.Values) Console.WriteLine($" → {entry.Describe()}"); } } // A standalone generic method with TWO constraints on different type parameters. // TSource must implement IEntity. TResult must have a public parameterless constructor. // This lets us project entities into a new type we can construct on the fly. public static class EntityMapper { public static List<TResult> MapDescriptions<TSource, TResult>( IEnumerable<TSource> entities, Func<TSource, TResult> mapFunc) where TSource : IEntity // constraint 1: must have IEntity members where TResult : new() // constraint 2: must be constructable without args { var results = new List<TResult>(); foreach (var entity in entities) { // mapFunc is the caller's logic — we just call it safely results.Add(mapFunc(entity)); } return results; } } class Program { static void Main() { // ── Product repository ─────────────────────────────────────── var productRepo = new InMemoryRepository<Product>(); productRepo.Save(new Product { Id = 1, Name = "Mechanical Keyboard", Price = 149.99m }); productRepo.Save(new Product { Id = 2, Name = "USB-C Hub", Price = 49.95m }); Console.WriteLine("\nAll products:"); productRepo.PrintAll(); // ── Employee repository — SAME generic class, different T ──── var employeeRepo = new InMemoryRepository<Employee>(); employeeRepo.Save(new Employee { Id = 101, FullName = "Grace Hopper", Department = "Engineering" }); employeeRepo.Save(new Employee { Id = 102, FullName = "Alan Turing", Department = "Research" }); Console.WriteLine("\nAll employees:"); employeeRepo.PrintAll(); // ── Look up by ID — returns the exact type, no cast needed ─── Product? keyboard = productRepo.FindById(1); Console.WriteLine($"\nFound: {keyboard?.Describe() ?? "not found"}"); } }
Saved: Product #2: USB-C Hub at $49.95
All products:
→ Product #1: Mechanical Keyboard at $149.99
→ Product #2: USB-C Hub at $49.95
Saved: Employee #101: Grace Hopper in Engineering
Saved: Employee #102: Alan Turing in Research
All employees:
→ Employee #101: Grace Hopper in Engineering
→ Employee #102: Alan Turing in Research
Found: Product #1: Mechanical Keyboard at $149.99
Generic Interfaces and Covariance — The Pattern Behind LINQ and IEnumerable
Once you're comfortable writing generic classes, the next level is understanding generic interfaces and variance — specifically covariance (out) and contravariance (in). These aren't academic features; they're why you can assign a List<string> to an IEnumerable<string> variable, and why LINQ works seamlessly across all collection types.
Covariance means a generic type with a more derived type argument can be treated as a generic type with a base type. So IEnumerable<string> can be assigned to IEnumerable<object> because string derives from object, and IEnumerable<T> is declared with out T — meaning T is only ever produced (returned), never consumed. The out keyword is what tells the compiler it's safe to widen the type.
Contravariance is the reverse — Action<object> can be assigned to Action<string> because Action<T> uses in T, meaning T is only consumed (taken as input). If you can handle any object, you can certainly handle a string.
In practice, you'll consume covariant and contravariant interfaces far more often than you'll write them. But knowing WHY IEnumerable<T> uses out T explains why so much LINQ code just works, and it's the kind of deep knowledge that separates engineers who use the framework from those who understand it.
using System; using System.Collections.Generic; // A covariant generic interface — 'out T' means T is only ever RETURNED, never taken in. // This is safe to widen: if you produce Cats, a caller expecting Animals is perfectly happy. public interface IProducer<out T> { T Produce(); // T appears only in output position — safe for covariance // void Consume(T item); // ← would be a COMPILE ERROR with 'out T' // You can't consume a T in a covariant interface } // A contravariant generic interface — 'in T' means T is only ever TAKEN IN, never returned. // Safe to narrow: if you can process any Animal, you can certainly process a Cat. public interface IConsumer<in T> { void Consume(T item); // T appears only in input position — safe for contravariance // T Produce(); // ← would be a COMPILE ERROR with 'in T' } // A simple class hierarchy for demonstration public class Animal { public string Name { get; init; } = string.Empty; public virtual string Sound() => "..."; } public class Dog : Animal { public override string Sound() => "Woof"; } // A producer of Dogs public class DogProducer : IProducer<Dog> { public Dog Produce() => new Dog { Name = "Rex" }; } // A consumer that handles any Animal — including Dogs public class AnimalLogger : IConsumer<Animal> { public void Consume(Animal animal) => Console.WriteLine($"Logging animal: {animal.Name} says {animal.Sound()}"); } class Program { static void Main() { // ── COVARIANCE ─────────────────────────────────────────────── // DogProducer implements IProducer<Dog>. // Because IProducer<T> is covariant (out T), we can assign it to IProducer<Animal>. // Dog IS-A Animal, and since we only ever READ T, this is completely safe. IProducer<Dog> dogProducer = new DogProducer(); IProducer<Animal> animalProducer = dogProducer; // covariant assignment — works! Animal producedAnimal = animalProducer.Produce(); Console.WriteLine($"Produced: {producedAnimal.Name} says {producedAnimal.Sound()}"); // ── CONTRAVARIANCE ─────────────────────────────────────────── // AnimalLogger implements IConsumer<Animal>. // Because IConsumer<T> is contravariant (in T), we can assign it to IConsumer<Dog>. // If it can handle any Animal, it can definitely handle a Dog. IConsumer<Animal> animalConsumer = new AnimalLogger(); IConsumer<Dog> dogConsumer = animalConsumer; // contravariant assignment — works! dogConsumer.Consume(new Dog { Name = "Buddy" }); // ── REAL-WORLD COVARIANCE: IEnumerable<T> ─────────────────── // IEnumerable<T> is declared as IEnumerable<out T> in the BCL. // That's why you can assign List<string> to IEnumerable<object>. List<string> dogNames = new() { "Max", "Bella", "Charlie" }; IEnumerable<object> objectNames = dogNames; // works because IEnumerable is covariant Console.WriteLine("\nDog names as objects:"); foreach (object name in objectNames) Console.WriteLine($" {name}"); // each is a string at runtime, stored as object } }
Logging animal: Buddy says Woof
Dog names as objects:
Max
Bella
Charlie
| Aspect | Non-Generic (object / ArrayList) | Generic (List<T>, custom class<T>) |
|---|---|---|
| Type Safety | Runtime — errors surface as InvalidCastException when executed | Compile-time — type mismatch caught before the program ever runs |
| Casting Required | Yes — every read requires an explicit (Type) cast | No — the compiler already knows the type, no cast needed |
| Boxing of Value Types | Yes — int/double are boxed to heap on every write | No — value types stored directly, zero boxing overhead |
| Code Reuse | One class handles all types via object, but unsafely | One class handles all types via T, fully type-checked |
| IntelliSense Support | Minimal — IDE only knows it's object | Full — IDE knows the real type, shows all members |
| Readability | Unclear — you must hunt for cast comments to know the type | Self-documenting — List<Invoice> tells you exactly what's inside |
| Performance (hot paths) | Degraded — boxing/unboxing generates garbage for GC | Optimal — no heap allocation overhead for value types |
🎯 Key Takeaways
- Generics move type errors from runtime (InvalidCastException crashes) to compile time — the single biggest reliability win they offer over object-based code.
- The where keyword is not optional decoration — it's the mechanism that lets the compiler unlock interface members on T. Without a constraint, you're back to writing object-level code inside a generic wrapper.
- Covariance (out T) means a producer of Dogs can stand in as a producer of Animals. Contravariance (in T) means a consumer of Animals can stand in as a consumer of Dogs. Both only work on interfaces and delegates, not classes.
- The Result<T> pattern is a production-grade use of generics that replaces exception-driven control flow for expected failure cases — once you see it, you'll want it in every codebase you touch.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QWhat is the difference between a generic constraint 'where T : class' and 'where T : IMyInterface', and when would you choose one over the other?
- QWhy can you assign List<string> to IEnumerable<object>, but not to List<object>? What is this feature called and how does the C# compiler enforce it?
- QIf you have a method that needs to work on any type T that can be compared for ordering, what constraint would you add, and what interface does that constraint typically reference?
Frequently Asked Questions
What does the T in C# generics actually mean?
T is just a conventional name for a type parameter — a placeholder the compiler replaces with a real type when you use the class or method. You could name it anything (TItem, TEntity), but T is the single-parameter convention. It carries no special meaning by itself; its behaviour is entirely determined by any constraints you add with the where keyword.
Can I use multiple type parameters in a single generic class?
Absolutely. Dictionary<TKey, TValue> is the most famous example in the BCL. You declare them as class MyPair<TFirst, TSecond> and can add separate constraints on each: where TFirst : class where TSecond : struct. Each type parameter is independent — callers supply both when they instantiate the class.
Is there a performance difference between generic collections and non-generic ones?
Yes, and it's most significant for value types. A List<int> stores integers directly in contiguous memory. An ArrayList stores each integer boxed as an object on the heap. In a tight loop processing millions of integers, the non-generic version generates enormous garbage collection pressure. For reference types the difference is smaller, but the compile-time safety of generics is still worth it regardless of performance.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.