Generics let you parameterize types with placeholders (T) for compile-time safety.
List replaces ArrayList — no runtime casts, no boxing for value types.
Constraints (where T : IInterface) unlock member access on T.
Covariance (out) and contravariance (in) enable type-safe substitution on interfaces.
Performance: value types avoid boxing, cutting GC pressure by up to 90% in hot paths.
Biggest mistake: assuming T can do anything without constraints — expect compile errors.
Plain-English First
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.
Generics are everywhere in C#. List<T>, Dictionary<TKey, TValue>, Task<T> — you use them daily without thinking about what's happening under the hood. That's fine until something breaks. A cast fails. Boxing spikes your GC. A constraint you didn't add forces a runtime workaround. That's when knowing how generics actually work saves you a late-night debug session.
Before C# 2.0, developers used ArrayList and cast everything to and from object. The compiler couldn't catch type mismatches — a bug that should fail at compile time exploded as InvalidCastException at runtime. Generics fix this by letting you parameterise a class with a type placeholder. The compiler fills it in when you use the code, giving you full type checking without any runtime casting overhead.
By the end of this article you'll understand why generics exist at a language-design level, write your own generic classes with constraints, combine generics with interfaces for real architectural patterns, and 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.
BeforeAndAfterGenerics.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
usingSystem;
usingSystem.Collections;
usingSystem.Collections.Generic;
classBeforeAndAfterGenerics
{
staticvoidMain()
{
// BEFORE GENERICSvar legacyScores = newArrayList();
legacyScores.Add(95); // int gets BOXED onto the heap
legacyScores.Add(87);
legacyScores.Add("oops"); // compiler is fine with this string!try
{
foreach (object item in legacyScores)
{
int score = (int)item; // UNBOXING — risky cast every timeConsole.WriteLine($"Legacy score: {score}");
}
}
catch (InvalidCastException ex)
{
Console.WriteLine($"Runtime crash: {ex.Message}");
}
// AFTER GENERICSvar modernScores = newList<int>();
modernScores.Add(95); // stored directly, no boxing
modernScores.Add(87);
// modernScores.Add("oops"); // COMPILE ERROR: cannot convert string to intforeach (int score in modernScores) // no cast needed
{
Console.WriteLine($"Modern score: {score}");
}
}
}
Output
Legacy score: 95
Legacy score: 87
Runtime crash: Specified cast is not valid.
Modern score: 95
Modern score: 87
Watch Out: The Hidden Boxing Tax
If you're storing millions of value types (int, double, struct) using object or a non-generic collection, you're creating heap pressure on every write AND every read. Switching to List<int> instead of ArrayList removes boxing entirely. In a hot path — a game loop, a financial engine, a parser — this difference is not academic.
Production Insight
ArrayList in a production loop processing 1M integers generated 100MB+ GC pressure per second.
Switching to List<int> eliminated boxing and reduced GC to near zero.
Rule: Always use generic collections for value types in hot paths.
Key Takeaway
Generics move type errors from runtime to compile time.
Boxing is not just slow — it's a GC disaster in hot paths.
Generic collections are not optional; they are the default.
Choosing Between Generic and Non-Generic Collections
IfStoring value types (int, struct, decimal) in a loop
→
UseUse List<T> — boxing elimination alone justifies it. Performance difference is measurable.
IfInterfacing with legacy code that expects IList or ArrayList
→
UseUse List<T> internally, convert at boundaries. Keep boxing isolated to entry/exit points.
IfYou need runtime type flexibility (e.g., mixed-type collections)
→
UseUse List<object> or ArrayList with explicit documentation. Accept the casting cost consciously.
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.
ResultWrapper.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
usingSystem;
publicclassResult<T>
{
public T? Value { get; }
publicstring? ErrorMessage { get; }
publicboolIsSuccess { get; }
privateResult(T? value, string? error, bool isSuccess)
{
Value = value;
ErrorMessage = error;
IsSuccess = isSuccess;
}
publicstaticResult<T> Success(T value)
=> newResult<T>(value, null, true);
publicstaticResult<T> Failure(string errorMessage)
=> newResult<T>(default, errorMessage, false);
publicoverridestringToString()
=> IsSuccess ? $"Success: {Value}" : $"Failure: {ErrorMessage}";
}
publicclassUserService
{
publicResult<string> FindUsername(int userId)
{
if (userId == 42)
returnResult<string>.Success("ada.lovelace");
returnResult<string>.Failure($"No user found with ID {userId}");
}
}
classProgram
{
staticvoidMain()
{
var service = newUserService();
Result<string> found = service.FindUsername(42);
if (found.IsSuccess)
Console.WriteLine($"Found user: {found.Value}");
elseConsole.WriteLine($"Error: {found.ErrorMessage}");
Result<string> notFound = service.FindUsername(99);
Console.WriteLine(notFound);
Result<int> calculationResult = Result<int>.Success(1337);
Console.WriteLine($"Calculation gave us: {calculationResult.Value + 1}");
}
}
Output
Found user: ada.lovelace
Failure: No user found with ID 99
Calculation gave us: 1338
Pro Tip: Result Over Exception Spam
Exceptions should be exceptional — reserved for things you genuinely didn't anticipate. When 'user not found' or 'validation failed' are expected business outcomes, returning a Result<T> keeps your call stack clean, makes error handling explicit at the call site, and is far easier to unit test. This pattern is at the heart of libraries like FluentResults and ErrorOr in the .NET ecosystem.
Production Insight
Result<T> avoids exception overhead but can become verbose with many error types.
Consider discriminated union libraries (OneOf, ErrorOr) for more than 3 error variants.
Rule: Use Result<T> for expected failures; throw only for programming errors.
Key Takeaway
Result<T> is a production pattern for typed, exception-free error handling.
Private constructors enforce valid state — impossible to create invalid Result.
Static factory methods keep the API clean and discoverable.
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.
Interview Gold: Constraints Are Compile-Time Contracts
Interviewers love asking 'what happens if you don't add a constraint?' The answer: T is treated as object, you lose access to all interface/class members, and you'd have to cast — defeating the entire purpose of generics. Constraints are how you write algorithms that are reusable AND fully type-checked. Always add the minimum constraint that makes your code correct.
Production Insight
Forgetting a constraint forces you to cast T to object, losing compile-time safety.
Common trap: calling .ToString() on T works without constraint, but .CompareTo() does not.
Rule: Always add the minimum constraint that unlocks required members.
Key Takeaway
Constraints are compile-time contracts — they unlock member access on T.
Without constraints, T is just object with no guaranteed members.
Use where T : IInterface to write reusable, type-safe algorithms.
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.
Pro Tip: out and in Are Only for Interfaces and Delegates
Variance keywords (out and in) only work on generic interfaces and generic delegates — not on generic classes. Trying to declare class MyClass<out T> will give you a compile error. This is intentional: classes support both read and write operations on their fields, making it impossible for the compiler to guarantee variance safety. If you need variance, define an interface.
Production Insight
Developers often try to cast List<Dog> to List<Animal> and fail at compile time.
List<T> is invariant — use IEnumerable<T> for covariance.
In production, this error surfaces at compile time, which is good: the bug is caught before deployment.
Key Takeaway
Covariance (out) and contravariance (in) only exist on interfaces and delegates.
They solve the producer/consumer substitution problem safely.
Covariance: a string producer can be assigned to an object producer. Contravariance: an animal consumer can be a dog consumer.
Generic Methods and Type Inference — When the Compiler Deducing T Works (and When It Doesn't)
Generic methods are distinct from generic classes. You can have a generic method inside a non-generic class, and the type parameter is inferred from the arguments you pass. This is incredibly convenient — you don't need to specify the type unless the compiler can't figure it out. For example, when you write var result = Helper.Swap(ref a, ref b); the compiler infers T from the type of a.
But type inference has limits. If the method's type parameter appears only in the return type, the compiler cannot infer it — you must specify it explicitly. This is common in factory patterns: T Create<T>() where T : new() requires you to call Create<MyType>().
Another common gotcha is overload resolution. If two overloads differ only by a generic type parameter, the compiler may pick the wrong one or fail with an ambiguity error. In that case, explicitly specifying the type argument resolves the ambiguity.
Understanding when inference works and when it doesn't separates developers who fight the compiler from those who let it work for them.
GenericMethodsInference.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
usingSystem;
usingSystem.Collections.Generic;
publicclassHelper
{
publicstaticvoidSwap<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
publicstatic T Create<T>() where T : new()
{
returnnew T();
}
publicstaticvoidProcess<T>(T item)
{
Console.WriteLine($"Single item of type {typeof(T).Name}: {item}");
}
publicstaticvoidProcess<T>(IEnumerable<T> items)
{
Console.WriteLine($"Collection of {typeof(T).Name}:");
foreach (var item in items)
Console.WriteLine($" - {item}");
}
}
publicclassExample
{
publicstaticvoidMain()
{
int x = 1, y = 2;
Helper.Swap(ref x, ref y);
Console.WriteLine($"Swapped: x={x}, y={y}");
string s1 = "hello", s2 = "world";
Helper.Swap(ref s1, ref s2);
Console.WriteLine($"Swapped: s1={s1}, s2={s2}");
var list = Helper.Create<List<int>>();
list.Add(42);
Console.WriteLine($"Created list with: {list[0]}");
List<int> numbers = new() { 10, 20, 30 };
Helper.Process(numbers);
int[] array = { 1, 2, 3 };
Helper.Process(array);
Helper.Process<int[]>(array);
}
}
Output
Swapped: x=2, y=1
Swapped: s1=world, s2=hello
Created list with: 42
Collection of Int32:
- 10
- 20
- 30
Collection of Int32:
- 1
- 2
- 3
Single item of type Int32[]: System.Int32[]
Remember: Inference Is Not Magic
Inference works when the method uses the type parameter in at least one argument. If T appears only in the return type, the compiler cannot deduce it — you must specify it. This is why factory methods like T Create<T>() always require explicit type arguments.
Production Insight
A team created an overload set where one method accepted T and another accepted IEnumerable<T>.
When passing a List<T>, the compiler consistently picked the wrong overload for their use case.
Fix: either rename methods or use a marker parameter that disambiguates.
Key Takeaway
Type inference works when T appears in at least one parameter.
Explicit type arguments are required when T is only in the return type.
For overloaded generic methods, disambiguate with distinct signatures or marker types.
Real-World Generic Patterns — Repository, Specification and Type-Safe Builders
Now that you understand constraints and variance, let's look at three real-world patterns that use generics to solve production problems. These aren't academic — they're patterns you'll find in every mature C# codebase.
The Generic Repository pattern keeps data access code consistent across entity types. With a constraint like where T : IEntity, you get a single implementation that handles Product, Order, User — any type with an identity. The pattern reduces duplication, but it also introduces a decision: do you build one grand repository or compose small ones? Generics let you do either.
The Specification pattern pairs with generics to build composable, testable query logic. A Specification<T> is a predicate wrapped in a class. You combine specifications with &&, ||, and ! operators. Pass them to a generic repository method: repository.Find(spec). The T makes the specification reusable across entity types without casting.
Type-safe Builders use generic methods to enforce a construction sequence at compile time. Instead of a builder that throws InvalidOperationException when you call Build() too early, you make each step return a new builder type. The compiler prevents you from creating an invalid object in the first place — no runtime checks needed.
Email ready: Email { To = user@example.com, Subject = Your order confirmation }
Mental Model: Generics Are Type-Level Abstractions
A non-generic class says: I work with Product. A generic class says: I work with anything that has an Id.
Constraints define the minimum set of capabilities a type must have to work with your code.
The more generic your code, the more constraints you need — otherwise the compiler can't guarantee anything.
Every generic parameter is a trade-off: more flexibility means more complexity in understanding the code.
The goal isn't maximum genericity — it's the right amount of genericity for your use case.
Production Insight
Overly generic code is harder to debug — stack traces show T instead of concrete types.
Each generic parameter adds cognitive load; cap at 3 unless there's a strong reason.
Rule: Start concrete, extract generics when you see the third repetition of a pattern.
Key Takeaway
Generic Repository + Specification = composable, testable data access without duplication.
Type-safe Builders use generics to prevent invalid state at compile time.
Start concrete, extract generics at the third repetition — not before.
● Production incidentPOST-MORTEMseverity: high
Boxing-Induced OutOfMemory in a High-Frequency Trading Engine
Symptom
OutOfMemoryException every 2 hours; GC CPU usage at 30%; trading engine down. Memory dumps showed massive object heap with boxed integers.
Assumption
Team assumed ArrayList was fine because 'it works with any type' and 'performance is just object overhead'.
Root cause
ArrayList boxes every int to object on write, then unboxes on read. With 50k trades/sec, each trade wrote and read values, generating ~100 MB/s of heap pressure. GC couldn't keep up, triggering full collections that paused the engine.
Fix
Replaced ArrayList with List<int> (generic). Boxing eliminated entirely. GC CPU dropped to <5%. Engine stable.
Key lesson
Value types in non-generic collections cause boxing overhead in every read and write.
When profiling shows high GC, check collections for boxing — it's the silent killer.
Generics are not just a safety feature; they are a performance requirement in hot paths dealing with value types.
Production debug guideSymptom-to-action guide for common generic pitfalls5 entries
Symptom · 01
Cannot apply indexing with [] to IEnumerable<T>
→
Fix
IEnumerable<T> is read-only. Convert to List<T> with .ToList() or use .ElementAt(). Beware of multiple enumeration — cache to list if iterating more than once.
Symptom · 02
Compile error: 'The type T cannot be used as type parameter...'
→
Fix
Missing or incompatible constraint. Check the method/class definition: does T require a specific interface or base class? Ensure the argument satisfies that constraint.
Symptom · 03
CS0311: Type X cannot be used as type parameter T in generic type Y
→
Fix
The type argument does not implement the required interface. Either add the interface to type X, or select a different T that satisfies constraints.
Symptom · 04
CS0266: Cannot implicitly convert List<Dog> to List<Animal>
→
Fix
List<T> is invariant. For read-only access, use IEnumerable<Animal> (covariant). For write access, create a new list via .Cast<Animal>().ToList() or use .ConvertAll().
Symptom · 05
CS0019: Operator cannot be applied to operands of type T and T
→
Fix
Generic operators (+, <) are not allowed on unconstrained T. For ordering, constrain to IComparable<T>. For arithmetic, use interfaces like IAdditionOperators<T,T,T> from System.Numerics.
★ Quick Debug Cheat Sheet for GenericsCommon symptoms and immediate actions when debugging generic issues in production.
Suspected boxing causing high GC−
Immediate action
Profile with dotnet-counters or PerfView. Look for high Gen0/Gen1 collections.
dotnet-dump collect --process-id <pid> then analyze with dotnet-dump analyze
Fix now
Replace ArrayList with List<T> and any non-generic collections with generic equivalents.
Compile error on generic method call: 'type arguments cannot be inferred'+
Immediate action
Check if the method has enough parameter information for inference.
Commands
Add explicit type arguments: Method<MyType>(arg);
If overloads exist, verify overload resolution by removing one overload temporarily.
Fix now
Provide explicit type argument or refactor method signature to include a parameter that uses T.
Runtime InvalidCastException from generic collection cast+
Immediate action
Check if you're casting between incompatible generic types (e.g., List<string> to List<object>).
Commands
Use IEnumerable<object> for covariance, not List<object>.
If you need a mutable collection of a base type, use .Cast<T>().ToList().
Fix now
Replace covariant assignment with IEnumerable<T> or create a new list with the correct type.
Non-Generic vs Generic Collections
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
1
Generics move type errors from runtime (InvalidCastException crashes) to compile time
the single biggest reliability win they offer over object-based code.
2
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.
3
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.
4
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.
5
Generic methods with type inference reduce boilerplate, but when T appears only in the return type, you must specify it explicitly. Know when to let the compiler work and when to help it.
6
Start concrete, extract generics at the third repetition. Over-generic code is harder to debug and maintain than code that's just generic enough.
Common mistakes to avoid
5 patterns
×
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. The compiler has no information about T beyond the methods of object.
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.
×
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. The class exists only to hold a single generic operation, leading to unnecessary object allocation.
Fix
If only one method needs to be generic, make that method generic instead of the whole class. public static T DeepClone<T>(T source) is far cleaner than instantiating a Cloner<T> just to call .Clone(). Reserve generic classes for when state must be stored per-T.
×
Confusing covariance with inheritance and getting an InvalidCastException
Symptom
You try to cast List<Dog> to List<Animal> thinking 'Dog is an Animal so this should work', and get a runtime exception or compile error.
Fix
List<T> is invariant (no out or in keyword), so List<Dog> is NOT assignable to List<Animal>. Use IEnumerable<Animal> instead (which IS covariant). This is intentional — if List<Animal> secretly held a List<Dog>, you could call .Add(new Cat()) and corrupt it.
×
Over-constraining with new() when the type doesn't need construction
Symptom
You add where T : new() to a generic class, but callers that use reference types with parameterized constructors cannot use your class. They get a compile error even though your code never actually calls new T().
Fix
Only constrain with new() if you explicitly call the parameterless constructor somewhere in your generic code. Otherwise leave it off. Adding unnecessary constraints limits reusability and forces callers into workarounds.
×
Using typeof(T) for runtime type discrimination inside generic methods
Symptom
A generic method with if (typeof(T) == typeof(string)) logic in a loop. Each call pays the branching cost, and the JIT cannot specialize the code path per type. Performance suffers.
Fix
If you need type-specific behavior, either create separate overloads for each type or use a different pattern (like a strategy per type). typeof(T) checks inside generics are almost always a sign you're fighting the abstraction.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
What is the difference between a generic constraint 'where T : class' an...
Q02SENIOR
Why can you assign List to IEnumerable
Q03SENIOR
If you have a method that needs to work on any type T that can be compar...
Q04SENIOR
Explain how type inference works for generic methods. What's the one cas...
Q01 of 04SENIOR
What is the difference between a generic constraint 'where T : class' and 'where T : IMyInterface', and when would you choose one over the other?
ANSWER
The 'where T : class' constraint restricts T to reference types only (no structs). It's useful when you need to assign null to T or use reference-type-specific operations. The 'where T : IMyInterface' constraint requires T to implement a specific interface, which gives you access to its members. You would choose 'class' when you only care about ref-type identity, e.g., for a cache key (null check or reference equality). You choose an interface constraint when you need to call methods defined in that interface — e.g., 'where T : IComparable<T>' to enable sorting. In practice, interface constraints are far more common because they enable actual behavior-driven generics. You can also combine them: 'where T : class, IMyInterface, new()'.
Q02 of 04SENIOR
Why can you assign List to IEnumerable
ANSWER
This is covariance. IEnumerable<T> is declared with the 'out' keyword (public interface IEnumerable<out T>), making it covariant. Covariance means that if type B derives from A, then IEnumerable<B> can be treated as IEnumerable<A> — because IEnumerable<T> only produces T. The compiler enforces that any 'out' type parameter can only appear in output positions (method return types, get-only properties). List<T> does not use 'out' because it can consume T through methods like Add(T), so it is invariant. That's why List<string> cannot be assigned to List<object> — if it could, you could add an int to what is actually a List<string>. The compiler enforces this at compile time; no runtime check is needed.
Q03 of 04SENIOR
If 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?
ANSWER
You would add 'where T : IComparable<T>' (or the non-generic 'where T : IComparable' for older code). This ensures T implements the CompareTo method that returns an int (<0, 0, >0). For example:
public static T Max<T>(T a, T b) where T : IComparable<T>
{
return a.CompareTo(b) > 0 ? a : b;
}
This allows the method to work with int, string, DateTime, or any custom type that implements IComparable<T>. The compiler enforces that only valid types can be used. For scenarios where you want to provide custom ordering without modifying the type, use an overload that accepts IComparer<T> instead of constraining T.
Q04 of 04SENIOR
Explain how type inference works for generic methods. What's the one case where the compiler cannot infer T and you must specify it explicitly?
ANSWER
Type inference works when the type parameter T appears in at least one method parameter. The compiler looks at the argument types at the call site and infers T from them. For example, in Swap<T>(ref T a, ref T b), calling Swap(ref x, ref y) with int arguments infers T = int. The compiler cannot infer T when T appears ONLY in the return type. For example, T Create<T>() where T : new() requires explicit specification: Create<MyType>(). This is because the compiler can only infer from input parameters, not from how the return value is assigned (type inference in C# is input-based, not output-based). Another edge case: if the method has multiple overloads and inference produces ambiguous results, you must specify the type explicitly.
01
What is the difference between a generic constraint 'where T : class' and 'where T : IMyInterface', and when would you choose one over the other?
SENIOR
02
Why can you assign List to IEnumerable, but not to List? What is this feature called and how does the C# compiler enforce it?
SENIOR
03
If 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?
SENIOR
04
Explain how type inference works for generic methods. What's the one case where the compiler cannot infer T and you must specify it explicitly?
SENIOR
FAQ · 6 QUESTIONS
Frequently Asked Questions
01
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.
Was this helpful?
02
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.
Was this helpful?
03
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.
Was this helpful?
04
When should I use a generic method instead of a generic class?
Use a generic method when only a single method needs to operate on a generic type, and that type doesn't need to be stored as state. For example, a Swap<T> method doesn't need a class — it can be a static method in a utility class. If you need to store state across multiple methods (like a Repository<T> that has Save, FindById, etc.), use a generic class. The rule: prefer generic methods to reduce complexity unless you need per-type state.
Was this helpful?
05
Can I use arithmetic operators like + and - on an unconstrained T?
No. The compiler doesn't allow operators on an unconstrained T because not all types support + or -. For arithmetic on generics, you have two options: use the interfaces from System.Numerics (like IAdditionOperators<T, T, T>) available in .NET 7+, or provide a calculator delegate: Func<T, T, T> add. The first approach is cleaner but requires .NET 7 or later.
Was this helpful?
06
Why does List not have 'out T' or 'in T' like IEnumerable does?
Because List<T> both produces and consumes T. The Add(T) method consumes T (input), and the indexer T this[int] gets T (output). Covariance (out) requires T to appear only in output positions. Contravariance (in) requires T to appear only in input positions. Since List<T> does both, it must be invariant. If it were covariant, you could add an Apple to a List<Banana> through a List<Fruit> reference — that would break type safety.