C# Generic Classes: Writing Your Own Generic Types
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.
- Fields —
private T _value; - Properties —
public T Value { get; set; } - Constructor parameters —
public Box(T initial) { ... } - Method parameters and return types —
public T Get() { ... },public void Set(T value) { ... } - Local variables inside any method body
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
public class Repository<T> ← open generic type (the template)
Repository<Product>
Repository<Customer>
Repository<Order>
How It Works
public class Pair<T1, T2>
{
// two independent placeholders
}
- When a type has multiple, unrelated type parameters, name them descriptively —
TKey/TValue,TFirst/TSecond— rather than genericT1/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;
}
}
FirstandSecondcan be completely different types —T1andT2are independent placeholders, filled in independently.
Pair<string, int> nameAndAge = new("Ana", 29);
Console.WriteLine($"{nameAndAge.First} is {nameAndAge.Second}");
- The compiler infers from context, or you spell it out explicitly, exactly like any other generic type usage —
T1becomesstring,T2becomesint, for this instance.
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:
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.- Nothing here needed
objector a cast anywhere —Firstis always genuinely astring,Secondalways genuinely anint, for this particular instance.
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: 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.
Analogy
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
- The compiled assembly contains exactly one metadata entry for
Repository<T>— the open generic type.Tis recorded as an unfilled placeholder, not duplicated per usage.
- Once instantiated,
Repository<Product>andRepository<Customer>are genuinely distinct types at runtime — callingtypeof(Repository<Product>)andtypeof(Repository<Customer>)returns two differentTypeobjects. Each closed generic type gets its own static fields, if the class declares any, entirely separate from the others.
- A subtle but important consequence: if
Repository<T>declared astatic int InstanceCount;field,Repository<Product>andRepository<Customer>would each maintain their own, completely independent counter — they do not share static state, even though they come from the same class definition.
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>orDictionary<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
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
- A generic class declares one or more type parameters that its fields, properties, constructors, and methods can all freely use.
- The class definition itself (
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. - A class can declare multiple, independent type parameters —
Pair<T1, T2>,Dictionary<TKey, TValue>— each filled in separately at the point of use. - Different closed generic types built from the same class (
Repository<Product>vsRepository<Customer>) are genuinely distinct runtime types, with independent static state. - A generic
Repository<T>is a realistic, everyday example of this pattern in real applications.
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.