Generics in C
A deep-dive walkthrough of generics in C# — covering the problem they solve versus object-based and non-generic collections, generic classes/methods/interfaces/delegates, type parameter constraints, variance (in/out) revisited in depth, how generics are compiled and why that matters for performance, generic type inference, and the design judgment calls that separate a genuinely reusable generic API from an over-engineered one.
Table of Contents
- Introduction
- The Problem Generics Solve
- Generic Classes
- Generic Methods
- Type Parameter Naming and Multiple Type Parameters
- Constraints: Narrowing What a Type Parameter Can Be
- Generic Interfaces and Delegates
- Variance Revisited:
inandoutin Depth - How Generics Are Actually Compiled
- Generic Type Inference
default(T)and the Problem of Not Knowing What T Is- Generic Collections in the .NET Framework
- When Generics Are the Wrong Tool
- Common Pitfalls
- Quick Reference Table
- Conclusion
Introduction
Generics let you write a class, method, interface, or delegate whose exact data type is a parameter, filled in later by whatever code actually uses it — List<T> is the canonical example: it's written once, entirely without knowing whether T will end up being int, string, Customer, or anything else, and yet a List<int> and a List<string> are each fully type-safe, with no casting and no risk of accidentally putting a string into a List<int>. This guide walks through generics in depth: the concrete problem they solve relative to object-based code, the full range of places C# lets you use a type parameter (classes, methods, interfaces, delegates), constraints that narrow what a type parameter is allowed to be, variance revisited from this series' Interfaces guide in more depth, and how generics are actually compiled — a detail that explains both their performance characteristics and several of their more surprising behaviors.
class Box<T> → a box that holds SOME type T, decided per instance
Box<int> intBox → T is int for this specific instance — type-safe, no casting
Box<string> stringBox → T is string for THIS instance — a completely independent type
from Box<int>, despite sharing the same class DEFINITION
1. The Problem Generics Solve
Before generics: object-based collections, and the two problems they create
// Pre-generics C# (this is what ArrayList looked like, and largely still does)
ArrayList list = new ArrayList();
list.Add(42);
list.Add("hello"); // ❌ compiles fine — ArrayList has NO idea what type it's "supposed" to hold
int first = (int)list[0]; // requires an explicit CAST — the compiler can't verify this is safe
int second = (int)list[1]; // ❌ compiles, but throws InvalidCastException at RUNTIME — "hello" isn't an int
Before generics existed in C# (introduced in C# 2.0), a general-purpose, reusable collection like ArrayList could only store object — which meant it could hold anything, including a mix of unrelated types in the same list, and reading anything back out required an explicit cast that the compiler had no way to verify was actually correct. This created exactly two problems: no type safety (the wrong type of item can be added, and the bug isn't caught until a cast fails at runtime) and boxing overhead for value types (an int stored as object has to be wrapped/unwrapped — Section 8 covers this in more depth).
With generics: the type is fixed, known, and enforced at compile time
List<int> list = new List<int>();
list.Add(42);
// list.Add("hello"); // ❌ does NOT compile — the compiler knows this List<int> only holds int
int first = list[0]; // no cast needed — the compiler already knows this is an int
List<int> declares, once and for all at the point of instantiation, exactly what type it holds — every Add call and every read is checked by the compiler, the same guarantee this series' Delegates guide describes for method signatures, just applied here to a container's element type instead. This is the entire value proposition of generics in one example: the reusability of ArrayList (one implementation, works for any type) combined with the type safety ArrayList never had.
Generics also solve a code-duplication problem, not just a safety one
// ❌ Without generics, supporting multiple types means writing near-identical classes repeatedly
public class IntBox { public int Value; }
public class StringBox { public string Value; }
public class CustomerBox { public Customer Value; }
// ... and so on, for every type that ever needs "a box holding one value"
Even setting the type-safety problem aside, generics eliminate a real, tedious form of code duplication — without them, supporting a "box holding one value" pattern for int, string, and Customer would mean writing three (or, realistically, many more) nearly identical classes differing only in one field's type, which is exactly the kind of duplication generics were built to eliminate by parameterizing that one varying piece.
2. Generic Classes
Declaring a class with a type parameter
public class Box<T>
{
private T _value;
public void SetValue(T value) => _value = value;
public T GetValue() => _value;
}
T here is a type parameter — a placeholder standing in for whatever concrete type the class is used with. Inside the class body, T is used exactly like any real type name: as a field's type, a parameter's type, a return type. The class definition itself is written once, entirely without knowing what T will actually be.
Instantiating a generic class with a specific type argument
Box<int> intBox = new Box<int>();
intBox.SetValue(42);
int value = intBox.GetValue(); // strongly typed — no cast needed
Box<string> stringBox = new Box<string>();
stringBox.SetValue("hello");
// stringBox.SetValue(42); // ❌ compile error — this Box's T is string, not int
Box<int> and Box<string> are each fully type-safe, independent uses of the same underlying class definition — the compiler substitutes T with the concrete type argument (int, string) at each usage, and enforces that substitution consistently everywhere T appears in the class.
A generic class can have multiple members all referencing the same type parameter
public class Repository<T> where T : class // constraint covered in Section 5
{
private readonly List<T> _items = new();
public void Add(T item) => _items.Add(item);
public T GetById(int index) => _items[index];
public IEnumerable<T> GetAll() => _items;
public int Count => _items.Count;
}
var userRepo = new Repository<User>();
userRepo.Add(new User { Name = "Alice" });
User first = userRepo.GetById(0); // strongly typed throughout — every member consistently uses T
Every member of Repository<T> — the field, Add, GetById, GetAll — consistently refers to the same T, which is fixed once, for the lifetime of a given Repository<User> instance, at the point it was constructed. This is the same generic Repository pattern this series' Interfaces guide's Section 8 introduces, revisited here with the class implementation, not just the interface contract, in view.
3. Generic Methods
A single method can be generic, even inside an entirely non-generic class
public class Utilities // NOT a generic class
{
public static T FindMax<T>(T a, T b) where T : IComparable<T>
{
return a.CompareTo(b) > 0 ? a : b;
}
}
int maxInt = Utilities.FindMax(3, 7); // T inferred as int
string maxString = Utilities.FindMax("apple", "banana"); // T inferred as string, entirely separately
FindMax<T> is a generic method, declared on an otherwise ordinary, non-generic class — the type parameter <T> belongs to the method itself, not the class, which means each call to FindMax can use a completely different type, independent of any other call. This is a genuinely important, distinct capability from generic classes (Section 2): a generic class fixes its type parameter once per instance, while a generic method can vary its type parameter on every single call.
Explicit type arguments vs. inferred ones
int maxInt = Utilities.FindMax<int>(3, 7); // explicit — spelling out <int> is optional here
int maxInt2 = Utilities.FindMax(3, 7); // inferred — the compiler figures out T = int from the arguments
Section 9 covers type inference in depth, but worth introducing here: in the common case, you don't need to write <int> explicitly — the compiler can usually work out the type argument from the method's actual arguments, which is why most real-world generic method calls in C# look exactly like ordinary method calls, with no visible angle brackets at all.
4. Type Parameter Naming and Multiple Type Parameters
T is convention, not a requirement — and more specific names are often clearer
public class Cache<TKey, TValue> // multiple type parameters, named descriptively
{
private readonly Dictionary<TKey, TValue> _store = new();
public void Set(TKey key, TValue value) => _store[key] = value;
public TValue Get(TKey key) => _store[key];
}
var cache = new Cache<string, int>();
cache.Set("age", 30);
int age = cache.Get("age");
T alone is the conventional name for a single, generically-meaningful type parameter (as in List<T>, Box<T>), but once a class or method has more than one type parameter, or the parameter's role is specific enough to name clearly, the T-prefixed naming convention (TKey, TValue, TResult, TInput) is standard practice across .NET — this is purely a readability convention, but it's followed consistently enough that deviating from it without a reason is worth avoiding, exactly as this series' Interfaces guide notes for the I-prefix convention on interface names.
Multiple type parameters are entirely independent of one another
public class Pair<TFirst, TSecond>
{
public TFirst First { get; }
public TSecond Second { get; }
public Pair(TFirst first, TSecond second) { First = first; Second = second; }
}
var pair = new Pair<string, int>("age", 30); // TFirst = string, TSecond = int — no relationship required between them
There's no requirement that multiple type parameters relate to each other in any way — TFirst and TSecond can be completely unrelated types, and the compiler tracks each independently, exactly as Dictionary<TKey, TValue> in the framework itself does.
5. Constraints: Narrowing What a Type Parameter Can Be
The problem constraints solve: T alone tells the compiler almost nothing about what's actually possible
public class Repository<T>
{
public void Validate(T item)
{
// item.Id // ❌ won't compile — the compiler has NO idea T has an "Id" property;
// T could be literally anything, including int or string
}
}
Without any constraint, the compiler must assume T could be absolutely any type — which means it can't let you call any member on a T value beyond what every single possible type universally supports (which is essentially just the members inherited from object, like ToString() and Equals()). Constraints are how you tell the compiler "actually, T will always be at least this specific," unlocking the ability to call whatever members that guarantee implies.
where T : <interface> — the most common constraint
public interface IEntity { int Id { get; } }
public class Repository<T> where T : IEntity
{
public void Validate(T item)
{
Console.WriteLine($"Validating entity with Id {item.Id}"); // ✅ now compiles — T is guaranteed to have Id
}
}
Constraining T to implement IEntity tells the compiler that whatever concrete type T ends up being, it's guaranteed to have an Id property — this is what makes item.Id a legal expression inside the generic class, closing exactly the gap the unconstrained version above ran into.
where T : <base class> — constraining to a class hierarchy
public abstract class Entity { public int Id { get; set; } }
public class Repository<T> where T : Entity
{
public void PrintId(T item) => Console.WriteLine(item.Id);
}
Just as with interfaces, constraining T to inherit from a specific base class guarantees access to that base class's members — this directly combines with this series' Abstract Classes guide's discussion of shared base-class implementation, letting a generic type rely on whatever the base class provides.
where T : class and where T : struct — reference type vs. value type constraints
public class Cache<T> where T : class // T must be a reference type
{
public T? Value; // nullable reference type — makes sense because T is guaranteed to be a class
}
public struct Optional<T> where T : struct // T must be a value type (int, bool, DateTime, custom structs)
{
public T Value;
public bool HasValue;
}
These constraints restrict T along the fundamental reference-type/value-type divide covered in this series' OOP guide — useful when a generic type's implementation genuinely depends on that distinction (nullability semantics differ meaningfully between the two, for instance).
where T : new() — requiring a parameterless constructor
public class Factory<T> where T : new()
{
public T CreateInstance() => new T(); // only legal because the constraint GUARANTEES this constructor exists
}
var factory = new Factory<Customer>(); // only compiles if Customer has an accessible parameterless constructor
Without where T : new(), new T() inside a generic class would not compile — the compiler has no way to know, in general, whether an arbitrary T even has a parameterless constructor available; this constraint is specifically what unlocks that capability, and it's a common pattern for generic factories.
Combining multiple constraints on a single type parameter
public class Repository<T> where T : class, IEntity, new()
{
public T CreateDefault() => new T(); // requires new()
public void Validate(T item) => Console.WriteLine(item.Id); // requires IEntity
// T? nullableRef; // valid because T is constrained to class
}
Constraints can be combined with commas — a single type parameter can require being a reference type, implementing a specific interface, and having a parameterless constructor, all at once. Worth noting the ordering rule: any class constraint (class, struct, or a specific base class) must come first, followed by interface constraints, with new() always last, if present.
6. Generic Interfaces and Delegates
Generic interfaces, revisited from this series' Interfaces guide
public interface IRepository<T>
{
T GetById(int id);
void Add(T entity);
}
This series' Interfaces guide covers IRepository<T> in depth already — worth restating here briefly because interfaces are one of the most common places generics actually appear in real C# code, and everything Sections 2 through 5 of this guide cover about generic classes (type parameters, constraints) applies identically to generic interfaces.
Generic delegates, revisited from this series' Delegates guide
public delegate TResult Transformer<T, TResult>(T input);
Transformer<int, string> intToString = n => n.ToString();
Transformer<string, int> stringLength = s => s.Length;
Similarly, Func<T, TResult>, Action<T>, and Predicate<T> — covered in depth in this series' Delegates guide — are themselves generic delegates, which is exactly what lets a single Func<T, TResult> declaration serve every possible parameter/return type combination rather than needing a distinct delegate type declared for each one.
7. Variance Revisited: in and out in Depth
The core problem, restated: why IEnumerable<Dog> isn't automatically IEnumerable<Animal> without variance annotations
public class Animal { }
public class Dog : Animal { }
// Without variance support, this would NOT compile, even though it seems intuitively safe:
IEnumerable<Dog> dogs = new List<Dog>();
IEnumerable<Animal> animals = dogs; // this DOES compile — because IEnumerable<T> is declared with `out T`
This series' Interfaces guide's Section 9 introduces variance; worth going deeper here on exactly why it's safe and where it breaks down, since it's a genuinely subtle piece of the generics system that trips up even experienced developers.
Covariance (out): safe specifically because the type parameter is read-only, from the interface's perspective
public interface IEnumerable<out T>
{
IEnumerator<T> GetEnumerator(); // T only ever comes OUT — there's no method taking a T as input
}
The reason IEnumerable<Dog> can safely be treated as IEnumerable<Animal> is structural, not just a convenient rule: every member of IEnumerable<T> only ever produces T values, never accepts one as a parameter — so no code using the IEnumerable<Animal> reference could ever attempt to feed something invalid (like a Cat) into what's actually a List<Dog> underneath. The out annotation is the compiler's way of verifying this invariant holds for every member of the interface — if you tried to add a method taking a T parameter to an out T interface, the interface itself would fail to compile.
Contravariance (in): safe specifically because the type parameter is write-only, from the interface's perspective
public interface IComparer<in T>
{
int Compare(T x, T y); // T only ever comes IN — nothing is ever returned AS a T
}
IComparer<Animal> animalComparer = new AnimalComparer();
IComparer<Dog> dogComparer = animalComparer; // ✅ compiles — a comparer of ANY Animal can certainly compare Dogs specifically
The mirror image: IComparer<T> only ever consumes T, never produces one — so a comparer built to handle any Animal is safely usable wherever a comparer of the more specific Dog type is expected, because it can handle everything a Dog-specific comparer could and more. in marks that T only appears in input positions, and the compiler enforces that constraint on every member exactly as it does for out.
Why invariant (the default) is the right choice for most generic types
public interface IRepository<T> // no `in` or `out` — INVARIANT by default
{
T GetById(int id); // T as OUTPUT
void Add(T item); // T as INPUT — this single method is why variance isn't possible here
}
IRepository<T> has both an input use of T (Add) and an output use (GetById), which means it cannot safely be marked out or in — allowing IRepository<Dog> to be treated as IRepository<Animal> would let calling code Add(someCat) into what's actually a dog-only repository underneath, exactly the type-safety hole Section 1 established generics exist to prevent in the first place. Most real generic types genuinely need both input and output uses of their type parameter, which is precisely why invariance (no variance annotation at all) is the correct default and covariant/contravariant interfaces are comparatively rare, specialized cases.
8. How Generics Are Actually Compiled
Reference types: one shared, compiled implementation, with types substituted at the metadata level
For Box<Customer> and Box<Order> — both REFERENCE types — the JIT compiler
generates and shares a SINGLE compiled implementation of Box<T>'s methods
at the machine-code level, since all reference types are represented
uniformly as pointers of the same size underneath.
This is a genuine, real performance characteristic worth knowing: for generic types instantiated with reference types, the CLR is smart enough to share the compiled code across every reference-type instantiation, since a Customer reference and an Order reference are both just pointers of identical size and representation — there's no meaningful difference in the generated machine code between Box<Customer> and Box<Order> at this level.
Value types: a distinct, specialized compiled implementation per value type — and why this matters
For Box<int> and Box<DateTime> — VALUE types with different sizes and
layouts — the JIT compiler generates a SEPARATE, SPECIALIZED compiled
implementation for EACH distinct value type used as a type argument,
because int and DateTime have genuinely different memory representations.
This is C# generics' key performance advantage over Java's generics (implemented via "type erasure," where generic type information doesn't survive into the compiled bytecode the same way) — because C# specializes the compiled code per value type, a List<int> genuinely stores actual int values directly in its internal array, with no boxing required, unlike the pre-generics ArrayList from Section 1, which had to box every int into an object wrapper to store it.
The boxing cost generics specifically eliminate
ArrayList oldList = new ArrayList();
oldList.Add(42); // BOXES the int 42 into a heap-allocated object wrapper
List<int> newList = new List<int>();
newList.Add(42); // NO boxing — stored directly as a raw int within the list's internal array
This is the concrete, measurable performance payoff of generics for value types specifically — every Add and every read from an ArrayList of value types involves a heap allocation (boxing) and the corresponding garbage collection pressure, while List<int> avoids this entirely, which is a genuinely significant difference in high-throughput, value-type-heavy code (the kind covered extensively in this series' High-Volume Transaction Processing guide).
9. Generic Type Inference
The compiler working backward from arguments to determine type parameters
public static T FindMax<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b) > 0 ? a : b;
var result = FindMax(3, 7); // compiler infers T = int from the ARGUMENT TYPES, no <int> needed
Type inference is what makes generic method calls in C# read exactly like ordinary method calls in the common case — the compiler looks at the actual arguments passed (3 and 7, both int) and works backward to determine what T must be, without you ever writing it explicitly.
When inference fails, and explicit type arguments become necessary
public static List<T> CreateEmptyList<T>() => new List<T>();
// var list = CreateEmptyList(); // ❌ cannot infer — there are no ARGUMENTS to infer T from at all
var list = CreateEmptyList<string>(); // must specify explicitly — nothing in the call gives the compiler a clue
Inference relies entirely on the method's actual arguments — if a generic method's type parameter doesn't appear in any parameter (only in the return type, as here), there's nothing for the compiler to infer from, and the type argument must be supplied explicitly.
Inference across multiple type parameters simultaneously
public static TResult Transform<TInput, TResult>(TInput input, Func<TInput, TResult> transformer) => transformer(input);
var result = Transform(5, n => n.ToString()); // TInput inferred as int (from `5`),
// TResult inferred as string (from the lambda's return type)
The compiler can infer multiple type parameters at once from different arguments — TInput is inferred from the first argument's type, while TResult is inferred from what the lambda passed as transformer actually returns, both resolved together in a single inference pass.
10. default(T) and the Problem of Not Knowing What T Is
Why you can't just write null or 0 generically
public class Box<T>
{
private T _value;
public void Reset()
{
// _value = null; // ❌ doesn't compile in general — T might be a value type, which can't be null
// _value = 0; // ❌ doesn't compile in general — T might be a reference type, which isn't 0
_value = default(T); // ✅ — works regardless of what T actually is
}
}
Since T could be resolved to either a reference type (where the "empty" value is null) or a value type (where the "empty" value is something like 0, false, or a zeroed-out struct), neither null nor 0 is universally valid inside a generic type's implementation — default(T) (or the more concise default since C# 7.1, when the target type is inferable) is the language's answer: it evaluates to null for reference types and to the appropriate zero-equivalent for value types, whichever T actually turns out to be.
default as a general-purpose "empty" value for any type parameter
public T GetValueOrDefault(int index)
{
if (index < 0 || index >= _items.Count) return default; // works whether T is int, string, or anything else
return _items[index];
}
This pattern — returning default as a fallback when a generic method has nothing meaningful to return — is exactly what real .NET APIs like Dictionary<TKey,TValue>.GetValueOrDefault and LINQ's FirstOrDefault() do internally, which is why those method names literally include "Default" in them.
11. Generic Collections in the .NET Framework
The pre-generics collections still exist, but are almost never the right choice today
System.Collections (pre-generics, largely legacy): ArrayList, Hashtable, Queue, Stack
System.Collections.Generic (the modern standard): List<T>, Dictionary<TKey,TValue>,
Queue<T>, Stack<T>, HashSet<T>
Every collection type from Section 1's ArrayList era has a direct generic replacement in System.Collections.Generic, and modern C# code should essentially always reach for the generic versions — the non-generic collections remain in the framework almost entirely for backward compatibility with older code, not as a genuine alternative for new development.
The most commonly used generic collections, briefly
List<T> // an ordered, resizable, index-accessible sequence — the generic replacement for ArrayList
Dictionary<TKey, TValue> // key-value lookup — the generic replacement for Hashtable
HashSet<T> // an unordered collection of UNIQUE values, with fast Contains checks
Queue<T> // first-in-first-out
Stack<T> // last-in-first-out
Each of these follows exactly the same generic principles this guide has covered throughout — a single implementation, parameterized by T (or TKey/TValue), fully type-safe, and (per Section 8) free of boxing overhead when used with value types.
12. When Generics Are the Wrong Tool
Over-genericizing a type that will only ever have one real use
// ❌ Genuinely unnecessary — this will only EVER be used with Customer,
// and the generic parameter adds ceremony without any real reuse benefit
public class CustomerRepository<T> where T : Customer
{
public T GetById(int id) { /* ... */ return default; }
}
// ✅ Just... a Customer repository
public class CustomerRepository
{
public Customer GetById(int id) { /* ... */ return null; }
}
Generics exist to eliminate genuine duplication and to support genuine reuse across multiple, real, varying types — reaching for a type parameter when there's no actual second use case in sight adds real cognitive overhead (constraints to understand, an extra concept to track) for a flexibility the code will likely never exercise. This is analogous to the caution this series' Abstract Classes and Interfaces guides raise about reaching for their respective features without a genuine need driving the decision.
object-based (or non-generic) code is still occasionally the right call
Reflection-heavy code operating genuinely generically over "any type" at
runtime, or interop scenarios working with legacy, non-generic APIs, are
among the narrow cases where object-based code remains the more natural fit —
worth recognizing as an exception, not a reason to avoid generics broadly.
Worth acknowledging as a genuine, if narrow, exception: certain reflection or interop-heavy scenarios are working with types that are only known at runtime, not compile time, which is precisely the situation generics (a compile-time mechanism) aren't built for — this doesn't undermine generics' value everywhere else, it's simply a different problem shape that calls for a different tool.
13. Common Pitfalls
| Pitfall | Why it hurts | Better approach |
|---|---|---|
Reaching for object-based collections in new code |
Loses compile-time type safety and, for value types, incurs real boxing overhead (Section 8) | Use the generic collections in System.Collections.Generic (List<T>, Dictionary<TKey,TValue>, etc.) |
Assuming T supports members it isn't constrained to have |
Doesn't compile — the compiler only knows what a constraint (or the lack of one) guarantees about T
|
Add the appropriate constraint (where T : IEntity, etc.) to unlock the members the generic code actually needs |
Writing null or 0 as a generic "empty" value |
Doesn't compile in general, since T could resolve to either a reference or value type |
Use default(T) (or default), which resolves correctly regardless of what T turns out to be |
Adding an in or out variance annotation to an interface that uses its type parameter both ways |
Doesn't compile — a type parameter used as both an input and an output can't be safely variant (Section 7) | Leave the interface invariant (the default) unless every member genuinely only produces or only consumes T
|
| Over-genericizing a type with no real second use case | Adds constraint complexity and cognitive overhead for flexibility the code will likely never exercise | Only introduce a type parameter once there's a genuine, current or clearly anticipated need for more than one concrete type |
| Assuming generic code performs identically regardless of value vs. reference type arguments | Reference-type instantiations share compiled code; value-type instantiations get separate, specialized code — a real, if usually invisible, distinction | Understand this is actually a performance advantage (no boxing) rather than something to work around |
Forgetting that a generic class fixes T per instance, while a generic method can vary T per call |
Leads to confusion about why a List<int> can't also hold a string, while FindMax can be called with different types on different calls |
Remember: generic class = one T per instance (Section 2); generic method = T resolved fresh, per call (Section 3) |
| Combining constraints in the wrong order | A compile error that can be confusing if the ordering rule (class/struct/base class, then interfaces, then new() last) isn't known |
Follow the required constraint ordering exactly, or let the compiler's error message guide the correction |
Quick Reference Table
| Concept | C# Syntax | Purpose |
|---|---|---|
| Generic class | class Box<T> { ... } |
One implementation, reusable and type-safe across any concrete T
|
| Generic method | T FindMax<T>(T a, T b) { ... } |
A type parameter scoped to a single method, resolved fresh per call |
| Multiple type parameters | class Cache<TKey, TValue> { ... } |
Independent type parameters within the same generic type |
| Interface constraint | where T : IEntity |
Guarantees T implements a specific interface, unlocking its members |
| Reference/value type constraint |
where T : class / where T : struct
|
Restricts T along the reference-type/value-type divide |
| Constructor constraint | where T : new() |
Guarantees a parameterless constructor exists, enabling new T()
|
default(T) |
_value = default(T); |
The type-agnostic "empty" value — null or the zero-equivalent, depending on T
|
| Covariance | interface IEnumerable<out T> |
Safe widening for a type parameter that's output-only |
| Contravariance | interface IComparer<in T> |
Safe narrowing for a type parameter that's input-only |
| Type inference |
FindMax(3, 7) (no <int> needed) |
The compiler determines type arguments from the call's actual arguments |
Conclusion
Generics solve two problems at once: the type-unsafety of pre-generics, object-based reusable code, and the tedious duplication of writing near-identical types by hand for every concrete type that needs the same shape of behavior — List<T> is the everyday example, but the same underlying mechanism scales from a simple Box<T> up through constrained generic repositories, covariant and contravariant interfaces, and multi-parameter generic delegates. Understanding constraints is what makes generic code genuinely useful rather than restricted to the handful of members every possible type shares; understanding how generics are actually compiled — shared code for reference types, specialized code for value types — explains both their real performance advantage over boxing-heavy alternatives and why C#'s generics behave meaningfully differently from type-erasure-based generics in other languages.
The recurring judgment call, consistent with this series' other guides on interfaces and abstract classes, is knowing when a type parameter is earning its place: genuine reuse across multiple, real concrete types justifies it; a type that will only ever have one real use doesn't, and adds constraint complexity without a corresponding benefit. Used well, generics are what let a single, carefully-written implementation serve an open-ended range of types safely — used reflexively, they add ceremony to code that would have been simpler, and just as correct, without a type parameter at all.
Found this useful? Feel free to star the repo, open an issue with corrections, or share the ArrayList-boxing-caused-a-measurable-GC-pressure-spike story that made the case for generics better than any type-safety argument alone ever could.
Top comments (0)