A generic class is a class whose definition includes one or more type parameters, written in angle brackets after the class name. The type parameter stands in for a real type that gets filled in later, when someone actually uses the class. This lesson covers how to declare a generic class, how to use multiple type parameters, what fields, properties, constructors, and methods look like inside one, and how static members behave when the same generic class is used with different type arguments.
A generic class declaration looks almost identical to a regular class, with one addition: a type parameter in angle brackets right after the class name. The convention is to use a single capital letter T for one type parameter, where T stands for "type."
A small in-memory Repository<T> that stores items keyed by an integer ID. The example focuses on the generic mechanics, not on database concerns, so the storage is just a list.
Look closely at what the type parameter T does. Inside the class body, T is used in three places: the field List<T> _items, the parameter type of Add(T item), and the return type of Get(...). None of those mention string anywhere. The class is written once, in a type-agnostic way, and the compiler substitutes the real type at the point where you instantiate it.
When the program writes new Repository<string>(), the compiler treats every T inside Repository<T> as string for that specific use. _items becomes a List<string>, Add takes a string, and Get returns a string. If you write new Repository<int>() somewhere else in the same program, every T in that use becomes int. The class definition itself is reusable across any element type, with no copy-paste and no object-based casting.
The same Repository<T> works for product objects too. Here's the same class storing Product instances instead of strings.
The repository code didn't change. Only the type argument did. That's the central value of a generic class: the container or wrapper is written once, and any reasonable element type slots in.
No boxing happens here, even for Repository<int>. The compiler generates specialized code for value-type arguments, so the int is stored directly inside the backing array. Compare that to a hypothetical non-generic ObjectRepository that stored object: every int would be boxed onto the heap on the way in and unboxed on the way out.
When you write class Repository<T>, the class itself is an open type: it has unfilled type parameters. You can't create an instance of Repository<T> directly because the compiler doesn't know what T is supposed to be. Once you write Repository<Product>, you've filled in the parameter and produced a closed type, which is a concrete type you can construct.
The _Generics Basics_ lesson covers the precise terminology around open and closed types in more depth. For now, the practical rule is simple: every time you write new Repository<...>(), you have to fill in the angle brackets with a real type. Different fillings produce different closed types, and the rest of this lesson explores what that means.
A generic class can have more than one type parameter. The classic example is a key-value lookup, which needs one type for keys and a different type for values. C# uses descriptive names with a T prefix in this case: TKey and TValue.
A small in-memory cache that maps a key to a value. The key type and the value type are independent.
The where TKey : notnull part is a constraint. It's included here only because Dictionary<TKey, TValue> requires that keys not be null, and the constraint is the standard way to satisfy that requirement. For the rest of this lesson, ignore the where clause and focus on how TKey and TValue are used inside the class.
Using Cache<TKey, TValue> looks just like using Repository<T>, except you fill in two type slots.
The prices cache is Cache<int, decimal>: keys are integer product IDs, values are decimal prices. The names cache is Cache<string, string>: keys and values are both strings. Same class definition, two completely different closed types.
The order of type parameters matters. Cache<int, decimal> is not the same type as Cache<decimal, int>. The first one expects int keys and decimal values; the second expects decimal keys and int values. The compiler will reject mismatches with a clear error message.
The error tells you exactly which type was expected and which was passed. That's the kind of compile-time safety generics buy you.
The naming patterns for type parameters are not enforced by the compiler. They're conventions that make code easier to read, and they're consistent across the .NET base class library.
| Use case | Convention | Example |
|---|---|---|
| Single type parameter | T | List<T>, Repository<T> |
| Key in a key-value pair | TKey | Dictionary<TKey, TValue> |
| Value in a key-value pair | TValue | Dictionary<TKey, TValue> |
| Element type | TItem | Less common, used when T is taken |
| Result type | TResult | Func<T, TResult> |
| Two unrelated types | T1, T2 | Tuple<T1, T2> (older code) |
The single-T style is for classes that work with one element type. The TKey/TValue style is for classes where the parameters have distinct roles you can name. The convention is also to always prefix multi-parameter type parameters with T, so TKey and TValue rather than Key and Value. The T prefix is what tells a reader "this is a type parameter, not a regular class."
If your generic class has a parameter with a specific role, name it for the role. Result<TSuccess, TError> is more readable than Result<T1, T2> because the reader can see what each slot is for without going to the documentation.
Inside a generic class, fields and properties can use the type parameters anywhere a normal type would appear. Constructors can take parameters of the type parameter types too. Everything you'd normally write in a class works, you just have T, TKey, or TValue available as type names.
Here's a Wishlist<T> that tracks the owner's name alongside the items, with a constructor that takes both.
A few things are happening here. The field _items is a List<T>, where T is whatever the user supplies when constructing the wishlist. The Owner property is a plain string because it's not part of the generic surface. The LastAdded property is declared as T?, which means "nullable T" so the property can hold a default when the wishlist is empty (the _default Keyword in Generics_ lesson covers default(T) in detail).
The constructor mixes both worlds: it accepts a string for Owner (a concrete type) and a T for firstItem (the type parameter). When the program writes new Wishlist<string>("Alice", "Keyboard"), the compiler binds T to string and then checks that "Keyboard" is a valid string. It is, so the call compiles.
If you tried new Wishlist<int>("Alice", "Keyboard"), the compiler would bind T to int and then reject "Keyboard" because the constructor's second parameter, after substitution, would be int. Same constructor, different rules depending on the closed type.
A method declared inside a generic class can use the class's type parameters freely. The method itself is not "generic" in the _Generic Methods_ sense; it just inherits the type parameter from the surrounding class. The _Generic Methods_ lesson covers methods that introduce their own type parameters, which is a different feature.
Here's Repository<T> extended with a few more methods to show the patterns.
Each method uses T as if it were a normal type. Replace takes a T, stores it in a local T variable, and returns a T. Contains calls List<T>.Contains, which works for any element type because List<T> is itself generic. GetAll returns an IReadOnlyList<T>, which is a generic interface that surfaces the same T through to the caller.
What you can't do here is call any operation on T that isn't defined on every possible type. For example, you can't write item.Price > 0 inside the repository, because T might be string, int, or anything else, and most types don't have a Price property. Adding that kind of capability requires constraints. Until you constrain T, the only operations available on a T value are the ones defined on object (such as Equals, GetHashCode, and ToString) plus assignment and equality with null.
The compiler is right to reject this. We can't restrict T to a type that has a Price yet. The _Generic Constraints_ lesson covers how to add that restriction with a where clause.
This is the surprising part of generics. A static member declared on a generic class belongs to each closed type separately, not to the open generic class as a whole.
What does that mean in practice? If Repository<T> has a static field Count, then Repository<Product>.Count and Repository<Customer>.Count are two completely independent variables. Incrementing one does not affect the other. Each closed type gets its own static storage.
Here's an example that demonstrates the behavior with a small instance counter.
Three Repository<Product> instances were created, so Repository<Product>.InstanceCount is 3. Two Repository<Customer> instances were created, so Repository<Customer>.InstanceCount is 2. The two counters never interfered. The runtime treats Repository<Product> and Repository<Customer> as separate types at runtime, and each has its own static storage.
The mental model is that the open type Repository<T> is a template. Every time the compiler encounters a new closed type (Repository<Product>, Repository<Customer>, Repository<Order>, and so on), it stamps out a separate runtime type from that template. Each stamp has its own copy of every static field and property.
The open type at the top is just a definition. Each closed type below it is a real runtime type with its own static storage. The InstanceCount boxes are not shared. The diagram also includes Repository<Order> to show that you don't have to instantiate a closed type for it to exist as a type, but its static field stays at zero until something creates an instance.
This per-closed-type behavior changes how to design generic classes. A few things to watch out for:
Repository<T>.Repository<T> runs once for Repository<Product>, once for Repository<Customer>, and once for every other closed type that gets used. Not once total.Each closed value-type instantiation (Repository<int>, Repository<double>, Repository<DateTime>, ...) gets its own native code at runtime, generated by the JIT. Each closed reference-type instantiation shares one set of native code across all reference-type arguments. This is why generic collections with value-type elements have no boxing overhead: the JIT has produced specialized code that works directly on the value layout.
Let's pull everything together. Here's a slightly larger example that uses one generic Repository<T> to store three different entity types: products, customers, and orders. The repository keys each item by an integer ID and provides the operations a beginner store would actually need.
A few things to call out from this example.
The where T : IHasId clause is a constraint that says "you can only close Repository<T> over types that implement IHasId." This is what lets the repository call item.Id inside Add. The _Generic Constraints_ lesson covers constraints in full, including all the kinds you can write. The constraint is included here only because the example needs to grab the ID off each item, and that's not possible on an unconstrained T.
The static counter OperationsPerformed is per closed type, as the output shows. Repository<Product> performed 4 operations (3 adds + 1 find). Repository<Customer> performed 3 (2 adds + 1 find). Repository<Order> performed 3 (2 adds + 1 find). The three counters never interfered with each other, and the program didn't have to do anything special to keep them separate. That's the closed-type-per-instantiation rule at work.
The FindById method returns T? and uses default as the "not found" sentinel. For reference types like Product and Customer, default is null. The _default Keyword in Generics_ lesson covers how default(T) works for value types and the subtleties around nullable annotations.
The All method returns IEnumerable<T>. This means callers can iterate the items without being able to mutate the dictionary directly. That's a reasonable encapsulation choice for a repository: callers get a view, not a handle to the underlying storage. The _Generic Interfaces_ lesson covers IEnumerable<T> in depth.
object?A reasonable question: why bother with generics at all? Couldn't a single Repository class store object and handle every type that way?
The answer is yes, technically, but it would lose two things that matter: type safety and performance.
The ObjectRepository stores a mix of types because everything inherits from object. The problem appears when a caller tries to retrieve a value: it comes back as object, the caller has to cast to the type they expected, and if they cast wrong they get an InvalidCastException at runtime. The compiler couldn't help.
With Repository<string>, the compiler enforces that only strings go in and only strings come out. The mistake of adding an int is caught at the call site, not in production. That's the safety win.
The performance win shows up when T is a value type. A Repository<int> stores raw int values directly in its backing List<int>. An ObjectRepository storing ints would box every one onto the heap, which is two allocations per add (one for the box, indirectly via list resizes) plus an unbox on every read. For a million-element repository of ints, that's the difference between four megabytes of contiguous memory and a million tiny heap objects.
The generic class is also more readable. Repository<Product> tells the reader exactly what's stored. ObjectRepository tells them nothing and requires every caller to remember and cast correctly.
To round out the patterns, here's one more shape of generic class: a small Stock<T> that tracks how many units of each item are in inventory, regardless of what kind of item it is. The class uses T as the dictionary key, so any item type that can be a dictionary key works.
The pattern here is that T is being used as the dictionary's key type. The where T : notnull constraint, again, is what keeps Dictionary<TKey, TValue> happy. Apart from that, the class is identical in shape to the earlier examples: a generic field, generic methods, and a non-generic property (DistinctItems) that exposes a count.
If the inventory were keyed by product ID instead of product name, the same class would work with Stock<int>. The generic class doesn't care which it is. That flexibility is what generics buy you.
9 quizzes