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.
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.
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.
private T _value;public T Value { get; set; }public Box(T initial) { ... }public T Get() { ... }, public void Set(T value) { ... }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.
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.
public class Repository<T> ← open generic type (the template)
Repository<Product>
Repository<Customer>
Repository<Order>
public class Pair<T1, T2>
{
// two independent placeholders
}
TKey/TValue, TFirst/TSecond — rather than generic T1/T2, whenever that improves clarity for callers.public class Pair<T1, T2>
{
public T1 First { get; }
public T2 Second { get; }
public Pair(T1 first, T2 second)
{
First = first;
Second = second;
}
}
First and Second can be completely different types — T1 and T2 are independent placeholders, filled in independently.Pair<string, int> nameAndAge = new("Ana", 29);
Console.WriteLine($"{nameAndAge.First} is {nameAndAge.Second}");
T1 becomes string, T2 becomes int, for this instance.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:
Pair<T1, T2> stores two independently-typed values with full type safety for both.Swap() returns a different closed generic type — Pair<T2, T1> — built entirely from the class's own type parameters, with the order reversed.object or a cast anywhere — First is always genuinely a string, Second always genuinely an int, for this particular instance.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: 1Notice 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.
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.
Repository<T> — the open generic type. T is recorded as an unfilled placeholder, not duplicated per usage.Repository<Product> and Repository<Customer> are genuinely distinct types at runtime — calling typeof(Repository<Product>) and typeof(Repository<Customer>) returns two different Type objects. Each closed generic type gets its own static fields, if the class declares any, entirely separate from the others.Repository<T> declared a static int InstanceCount; field, Repository<Product> and Repository<Customer> would each maintain their own, completely independent counter — they do not share static state, even though they come from the same class definition.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.
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
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.
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.
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.
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.
Pair<T1, T2> or Dictionary<TKey, TValue>.T — covered in the next two lessons).Repository<T>Repository<Product>Pair<T1, T2>Repository<Product> vs Repository<Customer>) is a genuinely distinct runtime type, including separate static state.Repository<T>) is an open generic type — a template. Supplying type arguments (Repository<Product>) produces a closed generic type — a real, usable, fully type-checked type.Pair<T1, T2>, Dictionary<TKey, TValue> — each filled in separately at the point of use.Repository<Product> vs Repository<Customer>) are genuinely distinct runtime types, with independent static state.Repository<T> is a realistic, everyday example of this pattern in real applications.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?
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?
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?
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?
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.