← Open in the full interactive course (progress tracking, search & more)

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.

What Is It?

The Simple Explanation

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.

The Technical Definition

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.

A Generic Type Is a Template, Not a Type Itself

Why Does It Exist?

The Problem — Before Generics

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 wrong

This "just use object" approach has two serious, related problems:

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.

The Need

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 Solution — Generics

// 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 runtime

Box<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.

Big Picture

object BOX vs GENERIC Box<T>
ObjectBox (pre-generics style)
Set(42)
↓ boxed onto the heap
Get() returns object
↓ manual cast required
wrong cast = runtime crash
Box<int> (generic)
Set(42)
↓ no boxing — stored as int directly
Get() returns int
↓ no cast needed
wrong type = compile error, before you ever run it
Same idea — a container that holds one item — but one catches mistakes on your screen, and the other on your users' machines.

How It Works

FROM TYPE PARAMETER TO CONCRETE TYPE, STEP BY STEP
1. DECLARE A TYPE PARAMETER
public class Box<T>
{
    // T is a placeholder — not a real type yet
}
2. USE T ANYWHERE A TYPE WOULD NORMALLY GO
public class Box<T>
{
    private T? _item;                 // field type
    public void Set(T item) => _item = item;   // parameter type
    public T? Get() => _item;         // return type
}
3. SUPPLY A TYPE ARGUMENT WHEN YOU USE IT
Box<int> intBox = new();       // T becomes int, for this instance
Box<string> textBox = new();   // T becomes string, for this instance
4. THE COMPILER ENFORCES THAT CHOICE EVERYWHERE

Simple Example

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 int

Code → Meaning → Result:

Real-World Example

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 99

Notice 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.

Analogy

A Shipping Container With a Label

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.

Under the Hood

WHAT ACTUALLY HAPPENS TO Box<T> AT COMPILE TIME AND RUNTIME
1. ONE SOURCE, ONE COMPILED DEFINITION
2. TYPE ARGUMENTS ARE FILLED IN AT JIT TIME, PER TYPE
3. WHY THIS AVOIDS BOXING

Common Confusion

1. Generics vs Inheritance/Polymorphism — different problems entirely

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.

2. "T" is just a name, not a keyword

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.

3. You've already been a generics consumer — this module makes you a generics author

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.

Common Mistakes

Mistake 1 — Reaching for 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 wrong

Correct — 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

Mistake 2 — Copy-pasting a type for every different data type you need to hold

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).

Mistake 3 — Assuming generics are only about collections

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.

When Should I Use It?

Reach for generics when

Don't reach for generics when

Rule of thumb: If you can honestly say "this logic doesn't care what type it's working with, it just needs some type, consistently," that's the signal to make it generic. If the logic actually depends on specific behavior of one particular type, generics alone won't help — you'll need constraints (covered in the next two lessons) or a different design entirely.

Mental Model

Type parameter (T) = a placeholder for "some type, decided later"
Type argument = the real type supplied when you actually use the generic type (Box<int>int is the argument)
Generic type/method = written once, specialized safely for every type argument it's used with

Remember:
· object-based code trades away compile-time safety and, for value types, forces boxing.
· Generics keep the compile-time safety and avoid duplicating code and avoid boxing for value types.
· You've been consuming generics (List<T>, Dictionary<TKey,TValue>) all along — now you're learning to author them.

Key Takeaway


Check Your Understanding

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?

Show answer

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?

Show answer

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?

Show answer

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?

Show answer

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.