Every BiblioTech example you've seen so far has worked with concrete types: a method that takes a Book, a list meant only for LibraryItem. But what happens when you need the same logic — storing elements, searching for them, counting them — for Member, for Loan, or for any future type that doesn't even exist yet? Writing a separate class for each type would mean repeating the same code over and over. Generics solve exactly this problem: they let you write a class or a method once, leaving the concrete type it will work with as just another parameter, decided when it's used. This lesson builds a reusable Repository<T> and specializes it for BiblioTech's items and members, laying the foundation on which the Library class will be built in the next lesson.

Contents

  1. The problem without generics: code duplication, or object and boxing
  2. Generic classes: Repository<T>
  3. Generic methods
  4. Constraints: where T : ...
  5. Specializing Repository<T> for BiblioTech
  6. Generics with several type parameters (a preview of Dictionary<TKey, TValue>)

  1. The problem without generics: code duplication, or object and boxing

Without generics, there are two common ways of writing a reusable class for "any type," and neither is satisfactory. The first is to duplicate the entire class, changing only the type:

class BookRepository
{
    private List<Book> _items = new List<Book>();
    public void Add(Book item) => _items.Add(item);
}

class MemberRepository
{
    private List<Member> _items = new List<Member>();
    public void Add(Member item) => _items.Add(item);
}

The code is identical except for the type: any fix or improvement has to be repeated in every copy. The second alternative, which predates generics in very old versions of .NET, was to use object as a universal type:

class ObjectRepository
{
    private List<object> _items = new List<object>();
    public void Add(object item) => _items.Add(item);
}

This is indeed reusable, but it has two serious problems: all compile-time type checking is lost (nothing prevents adding a Member to a repository meant for Book, and the error would only show up at run time when converting it back), and, for value types like int or DateTime, storing them as object forces a process called boxing: wrapping the value in a dynamically allocated object (and unboxing when retrieving it), with a measurable performance cost that repeats on every operation.

List<object> numbers = new List<object>();
numbers.Add(42);              // boxing: the int 42 is wrapped in an object on the heap
int value = (int)numbers[0];  // unboxing: extracted back, with an explicit conversion

Generics solve both problems at once: compile-time type safety, no code duplication, and no boxing for value types.

  1. Generic classes: Repository<T>

A generic class declares one or more type parameters between < and > next to its name (by convention, a single uppercase letter such as T, for Type), and uses them as if they were a regular type inside the class:

class Repository<T>
{
    private List<T> _items = new List<T>();

    public void Add(T item)
    {
        _items.Add(item);
    }

    public int Count => _items.Count;
}

T isn't any concrete type: it's a placeholder that the compiler replaces with the real type at the point where the class is used, given between <>:

Repository<Book> bookRepository = new Repository<Book>();
bookRepository.Add(new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4"));
// bookRepository.Add(new Member(1, "Ana Martinez")); // Compilation error: Member isn't a Book

Repository<Member> memberRepository = new Repository<Member>();
memberRepository.Add(new Member(1, "Ana Martinez"));

Console.WriteLine(bookRepository.Count);   // 1
Console.WriteLine(memberRepository.Count); // 1

With a single definition of Repository<T>, the compiler generates (conceptually) a specialized version for each type it's used with, with full type checking: the error of trying to add a Member to Repository<Book> is caught at compile time, not at run time.

A Find method completes the class, taking a search criterion as a parameter (using Func<T, bool>, already familiar from the Delegates and Events lesson):

class Repository<T>
{
    private List<T> _items = new List<T>();

    public void Add(T item)
    {
        _items.Add(item);
    }

    public T? Find(Func<T, bool> criterion)
    {
        foreach (T item in _items)
        {
            if (criterion(item))
            {
                return item;
            }
        }

        return default; // default(T): null for reference types, 0/false/etc. for value types
    }

    public int Count => _items.Count;
}

default (equivalent to default(T)) returns T's default value, whatever it may be: null if T is a reference type (like Book), or the corresponding default value (0, false, a minimum date...) if T is a value type. This is something only a generic can write without knowing in advance what T is.

  1. Generic methods

Besides full generic classes, a single method (inside a regular or a generic class) can declare its own type parameter, independent of the one belonging to the containing class:

class Utilities
{
    public static T FirstOrDefault<T>(List<T> list, T defaultValue)
    {
        return list.Count > 0 ? list[0] : defaultValue;
    }
}
List<string> names = new List<string> { "Ana", "Luis" };
string first = Utilities.FirstOrDefault(names, "No name"); // "Ana"

List<int> empty = new List<int>();
int firstNumber = Utilities.FirstOrDefault(empty, -1); // -1

Notice that there was no need to write Utilities.FirstOrDefault<string>(...) explicitly: the compiler infers the type T from the arguments (names is List<string>, so T is string). Writing the type explicitly between <> is still valid, and sometimes necessary, but it's rarely needed.

  1. Constraints: where T : ...

With no constraint at all, inside a generic class or method you can only assume that T is "any type," which severely limits what you can do with it (you couldn't even call item.Title, because not every type has that property). A constraint (where T : ...) narrows the set of accepted types in exchange for being able to assume more about them inside the class:

class LendableRepository<T> where T : ILendable
{
    private List<T> _items = new List<T>();

    public void Add(T item)
    {
        _items.Add(item);
    }

    public List<T> GetAvailable()
    {
        List<T> result = new List<T>();
        foreach (T item in _items)
        {
            if (item.Available) // valid: T is constrained to ILendable, which declares Available
            {
                result.Add(item);
            }
        }
        return result;
    }
}

Thanks to where T : ILendable (recalling the interface from this module's first lesson), the compiler knows that any T this class is used with will have, at minimum, Available, Lend(), and Return(), and it allows them to be used inside the generic class with no additional conversion.

Constraint Meaning
where T : LibraryItem T must be LibraryItem or an inheriting class
where T : ILendable T must implement the ILendable interface
where T : class T must be a reference type (not a struct)
where T : struct T must be a value type
where T : new() T must have a public parameterless constructor, allowing new T() inside the generic class
Several at once where T : LibraryItem, ISearchable — all must be satisfied

  1. Specializing Repository<T> for BiblioTech

With Repository<T> already defined, it's specialized for the two central types of BiblioTech's model:

Repository<LibraryItem> itemRepository = new Repository<LibraryItem>();
itemRepository.Add(new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4"));
itemRepository.Add(new Magazine("National Geographic", "Various authors", 302));

LibraryItem? found = itemRepository.Find(i => i.Title == "Hopscotch");
if (found is Book foundBook)
{
    Console.WriteLine($"Found: {foundBook.Isbn}");
}

Repository<Member> memberRepository = new Repository<Member>();
memberRepository.Add(new Member(1, "Ana Martinez"));
memberRepository.Add(new Member(2, "Luis Gomez"));

Member? foundMember = memberRepository.Find(m => m.Id == 2);
Console.WriteLine(foundMember?.Name); // "Luis Gomez"

Repository<LibraryItem> and Repository<Member> share exactly the same Add, Find, and Count code, written once. This same Repository<T> could, in principle, back the Library class's catalog internally; in the next lesson, however, Library will be built directly on top of List<T> and Dictionary<TKey, TValue> (.NET's standard collections), which is the more common approach in real code when the extra logic of a custom repository isn't needed. Both approaches are valid; what matters in this section is seeing generics applied to a complete example.

  1. Generics with several type parameters (a preview of Dictionary<TKey, TValue>)

A generic class or method can declare more than one type parameter at once, separated by commas. The most common example, which you'll use in the next lesson, is Dictionary<TKey, TValue>, with two independent type parameters: the key's type and the type of the associated value.

class Pair<TKey, TValue>
{
    public TKey Key { get; }
    public TValue Value { get; }

    public Pair(TKey key, TValue value)
    {
        Key = key;
        Value = value;
    }
}

Pair<int, Member> memberPair = new Pair<int, Member>(1, new Member(1, "Ana Martinez"));
Console.WriteLine($"{memberPair.Key} -> {memberPair.Value.Name}"); // "1 -> Ana Martinez"

This idea — two independent type parameters, one for the key and one for the value — is exactly what Dictionary<TKey, TValue> uses, which you'll study in depth in the next lesson to index BiblioTech's members by their Id.

Common Mistakes and Tips

  • Forgetting where T : ... and expecting to use members of a concrete type: without a constraint, inside Repository<T> you can't write item.Title, because the compiler doesn't know that T will have that property; add the necessary constraint (where T : LibraryItem, or an interface) to be able to access those members.
  • Confusing a class's type parameter with a method's: class Repository<T> and T Method<T>(...) inside a non-generic class are different things; a generic method inside a generic class can even reuse the name T, although it's best avoided to keep the code easy to read.
  • Using object "for simplicity" instead of a generic: it gives up compile-time type checking and, for value types, introduces unnecessary boxing; with generics available since the early modern versions of C#, there's rarely a good reason to prefer object.
  • Forgetting that default can be surprising for value types: default(int) is 0, default(bool) is false; if a generic method returns default to signal "not found" and T is int, a result of 0 could be confused with an actual value that was found. With reference types (like LibraryItem), default is always null, easier to distinguish with is null.
  • Tip: name type parameters with a single letter when the purpose is generic and obvious from context (T, TKey, TValue), and with a more descriptive name (TItem, TMember) only if it adds real clarity to a class with several related type parameters.

Exercises

  1. Define class Repository<T> with an internal List<T>, a method Add(T item), and a Count property. Create a Repository<Book> and a Repository<Member>, add two elements to each, and show the count of each repository.

  2. Add to Repository<T> a method T? Find(Func<T, bool> criterion) that returns the first element satisfying the criterion, or default if none does. Search by title in a Repository<Book> and by Id in a Repository<Member>.

  3. Define class LendableRepository<T> where T : ILendable with a method List<T> GetAvailable() that returns only the elements with Available == true. Add two books (one lent out, one available) and check that GetAvailable() returns only the free one.

Solutions

class Repository<T>
{
    private List<T> _items = new List<T>();

    public void Add(T item)
    {
        _items.Add(item);
    }

    public int Count => _items.Count;
}

Repository<Book> bookRepository = new Repository<Book>();
bookRepository.Add(new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4"));
bookRepository.Add(new Book("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1"));

Repository<Member> memberRepository = new Repository<Member>();
memberRepository.Add(new Member(1, "Ana Martinez"));
memberRepository.Add(new Member(2, "Luis Gomez"));

Console.WriteLine(bookRepository.Count);   // 2
Console.WriteLine(memberRepository.Count); // 2
public T? Find(Func<T, bool> criterion)
{
    foreach (T item in _items)
    {
        if (criterion(item))
        {
            return item;
        }
    }
    return default;
}

Book? foundBook = bookRepository.Find(b => b.Title == "Hopscotch");
Console.WriteLine(foundBook?.Isbn); // "978-84-376-0495-4"

Member? foundMember = memberRepository.Find(m => m.Id == 2);
Console.WriteLine(foundMember?.Name); // "Luis Gomez"
class LendableRepository<T> where T : ILendable
{
    private List<T> _items = new List<T>();

    public void Add(T item) => _items.Add(item);

    public List<T> GetAvailable()
    {
        List<T> result = new List<T>();
        foreach (T item in _items)
        {
            if (item.Available)
            {
                result.Add(item);
            }
        }
        return result;
    }
}

LendableRepository<Book> repository = new LendableRepository<Book>();
Book available = new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4");
Book lentOut = new Book("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1");
lentOut.Lend();

repository.Add(available);
repository.Add(lentOut);

List<Book> availableBooks = repository.GetAvailable();
Console.WriteLine(availableBooks.Count);      // 1
Console.WriteLine(availableBooks[0].Title);   // "Hopscotch"

Conclusion

In this lesson you've learned to write generic classes and methods, to constrain a type parameter with where T : ... so you can use concrete members inside the generic class, and to avoid both code duplication and object boxing. Repository<T> demonstrates that a single implementation can safely serve both LibraryItem and Member.

Generics are, precisely, the foundation .NET's collections are built on, which you'll use from now on without a second thought: List<T>, already familiar from Module 1, and Dictionary<TKey, TValue>, with two type parameters like the Pair<TKey, TValue> from the last section. The next lesson, Collections, revisits both to finally build the complete Library class: a central catalog of items and members, managed in memory, ready to be queried with LINQ in the following lesson.

© Copyright 2026. All rights reserved