C# Generics Explained — Type Safety, Constraints and Real-World Patterns
Every production C# codebase leans on generics constantly — List
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
The T in Result
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
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
Covariance means a generic type with a more derived type argument can be treated as a generic type with a base type. So IEnumerable
Contravariance is the reverse — Action
In practice, you'll consume covariant and contravariant interfaces far more often than you'll write them. But knowing WHY IEnumerable
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 |
|---|---|---|
| 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 |
| 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
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
- ✕Mistake 1: Assuming T can do anything without constraints — Symptom: compile error 'T does not contain a definition for X' when you try to call a method on T — Fix: Add a where T : IYourInterface constraint so the compiler knows what methods T guarantees. Without a constraint, T is just object from the compiler's perspective.
- ✕Mistake 2: Using a generic class when a generic method is all you need — Symptom: an entire class is instantiated just to call one method that uses T — Fix: If only one method needs to be generic, make that method generic instead of the whole class. public static T DeepClone
(T source) is far cleaner than instantiating a Cloner just to call .Clone(). Reserve generic classes for when state must be stored per-T. - ✕Mistake 3: Confusing covariance with inheritance and getting an InvalidCastException — Symptom: you try to cast List
to List thinking 'Dog is an Animal so this should work', and get a runtime exception or compile error — Fix: List is invariant (no out or in keyword), so List is NOT assignable to List . Use IEnumerable instead (which IS covariant). This is intentional — if List secretly held a List , you could call .Add(new Cat()) and corrupt it.
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
to IEnumerable - 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
Is there a performance difference between generic collections and non-generic ones?
Yes, and it's most significant for value types. A List
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.