Generics let you write one piece of code that works safely with any type — without giving up type safety, and without paying for it in casts or crashes.
You've been using generics this entire course without necessarily calling them that. Every time you wrote List<Product>, Dictionary<string, decimal>, or Nullable<int> (a.k.a. int?), that <T>-in-angle-brackets syntax was generics at work. List<Product> is a "list that only holds Product," and List<string> is a "list that only holds string" — same List machinery underneath, specialized to a different type each time you use it.
Up to now you've only ever been a consumer of generics — picking the type that goes inside someone else's <T>. This lesson flips that around: you're about to learn how List<T> itself is built, so you can write your own generic types and methods from scratch.
In this lesson, you'll learn what generics actually are, the specific problem they solve — type safety without duplicating code, and without paying the performance cost of treating everything as object — and see that problem solved with a clear before/after.
A generic type or method is one that's written once, but works with many different types — decided later, by whoever uses it. Instead of writing a separate IntBox, StringBox, and ProductBox that all do the exact same thing, you write a single Box<T>, where T is a placeholder that gets filled in with a real type — int, string, Product — at the moment someone actually uses it.
Generics is a C# language feature that lets you parameterize a type or method by one or more type parameters — placeholder names, conventionally T (or TKey, TValue, TResult for clarity when there's more than one) — that stand in for a real type. The real type is supplied when the generic type or method is used, either explicitly (Box<int>) or inferred by the compiler. The compiler then enforces, at compile time, that only that specific type (or something compatible with it) can flow through that particular usage.
Box<T> by itself isn't a usable type — T is unfilled.Box<int> and Box<string> are the real, concrete, usable types — each is Box<T> with T filled in.Box<T>'s code exactly once. The compiler (and later, the runtime) handles producing a version specialized for each T you actually use.Before generics existed (C# 1.0 didn't have them — they arrived in C# 2.0), if you wanted a single container type that could hold any kind of value, you had exactly one tool: store everything as object, since every type in C# ultimately derives from object.
// A "box" that can hold anything — the old, pre-generics way
public class ObjectBox
{
private object? _item;
public void Set(object item) => _item = item;
public object? Get() => _item;
}
var box = new ObjectBox();
box.Set(42); // an int goes in fine...
box.Set("hello"); // ...and so does a string — no compile-time complaint
int number = (int)box.Get()!; // you must cast it back yourself
string text = (string)box.Get()!; // and the compiler can't stop you from casting wrongThis "just use object" approach has two serious, related problems:
int in and later trying to cast it out as a string — the compiler has no idea what's really inside an object. That mistake only surfaces as an InvalidCastException at runtime, possibly in production, long after the bug was introduced.int, bool, DateTime, and every other struct are value types — they normally live directly on the stack or inline in memory. The moment you store one as object, the runtime has to box it: allocate it on the managed heap and wrap it, purely so it can be treated like a reference type. Reading it back out requires unboxing — copying it back out of that heap allocation. Every single box/unbox is extra allocation and extra copying, for something as simple as storing a number.The alternative — hand-writing a separate, near-identical type for every kind of value you want to hold (IntBox, StringBox, ProductBox...) — solves the type-safety and boxing problems, but at the cost of duplicating the exact same logic over and over, with all the maintenance burden that implies. Fix a bug in one, and you must remember to fix it in all the others too.
What's needed is a way to write the container logic once, while still letting the compiler know — for any given usage — exactly which type is inside, so it can catch type mistakes before the program ever runs, and so value types never need to be boxed just to fit inside the container.
// The same "box" idea, written once, generically
public class Box<T>
{
private T? _item;
public void Set(T item) => _item = item;
public T? Get() => _item;
}
Box<int> intBox = new();
intBox.Set(42); // only an int is allowed
int number = intBox.Get()!; // no cast needed — the compiler already knows it's an int
Box<string> textBox = new();
textBox.Set("hello"); // only a string is allowed
textBox.Set(42); // compile error — caught immediately, not at runtimeBox<T> is written exactly once. Each time it's used with a different type argument (Box<int>, Box<string>, Box<Product>...), the compiler treats it as if a specialized version existed just for that type — full compile-time checking, no casts on the way out, and — for value types like int — no boxing at all.
public class Box<T>
{
// T is a placeholder — not a real type yet
}
<T> right after the class name declares a type parameter. By convention it's named T for a single, generic-purpose parameter.public class Box<T>
{
private T? _item; // field type
public void Set(T item) => _item = item; // parameter type
public T? Get() => _item; // return type
}
T behaves like any other type name — you can use it for fields, parameters, return types, even local variables.Box<int> intBox = new(); // T becomes int, for this instance
Box<string> textBox = new(); // T becomes string, for this instance
<...> — int, string, whatever you choose — is called the type argument. It fills in the T placeholder for that specific instance.Box<int>, every member that used T is now checked as if it had been written specifically for int — Set only accepts an int, and Get returns an int, no cast required.public class Box<T>
{
private T? _item;
public void Set(T item) => _item = item;
public T? Get() => _item;
public bool HasValue => _item is not null;
}
// Using it with a value type — no boxing at all
Box<int> ageBox = new();
ageBox.Set(29);
Console.WriteLine(ageBox.Get()); // 29 — already an int, no cast
// Using it with a reference type
Box<string> nameBox = new();
nameBox.Set("Ana");
Console.WriteLine(nameBox.Get()); // Ana
// This line simply doesn't compile — caught before the app ever runs
// ageBox.Set("not a number"); // compile error: cannot convert string to intCode → Meaning → Result:
Box<int> and Box<string> reuse the exact same Box<T> source code — nothing was duplicated to support both.Get() call needs a cast — the compiler already knows the concrete type from the type argument you supplied.Set a string into a Box<int>) is a compile-time error, not a runtime surprise.A very common real-world shape for exactly this problem is a result wrapper — a type that represents "either an operation succeeded and here's the value, or it failed and here's why," without resorting to exceptions for expected failure cases (like "user not found") or to object-typed data that loses type safety.
public class Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public string? Error { get; }
private Result(bool isSuccess, T? value, string? error)
{
IsSuccess = isSuccess;
Value = value;
Error = error;
}
public static Result<T> Success(T value) => new(true, value, null);
public static Result<T> Failure(string error) => new(false, default, error);
}
public class UserRepository
{
private readonly Dictionary<int, string> _users = new()
{
[1] = "Ana Ortiz",
[2] = "Ben Diaz",
};
public Result<string> FindNameById(int id)
{
return _users.TryGetValue(id, out string? name)
? Result<string>.Success(name)
: Result<string>.Failure($"No user with id {id}");
}
}
var repository = new UserRepository();
Result<string> found = repository.FindNameById(1);
if (found.IsSuccess)
Console.WriteLine($"Found: {found.Value}"); // Found: Ana Ortiz — Value is already a string, no cast
Result<string> missing = repository.FindNameById(99);
if (!missing.IsSuccess)
Console.WriteLine($"Error: {missing.Error}"); // Error: No user with id 99Notice Result<T> is written exactly once, yet it's completely reusable for any operation's return type — Result<string>, Result<Product>, Result<int> — all sharing the same success/failure logic, all fully type-checked, with no object and no casting anywhere in sight. You'll see this exact pattern again once you reach error-handling patterns later in this course.
Think of Box<T> as a shipping container's design blueprint — the same blueprint used to build every container in a shipping yard. Each physical container gets a label on the outside declaring exactly what it's allowed to hold: "Electronics Only," "Perishables Only," "Books Only." The container's internal structure (how it locks, how it stacks, how it's lifted) is identical every time — only the label, and what's allowed inside, changes.
An object-based box, by contrast, is like a container with no label at all — anyone can put anything inside, and whoever opens it later has to guess (or check by hand) what's actually in there before using it. Generics are the label: decided once, checked automatically, and impossible to ignore by accident.
Box<T> is compiled into IL (Intermediate Language) exactly once, as an open generic type — a definition with T still unfilled. This is what actually ships inside the compiled assembly.Box<int> or Box<string>, the Just-In-Time (JIT) compiler generates a real, specialized machine-code version for that specific type argument, the first time it's needed. This process is called generic type instantiation.int, the JIT generates a genuinely distinct, specialized version — T is compiled as if it were literally replaced by int everywhere it appears. No boxing occurs; an int field inside Box<int> is a real, unboxed int in memory.string or Product, the runtime is smarter: since all reference types are just pointers of the same size, the JIT can typically share one compiled version across all reference-type instantiations (Box<string>, Box<Product>, etc. can reuse the same generated code), which keeps generic code compact even when used with many different reference types.object-based ObjectBox from earlier: because its field is typed object, storing an int into it always requires boxing — allocating a small object on the heap to hold that int so it can be referred to as an object. Box<int>'s field is genuinely typed int, so no such allocation ever happens.Inheritance (which you learned in Foundations) lets many different types share behavior through a common base or interface — you call Speak() on an Animal reference without knowing if it's really a Dog or a Cat. Generics solve a different problem: writing one piece of logic that works with a type decided by the caller, while keeping that specific type fully known and checked at compile time. They're complementary, not competing — you'll often see both used together, as later lessons in this module show.
T has no special meaning to the compiler — it's an ordinary identifier, exactly like a variable name. You could legally write Box<TMyThing>, and it would work identically. T (and TKey, TValue, TResult for multiple parameters) is simply the near-universal naming convention across .NET, adopted because it signals "this is a type parameter" at a glance.
List<T>, Dictionary<TKey, TValue>, and Nullable<T> (int?) are all generic types the .NET team wrote for you, using exactly the mechanism this lesson just showed. Everything you've done with them so far — writing List<Product>, calling .Add(product) — was consuming generics. Starting with the next lesson, you'll write your own.
object "just to be flexible"Wrong — throws away type safety and forces casts on every caller:
public class Cache
{
private readonly Dictionary<string, object> _items = [];
public void Set(string key, object value) => _items[key] = value;
public object Get(string key) => _items[key];
}
// Every caller now has to cast, and every cast can crash at runtime
var name = (string)cache.Get("username"); // throws InvalidCastException if wrongCorrect — make the cache itself generic, so the type stays known:
public class Cache<TValue>
{
private readonly Dictionary<string, TValue> _items = [];
public void Set(string key, TValue value) => _items[key] = value;
public TValue Get(string key) => _items[key];
}
Cache<string> nameCache = new();
string name = nameCache.Get("username"); // no cast, guaranteed to be a string Writing IntStack, StringStack, ProductStack as separate, near-identical classes — every future bug fix now has to be applied N times, and it's easy to fix one and forget the others. Write Stack<T> once (or, in practice, just use the built-in Stack<T> covered in Foundations).
Thinking "generics = List and Dictionary, nothing else." In reality, generics apply to any class, struct, interface, delegate, or method where the same logic needs to work across multiple types — result wrappers, caches, repositories, event payloads, comparers, and more, as you'll see across this entire module. Recognize the underlying pattern — "this logic doesn't actually care what T is" — wherever it shows up, not just in collections.
T is limited, and how constraints fix that).Box<int> → int is the argument)object-based code trades away compile-time safety and, for value types, forces boxing.List<T>, Dictionary<TKey,TValue>) all along — now you're learning to author them.
T), and have the compiler specialize and check it for every real type argument it's used with.object everywhere — loses compile-time type safety (bad casts only fail at runtime) and forces boxing for every value type stored.List<T>, Dictionary<TKey,TValue>, and every collection from Foundations are all generic types you've already been using — you were just consuming them, not writing them.You've seen why generics exist and the specific problems they solve. Let's check your understanding.
1. What is the main problem with storing everything as object instead of using generics?
Correct: B
Why B is correct: Storing values as object means the compiler can no longer verify what type is actually inside — a wrong cast only surfaces as an InvalidCastException at runtime. For value types specifically, it also forces boxing — an extra heap allocation and copy every time.
Why A is incorrect: The opposite is generally true — generic code with a value type avoids boxing overhead that object-based code cannot.
Why C is incorrect: object can absolutely be used as a field or parameter type — that's exactly what the problematic ObjectBox example did. The issue isn't that it's disallowed, it's that it's unsafe and costly.
Why D is incorrect: They solve overlapping problems very differently — generics keep both safety and performance that plain object gives up.
Reinforcement: Generics exist precisely to give you type safety and performance at the same time — something object-based code cannot do.
2. In Box<int> ageBox = new();, what is int called?
Correct: B
Why B is correct: T in Box<T>'s declaration is the type parameter — the placeholder. int, supplied at the point of use, is the type argument — the real type that fills that placeholder for this particular instance.
Why A is incorrect: The type parameter is T itself, declared once in Box<T>'s definition — not the value you supply when using it.
Why C is incorrect: int isn't a base class here at all — Box<int> isn't inheriting from int, it's using int to fill in T.
Why D is incorrect: int is a concrete struct type, not an interface, and this line has nothing to do with interface implementation.
Reinforcement: Parameter = the placeholder in the definition. Argument = the real type supplied at the call site. The same distinction you already know from method parameters and arguments.
3. Why doesn't storing an int inside a Box<int> require boxing, while storing it inside an object-typed field does?
Correct: B
Why B is correct: As covered in Under the Hood, the JIT generates a genuinely specialized version of a generic type for each value-type type argument it's used with — the field really is an int, not a boxed object wrapping one. An object-typed field, by contrast, always requires boxing to hold a value type.
Why A is incorrect: No such conversion happens — the value stays an int the entire time.
Why C is incorrect: Boxing applies to any value type stored where a reference type (like object) is expected — int, bool, DateTime, custom structs, all of them.
Why D is incorrect: This isn't about physical location at all — it's about whether a heap allocation and wrapper are needed to treat a value type as an object.
Reinforcement: Avoiding unnecessary boxing for value types is one of the concrete, measurable performance benefits generics provide over object-based code.
4. A team has three nearly identical classes — IntCache, StringCache, and ProductCache — that differ only in the type of value they store. What's the best fix, based on what this lesson covered?
Correct: B
Why B is correct: This is exactly the scenario generics exist for — logic that's identical except for one varying type. A single Cache<TValue> keeps full type safety per usage (Cache<int>, Cache<string>, Cache<Product>) while eliminating the duplicated code.
Why A is incorrect: This is the exact object-based mistake this lesson warned against — it reintroduces boxing and loses compile-time type checking.
Why C is incorrect: Triplicated logic means triplicated bugs and triplicated maintenance — generics exist specifically to avoid this without sacrificing safety.
Why D is incorrect: StringCache "is not a" IntCache — there's no genuine is-a relationship here, so inheritance would misuse the mechanism (echoing the composition-vs-inheritance lesson from earlier in the course) and still wouldn't remove the duplication generics eliminate.
Reinforcement: Near-identical classes differing only by one type are the textbook signal to reach for a generic type instead.
You now understand what generics are and the exact problems they solve. Next: writing your own generic class from scratch, with multiple type parameters and generic members.
dotnetmadeeasy.com — Learn C# and .NET, the right way.