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

A generic class is an ordinary class with one extra decision deferred to whoever uses it: which type(s) it actually works with.

In the previous lesson you saw why generics exist by looking at a tiny Box<T>. Now it's time to build a real one yourself — a class with multiple type parameters, generic fields, generic properties, and generic methods, the same building blocks that List<T> and Dictionary<TKey, TValue> are made of.

In this lesson, you'll write a generic class from scratch, learn how to declare and use more than one type parameter at once, and see how ordinary class members — fields, properties, constructors, methods — work once their containing class is generic.

What Is It?

The Simple Explanation

A generic class is a class declared with one or more type parameters in angle brackets right after its name — class Repository<T>, class Pair<T1, T2>. Inside the class body, those type parameters behave exactly like real types: you can use them for fields, properties, method parameters, method return types, and local variables. The actual, concrete type only gets decided when someone creates an instance.

The Technical Definition

A generic class definition is an open generic type — it isn't a complete, usable type by itself, only a template. Supplying type arguments (Repository<Product>) produces a closed generic type (also called a constructed type) — a fully concrete type the compiler can check against and the runtime can instantiate. Everything inside the class — its fields, properties, and methods — can reference the class's own type parameters freely, as if they were ordinary types.

Where Type Parameters Can Appear

Why Does It Exist?

The Problem

Consider a very common real-world need: a data-access layer that fetches, adds, and removes entities by ID. Without generics, you'd write this once per entity type:

public class ProductRepository { private readonly Dictionary<int, Product> _items = []; public void Add(Product item) => _items[item.Id] = item; public Product? GetById(int id) => _items.GetValueOrDefault(id); public bool Remove(int id) => _items.Remove(id); } public class CustomerRepository { private readonly Dictionary<int, Customer> _items = []; public void Add(Customer item) => _items[item.Id] = item; public Customer? GetById(int id) => _items.GetValueOrDefault(id); public bool Remove(int id) => _items.Remove(id); }

These two classes are identical in every way except the type they store. Any bug fix, or any new feature (like a Count property), must be applied to both — and to every future repository you add for every future entity type.

The Solution

Making the class itself generic collapses this into one definition that works for any entity type, with full compile-time type safety preserved for every usage:

public class Repository<T> { private readonly Dictionary<int, T> _items = []; public void Add(int id, T item) => _items[id] = item; public T? GetById(int id) => _items.GetValueOrDefault(id); public bool Remove(int id) => _items.Remove(id); } Repository<Product> products = new(); Repository<Customer> customers = new();

One class. Every entity type gets its own fully type-checked, independent repository instance, and a bug fix to Repository<T> automatically benefits every entity type that uses it.

Big Picture

ONE OPEN GENERIC TYPE → MANY CLOSED GENERIC TYPES
public class Repository<T>    ← open generic type (the template)
▼ used with different type arguments ▼
Repository<Product>
Repository<Customer>
Repository<Order>
Each is a distinct, fully type-checked closed generic type — but they all share exactly one source definition.

How It Works

BUILDING A GENERIC CLASS WITH MULTIPLE TYPE PARAMETERS
1. DECLARE MULTIPLE TYPE PARAMETERS, COMMA-SEPARATED
public class Pair<T1, T2>
{
    // two independent placeholders
}
2. USE EACH TYPE PARAMETER FOR ITS OWN MEMBERS
public class Pair<T1, T2>
{
    public T1 First { get; }
    public T2 Second { get; }

    public Pair(T1 first, T2 second)
    {
        First = first;
        Second = second;
    }
}
3. SUPPLY BOTH TYPE ARGUMENTS WHEN CONSTRUCTING
Pair<string, int> nameAndAge = new("Ana", 29);
Console.WriteLine($"{nameAndAge.First} is {nameAndAge.Second}");

Simple Example

public class Pair<T1, T2> { public T1 First { get; } public T2 Second { get; } public Pair(T1 first, T2 second) { First = first; Second = second; } // A generic member using the class's own type parameters — no new ones needed public Pair<T2, T1> Swap() => new(Second, First); public override string ToString() => $"({First}, {Second})"; } Pair<string, int> nameAndAge = new("Ana", 29); Console.WriteLine(nameAndAge); // (Ana, 29) Pair<int, string> swapped = nameAndAge.Swap(); Console.WriteLine(swapped); // (29, Ana)

Code → Meaning → Result:

Real-World Example

A generic Repository<T> is one of the most common generic classes you'll build in real applications — a reusable data-access layer that works for any entity, as long as it can identify entities by an Id.

public record Product(int Id, string Name, decimal Price); public record Customer(int Id, string FullName, string Email); public class Repository<T> { private readonly Dictionary<int, T> _items = []; public void Add(int id, T item) => _items[id] = item; public T? GetById(int id) => _items.TryGetValue(id, out T? item) ? item : default; public bool Remove(int id) => _items.Remove(id); public IReadOnlyCollection<T> GetAll() => _items.Values; public int Count => _items.Count; } var products = new Repository<Product>(); products.Add(1, new Product(1, "Notebook", 4.75m)); products.Add(2, new Product(2, "Pen", 2.50m)); var customers = new Repository<Customer>(); customers.Add(101, new Customer(101, "Ana Ortiz", "ana@example.com")); Product? found = products.GetById(1); Console.WriteLine(found is not null ? found.Name : "not found"); // Notebook Console.WriteLine($"Products: {products.Count}, Customers: {customers.Count}"); // Products: 2, Customers: 1

Notice that products and customers are completely independent instances — each one is its own closed generic type (Repository<Product> and Repository<Customer>), with its own private _items dictionary. There's no risk of accidentally storing a Customer where a Product was expected — the compiler simply won't allow it.

Analogy

A Recipe vs a Finished Dish

A generic class definition is like a recipe that says "add [protein] and [vegetable]" instead of naming specific ingredients. The recipe itself (Pair<T1, T2>) can't be eaten — it's a set of instructions with blanks. Only once you pick actual ingredients — chicken and broccoli, or tofu and carrots — do you get an actual, edible dish (Pair<Chicken, Broccoli>, a closed generic type). The cooking steps (the class's methods) work identically no matter which ingredients you chose, because the recipe was written around the role each ingredient plays, not a specific ingredient.

Under the Hood

OPEN vs CLOSED GENERIC TYPES, AND WHY EACH INSTANTIATION IS SEPARATE
1. THE METADATA STORES ONE OPEN DEFINITION
2. Repository<Product> AND Repository<Customer> ARE DIFFERENT RUNTIME TYPES
3. STATIC FIELDS ARE PER CLOSED TYPE, NOT SHARED ACROSS T

Common Confusion

1. "Multiple type parameters" doesn't mean they must be related

Pair<T1, T2>'s two type parameters are completely independent — you could construct Pair<string, string>, Pair<int, Product>, or anything else. Compare that to Dictionary<TKey, TValue>, which also has two independent type parameters — a key type and a value type that don't need to match either.

2. A generic class's own type parameters are visible everywhere inside it

You don't need to redeclare T for each method inside a generic class — once the class itself is declared class Repository<T>, every instance member (fields, properties, non-static methods) can use T freely without repeating the declaration. The next lesson covers the different case — a method that needs a type parameter of its own

3. Primary constructors work on generic classes too

Modern C# lets you write public class Pair<T1, T2>(T1 first, T2 second) as a primary constructor, exactly as you learned for ordinary classes — the type parameters are declared on the class, and the primary constructor's parameters can use them just like any other type.

Common Mistakes

Mistake 1 — Forgetting a static field is per closed type, not shared

Wrong assumption — expecting one shared counter across every T:

public class Repository<T> { public static int TotalInstances; // NOT shared across different T! public Repository() => TotalInstances++; } new Repository<Product>(); new Repository<Customer>(); Console.WriteLine(Repository<Product>.TotalInstances); // 1, not 2 — surprising if you expected a shared total

Correct — if you genuinely need one shared count across every T, use a completely separate, non-generic class to hold it.

Mistake 2 — Adding type parameters "just in case," without a real need

Making every class generic on principle, even when it only ever needs to work with one specific type. This adds ceremony (type arguments everywhere) with no real payoff. Reach for a generic class when you genuinely see the same logic needing to work with more than one type — the signal covered at the end of the previous lesson.

Mistake 3 — Using single-letter names for many unrelated type parameters

class Mapper<T, U, V> — impossible to remember which is which. Use descriptive names once there's more than one or two type parameters: class Mapper<TSource, TDestination>. T alone is fine for a single, self-evidently generic-purpose parameter.

When Should I Use It?

Write a generic class when

  • You're building a reusable container, wrapper, or data-access abstraction (repository, cache, result type).
  • Two or more types genuinely need to be tracked together but stay distinct — like Pair<T1, T2> or Dictionary<TKey, TValue>.
  • You've spotted duplicated classes that differ only by one or two types.

Skip generics when

  • The class is genuinely tied to one specific type's behavior (needs to call members that aren't guaranteed to exist on an unconstrained T — covered in the next two lessons).
  • Only a single method needs the flexibility, not the whole class — a generic method (next lesson) is a better, more targeted fit.

Mental Model

Open generic type = the class definition with type parameters unfilled — Repository<T>
Closed generic type = a real, usable type with type arguments supplied — Repository<Product>
Multiple type parameters = independent placeholders, filled in independently — Pair<T1, T2>

Remember:
· Once a class declares type parameters, every instance member can use them freely, with no extra declaration.
· Each closed generic type (Repository<Product> vs Repository<Customer>) is a genuinely distinct runtime type, including separate static state.
· Give multiple type parameters descriptive names once there's more than one.

Key Takeaway


Check Your Understanding

You've written a generic class with multiple type parameters. Let's check your understanding.

1. What is the difference between an open generic type and a closed generic type?

Show answer

Correct: B

Why B is correct: The class definition, Repository<T>, is a template — an open generic type. Once you supply a type argument, like Repository<Product>, you get a closed generic type — a real, concrete, instantiable type.

Why A is incorrect: This distinction has nothing to do with access modifiers like public or private.

Why C is incorrect: They describe two distinct states — unfilled template versus fully specified, usable type.

Why D is incorrect: Both value types and reference types can be supplied as type arguments to close a generic type.

Reinforcement: "Open" means the type parameter is still a placeholder; "closed" means a real type has filled it in.

2. In public class Pair<T1, T2>, must T1 and T2 always be the same type when the class is used?

Show answer

Correct: B

Why B is correct: Multiple type parameters are independent by default — nothing requires them to be related or identical, exactly like Dictionary<TKey, TValue>'s key and value types.

Why A is incorrect: Nothing in the generic class mechanism enforces matching types across different parameters unless you add a constraint that specifically says so (constraints are covered in upcoming lessons).

Why C is incorrect: Both reference types and value types can be supplied for either parameter, with no restriction from this example.

Why D is incorrect: There's no such inheritance requirement between independent type parameters by default.

Reinforcement: Multiple type parameters are independently filled in — that flexibility is exactly the point.

3. A class declares public static int InstanceCount; inside a generic class Repository<T>. After creating one Repository<Product> and one Repository<Customer>, what is Repository<Product>.InstanceCount?

Show answer

Correct: B

Why B is correct: As covered in Under the Hood, each closed generic type — Repository<Product> and Repository<Customer> — is a genuinely separate runtime type with its own static storage. Only one Repository<Product> was created, so its own counter is 1.

Why A is incorrect: This is the common misconception this lesson specifically warned against — static state is not shared across different closed generic types.

Why C is incorrect: Static fields work fine inside generic classes; they're just scoped per closed type rather than shared globally.

Why D is incorrect: This is entirely valid, compiling C# — it just doesn't behave the way a shared-counter assumption would expect.

Reinforcement: Never assume static state is shared across different type arguments of the same generic class — each closed type keeps its own.

4. A team has ProductRepository and CustomerRepository, identical except for the entity type each stores. Based on this lesson, what's the best refactor?

Show answer

Correct: B

Why B is correct: This is exactly the motivating example from this lesson — identical logic differing only by stored type is the clearest signal to make the class generic, exactly as shown with Repository<T>.

Why A is incorrect: This reintroduces the boxing and lost-type-safety problems the previous lesson specifically warned against.

Why C is incorrect: Generics exist precisely to eliminate this kind of duplication without losing type safety — it is very much avoidable.

Why D is incorrect: A Customer is not a kind of Product — there's no real is-a relationship, so inheritance would misuse the mechanism and wouldn't actually solve the duplication.

Reinforcement: Near-identical classes differing only by the type they operate on are the textbook use case for a generic class.

You've written your own generic class. Next: generic methods — adding type-parameter flexibility to a single method, even inside a class that isn't generic itself.


dotnetmadeeasy.com — Learn C# and .NET, the right way.