Skip to main content

Generics: One PagedResult for Every Type – C# | Lesson 48

Published / updated

Introduction​

The Web API will return students, invoices, and attendance records one page at a time: "page 2 of 101, ten items per page". Without generics, you would write a StudentPage class, then an InvoicePage class, then a TeacherPage class, each identical except for the type of item inside.

Generics let you write a class or a method once, with a placeholder for the type. You have used them since Lesson 47: List<Student> is the generic List<T> with Student put in place of T. In this lesson, you write your own.

By the end of this lesson, you will be able to:

  • Explain type parameters such as T and why generics exist.
  • Write a generic class or record, and a generic method.
  • Restrict a type parameter with constraints, such as where T : ISoftDeletable.
  • Explain how generics prevent the run-time errors of older collections.
  • Discuss when a generic design helps and when it hurts.

Why this matters​

Every list endpoint in Module 11 returns a page of results. One PagedResult<T> serves them all, so paging rules, such as the maximum page size, are written and tested once.

Generics are also how .NET itself is built: collections, LINQ in Lesson 50, Task<T> in Lesson 53, and the dependency injection in Module 10 all use them. Reading modern C# means reading angle brackets comfortably.

The concept​

Example: a type parameter​

Example
public record PagedResult<T>(IReadOnlyList<T> Items, int Page, int PageSize, int TotalCount);

T is a type parameter: a placeholder for a real type, chosen by whoever uses the class.

You writeT becomesItems holds
PagedResult<Student>StudentStudents
PagedResult<StudentDto>StudentDtoStudent DTOs
PagedResult<string>stringText

Think of the school's "Lost property" form. It has one blank, "Item: ______", and works for a water bottle, a sweater, or a library book.

Nobody prints a separate form for each kind of item. The form is generic; each filled-in copy has a specific type.

Example: generic methods​

A method can have its own type parameter, written after its name:

Example
public static PagedResult<T> ToPage<T>(List<T> all, int page, int pageSize)

When you call Paging.ToPage(students, 1, 10), the compiler sees a List<Student> and works out that T is Student. This is called type inference: you rarely need to write the type in angle brackets yourself.

Example: constraints​

Inside a generic method, the compiler knows nothing about T, so it allows only what every type can do. A constraint promises more:

ConstraintT must beAllows the method to
where T : ISoftDeletableA type implementing the interfaceUse IsDeleted
where T : PersonPerson or a derived classUse FullName, Role
where T : classA reference typeUse null
where T : structA value typeUse it as T? meaning Nullable<T>
where T : new()A type with an empty constructorWrite new T()

Calling with a type that breaks the constraint does not compile. SoftDelete.ActiveOnly(new List<string>()) fails with error CS0311: The type 'string' cannot be used as type parameter 'T' ... There is no implicit reference conversion from 'string' to 'ISoftDeletable'.

Example: life before generics​

The first version of .NET had collections such as ArrayList, which held object: any value at all. Nothing stopped a string from being added to a list of marks:

Example
ArrayList marks = new ArrayList();
marks.Add(92m);
marks.Add("85"); // compiles: an ArrayList accepts anything

The mistake shows up only when the loop casts each item back to decimal, at run time: InvalidCastException: Unable to cast object of type 'System.String' to type 'System.Decimal'. With List<decimal>, the same line fails while you type it: error CS1503: Argument 1: cannot convert from 'string' to 'decimal'.

Generics move the error from the user's screen to your editor, which is the cheapest place to find any bug. They are also faster, because values such as decimal no longer have to be wrapped inside object, a step called boxing.

Apply it to the SMS​

Step 1: PagedResult and Paging​

src/SchoolApp.Core/Common/PagedResult.cs
namespace SchoolApp.Core.Common;

// Lesson 48: one page of any kind of item. T is a type parameter: PagedResult<StudentDto>,
// PagedResult<FeeInvoiceSummaryDto>, and so on, all from one definition.
public record PagedResult<T>(IReadOnlyList<T> Items, int Page, int PageSize, int TotalCount)
{
public int TotalPages => (TotalCount + PageSize - 1) / PageSize; // rounds up
public bool HasNextPage => Page < TotalPages;
}

// Lesson 48: a generic method in a normal static class. The compiler works out T from the list.
public static class Paging
{
public const int MaxPageSize = 100;

public static PagedResult<T> ToPage<T>(List<T> all, int page, int pageSize)
{
if (page < 1)
{
throw new ArgumentOutOfRangeException(nameof(page), "Page numbers start at 1.");
}
if (pageSize < 1 || pageSize > MaxPageSize)
{
throw new ArgumentOutOfRangeException(nameof(pageSize), $"The page size must be between 1 and {MaxPageSize}.");
}

int start = (page - 1) * pageSize;
int count = Math.Max(0, Math.Min(pageSize, all.Count - start));
List<T> items = count > 0 ? all.GetRange(start, count) : new List<T>();
return new PagedResult<T>(items, page, pageSize, all.Count);
}
}

PagedResult<T> is a generic record. Paging is an ordinary static class with one generic method. GetRange copies part of a list: from position start, count items.

Math.Min and Math.Max keep the count between 0 and the page size. A page past the end returns an empty list instead of failing, which is what an API client expects when it asks for page 102 of 101.

Step 2: a constrained generic method​

src/SchoolApp.Core/Common/SoftDelete.cs
using SchoolApp.Core.Interfaces;

namespace SchoolApp.Core.Common;

// Lesson 48: a generic method with a constraint. "where T : ISoftDeletable" means T can be any
// type, as long as it implements ISoftDeletable, so the method may use IsDeleted.
public static class SoftDelete
{
public static List<T> ActiveOnly<T>(List<T> items) where T : ISoftDeletable
{
List<T> active = new List<T>();
foreach (T item in items)
{
if (!item.IsDeleted)
{
active.Add(item);
}
}
return active;
}
}

Step 3: the program​

Show src/SchoolApp.Console/Program.cs
src/SchoolApp.Console/Program.cs
// Lesson 48: generics. One PagedResult<T> and one ActiveOnly<T> for many types.
using SchoolApp.Core.Common;
using SchoolApp.Core.Entities;
using SchoolApp.Core.Enums;

List<Student> students = new List<Student>();
foreach (Enrollment enrollment in DataFiles.LoadClass10Enrollments())
{
students.Add(enrollment.Student!);
}

// 1. A generic record: one page of students, as the Web API will return it.
PagedResult<Student> firstPage = Paging.ToPage(students, page: 1, pageSize: 10);
PagedResult<Student> lastPage = Paging.ToPage(students, page: 101, pageSize: 10);
Console.WriteLine($"Page {firstPage.Page} of {firstPage.TotalPages}: {firstPage.Items.Count} students, first {firstPage.Items[0].FullName}, next page: {firstPage.HasNextPage}");
Console.WriteLine($"Page {lastPage.Page} of {lastPage.TotalPages}: {lastPage.Items.Count} students, next page: {lastPage.HasNextPage}");

// 2. The same method pages any type: here, plain strings.
List<string> admissionNumbers = new List<string>();
foreach (Student student in students)
{
admissionNumbers.Add(student.AdmissionNumber);
}
PagedResult<string> numbersPage = Paging.ToPage(admissionNumbers, page: 2, pageSize: 3);
Console.WriteLine($"Admission numbers, page 2: {string.Join(", ", numbersPage.Items)}");

// 3. Rules inside the generic method protect every caller.
try
{
Paging.ToPage(students, page: 1, pageSize: 500);
}
catch (ArgumentOutOfRangeException ex)
{
Console.WriteLine($"Refused: {ex.Message}");
}

// 4. A constraint: ActiveOnly works for any ISoftDeletable type.
students[1].SoftDelete(new DateTime(2026, 7, 31, 10, 0, 0, DateTimeKind.Utc));
students[2].SoftDelete(new DateTime(2026, 7, 31, 10, 0, 0, DateTimeKind.Utc));
Console.WriteLine($"Students: {students.Count}; active: {SoftDelete.ActiveOnly(students).Count}");

List<Teacher> teachers = new List<Teacher>
{
new Teacher("TCH-001", "Anitha", "Rao", "anitha.rao@nexcodingschool.example", "9876540001", Gender.Female),
new Teacher("TCH-002", "Rahul", "Verma", "rahul.verma@nexcodingschool.example", "9876540002", Gender.Male),
};
teachers[1].SoftDelete(new DateTime(2026, 7, 31, 10, 0, 0, DateTimeKind.Utc));
Console.WriteLine($"Teachers: {teachers.Count}; active: {SoftDelete.ActiveOnly(teachers).Count}");

// 5. You have used generics since Lesson 35: decimal? is short for Nullable<decimal>.
Console.WriteLine($"decimal? is Nullable<decimal>: {typeof(decimal?) == typeof(Nullable<decimal>)}");
Output
Page 1 of 101: 10 students, first Aarav Sharma, next page: True
Page 101 of 101: 6 students, next page: False
Admission numbers, page 2: ADM-2017-0004, ADM-2017-0005, ADM-2017-0006
Refused: The page size must be between 1 and 100. (Parameter 'pageSize')
Students: 1006; active: 1004
Teachers: 2; active: 1
decimal? is Nullable<decimal>: True

Example: paging real data​

The 1,006 Class 10 students make 101 pages of ten. The last page holds the remaining six, and HasNextPage turns false. TotalPages rounds up with (TotalCount + PageSize - 1) / PageSize, a common integer trick: 1,015 divided by 10 is 101 in whole numbers.

The same method then paged admission numbers, plain strings, without a single change. Page 2 of size 3 holds positions 4 to 6: Meera of 10-A, then Rohan and Sana of 10-B.

Example: rules written once​

A page size of 500 was refused. Without such a limit, one request could ask for every row in a table, and a busy API would run out of memory. Because the check lives in the generic method, every list endpoint gets it for free.

ActiveOnly then served two different types with one body: 1,004 of 1,006 students remain active after two soft deletes, and one of the two teachers. Any future soft-deletable type, such as FeePayment, works with it immediately.

Example: Nullable is generic too​

The last line shows that decimal? from Lesson 35 is shorthand for Nullable<decimal>, a generic struct with HasValue and Value. Generics were part of your code long before this lesson gave them a name.

Example: default(T)​

Sometimes a generic method must return "nothing" for an unknown type. default(T), or just default where the type is clear, gives null for reference types and zero for value types.

The homework uses it in FindByCode: when a code is not found, it returns default, which is null for a catalogue of records.

Example: more than one type parameter​

A type can have several type parameters. Dictionary<TKey, TValue> from Lesson 47 has two: Dictionary<string, Student> uses text keys and student values.

Name them so the reader knows each one's job: TKey and TValue, or TEntity and TDto. A single parameter is simply called T, by long convention across all of .NET.

Example: why a List of students is not a List of persons​

Every Student is a Person, so it seems that a List<Student> should be usable as a List<Person>. It is not: List<Person> people = students; fails with error CS0029: Cannot implicitly convert type 'List<Student>' to 'List<Person>'.

The reason is safety. Through a List<Person>, code could add a Teacher, and the list of students would suddenly contain a teacher.

IEnumerable<Person> people = students; does compile, because IEnumerable<T> only reads; nothing can be added through it. This read-only flexibility is called covariance.

Example: generic interfaces ahead​

Generic typeWhat it describesLesson
IEnumerable<T>Anything foreach can walk through49
Func<T, TResult>A method that takes a T and returns a TResult50 and 51
Task<T>Work that will produce a T later53
ILogger<T>A logger named after the class TModule 12

Example: the generic repository debate​

A popular idea is one IRepository<T> with GetById, Add, Update, and Delete for every entity. It saves code at first, and interviewers like to ask about it.

Generic IRepository<T>Specific IStudentRepository (Lesson 46)
Code to writeLessMore
Methods offeredThe same for every table, needed or notExactly what the feature needs
Special queries, such as "by admission number"Awkward to addNatural
Deleting marks or paymentsOffered automatically, even where forbiddenOnly where allowed

This course uses specific repositories, because a generic one offers Delete for tables where the school's rules forbid deleting. A small generic base class shared by specific repositories is a common middle path.

Common mistakes​

  1. Copying a class for each type. Three page classes drift apart. Fix: one generic class.
  2. Using object to "support any type". Errors move to run time and values get boxed. Fix: a type parameter.
  3. Calling members on T without a constraint. The compiler refuses. Fix: add the constraint that promises those members.
  4. Constraints that are too strict. A method that only reads IsDeleted should not require Person. Fix: constrain to the smallest interface that works.
  5. Generic code for one type only. Extra angle brackets with no benefit. Fix: make it generic when the second type appears.

In real projects​

From 16 years of .NET projects

On one API, four teams each wrote their own paging response: one returned page, another pageNumber, a third started counting at zero. The mobile app needed four parsing routines.

We replaced them with one PagedResult<T> in the shared library, with the same names and the same 1-based pages everywhere. The app's paging code shrank to one function.

Generics are not only about saving typing. They give a whole system one consistent shape for the same idea.

Best practices​

  • Use generics when the same logic applies to several types.
  • Name a single type parameter T; name several clearly, such as TKey and TValue.
  • Add the smallest constraint that the method needs.
  • Prefer generic collections over object-based ones such as ArrayList, always.
  • Put shared generic types, such as PagedResult<T>, in the core library.

Recap​

  • A type parameter, such as T, is a placeholder filled in by the caller.
  • Generic classes, records, and methods are written once and used for many types.
  • The compiler usually infers T from the arguments.
  • Constraints, such as where T : ISoftDeletable, allow the method to use more members.
  • Generics catch type mistakes at compile time and avoid boxing.
  • PagedResult<T> and ActiveOnly<T> now serve every type in the SMS.

Homework​

Library module task: Write an ICatalogItem interface with Code and Title, and a generic Catalog<T> where T : ICatalogItem with Add, FindByCode, and Count, using a dictionary inside.

Use one catalog for books and another for magazines, and look up a code that does not exist.

A correct result looks like:

Output
BK-0002: The Jungle Book by Rudyard Kipling
MG-0001: Young Scientist; BK-0099: not found
Books: 2; magazines: 1
Show the solution
library-assignment/048-library-generics.cs
// Library homework, Lesson 48: one generic Catalog<T> for any kind of catalogue item.
// Run with: dotnet run library-assignment/048-library-generics.cs
Catalog<Book> books = new Catalog<Book>();
books.Add(new Book("BK-0001", "Wings of Fire", "A. P. J. Abdul Kalam"));
books.Add(new Book("BK-0002", "The Jungle Book", "Rudyard Kipling"));

Catalog<Magazine> magazines = new Catalog<Magazine>();
magazines.Add(new Magazine("MG-0001", "Young Scientist", "July 2026"));

Book? found = books.FindByCode("BK-0002");
Console.WriteLine($"BK-0002: {found?.Title} by {found?.Author}");
Console.WriteLine($"MG-0001: {magazines.FindByCode("MG-0001")?.Title}; BK-0099: {books.FindByCode("BK-0099")?.Title ?? "not found"}");
Console.WriteLine($"Books: {books.Count}; magazines: {magazines.Count}");

interface ICatalogItem
{
string Code { get; }
string Title { get; }
}

// T can be any type that implements ICatalogItem.
class Catalog<T> where T : ICatalogItem
{
private readonly Dictionary<string, T> _byCode = new Dictionary<string, T>();

public int Count => _byCode.Count;

public void Add(T item) => _byCode.Add(item.Code, item);

public T? FindByCode(string code) => _byCode.TryGetValue(code, out T? item) ? item : default;
}

record Book(string Code, string Title, string Author) : ICatalogItem;

record Magazine(string Code, string Title, string Edition) : ICatalogItem;

The records implement the interface directly in their declaration: record Book(...) : ICatalogItem. Their positional properties Code and Title satisfy the interface.

Quiz​

  1. What is T in PagedResult<T>?

    1. A fixed type called T
    2. A type parameter filled in by the user of the class
    3. A template file
    4. Short for Text
    Show answer

    Answer: B. A type parameter filled in by the user of the class PagedResult<Student> puts Student in place of T.

  2. Paging.ToPage(students, 1, 10) has no angle brackets. How does the compiler know T?

    1. It guesses object
    2. Type inference from the List<Student> argument
    3. It reads the file
    4. T is always Student
    Show answer

    Answer: B. Type inference from the List<Student> argument The argument's type tells the compiler what T must be.

  3. What does where T : ISoftDeletable allow inside the method?

    1. Using new T()
    2. Using IsDeleted and the other ISoftDeletable members
    3. Nothing extra
    4. Using T as a string
    Show answer

    Answer: B. Using IsDeleted and the other ISoftDeletable members The constraint promises every T implements the interface.

  4. Why is List<decimal> safer than ArrayList for marks?

    1. It is bigger
    2. Adding a string fails at compile time instead of at run time
    3. It sorts automatically
    4. It uses less disk space
    Show answer

    Answer: B. Adding a string fails at compile time instead of at run time Type mistakes become build errors.

  5. What does default return for a reference type T?

    1. 0
    2. null
    3. An empty object
    4. An exception
    Show answer

    Answer: B. null default is null for reference types and zero for value types.

Interview questions​

Questions are grouped by experience level. Click a question to see a model answer. All questions: Advanced C# interview questions.

Fresher (0-2 yrs)​

F What are generics in C#? concept

Classes, records, interfaces, and methods written with a type parameter, such as T, that the caller fills in. List<Student> and PagedResult<StudentDto> reuse one definition for many types, with full compile-time type checking.

See the example · Permalink

F What is a generic method? concept

A method with its own type parameter, written after its name, such as ToPage<T>(List<T> all, int page, int pageSize). The compiler usually infers T from the arguments, so callers rarely write it.

See the example · Permalink

F Why are generic collections better than ArrayList? concept

ArrayList holds object, so wrong types are accepted and fail later with InvalidCastException, and value types are boxed. List<decimal> rejects a string at compile time and stores decimals directly, which is safer and faster.

See the example · Permalink

Mid (3-5 yrs)​

M What are generic constraints? Give examples. concept

Rules on what a type parameter may be, written with where: where T : ISoftDeletable, where T : class, where T : struct, where T : new(). They let the generic code use the promised members, and the compiler rejects types that do not qualify (CS0311).

See the example · Permalink

M What is boxing, and how do generics avoid it? concept

Boxing wraps a value type, such as a decimal, in an object on the heap so it can be stored as object; unboxing casts it back. It costs memory and time. Generic collections store the value type directly, so no boxing happens.

See the example · Permalink

M Why can't a List<Student> be assigned to a List<Person>, while IEnumerable<Person> works? concept

Through a List<Person>, code could add a Teacher into a list of students, so C# forbids it (CS0029). IEnumerable<T> only reads, so treating students as persons is safe; this is covariance, marked by out T on the interface.

See the example · Permalink

Senior (6-9 yrs)​

S Four teams each built their own paging response for the API, and the mobile app struggles. What do you do? scenario

Define one generic PagedResult<T> in the shared library with agreed names, 1-based pages, and a maximum page size, and migrate endpoints to it gradually, keeping old shapes until clients move. Clients then need one paging routine.

How to answer: Cover the shared contract, the rules it enforces (page size limits), and a safe migration plan with versioning or a transition period for existing clients.

See the example · Permalink

S What are the pros and cons of a generic repository, IRepository<T>? design

Pros: less repetitive code and a uniform interface. Cons: every entity gets the same methods, including Delete where rules forbid it; special queries are awkward; and it often duplicates what an ORM already provides. Specific repositories, possibly sharing a small generic base, are a common choice.

How to answer: Present both sides fairly, tie the decision to business rules (forbidden deletes) and query needs, and mention that EF Core's DbSet already acts as a generic repository.

See the example · Permalink

Architect / Lead (10+ yrs)​

A How would you design shared generic building blocks for a large API without over-generalizing? design

Generalize proven, repeated shapes, such as paging, result wrappers, and soft-delete filters, with minimal constraints, in a shared library. Keep domain logic specific. Make something generic only when a second real use appears, and document the conventions so teams use them consistently.

How to answer: Show judgment: name good candidates and bad ones (generic services hiding business rules), discuss constraints and naming, and the cost of changing widely used generic types.

See the example · Permalink

Resources​

  • Code for this lesson: git tag lesson-48-generics (code repository coming soon)
  • Files: src/SchoolApp.Core/Common/PagedResult.cs, src/SchoolApp.Core/Common/SoftDelete.cs, src/SchoolApp.Console/Program.cs, library-assignment/048-library-generics.cs
  • Video: coming soon

What's next​

List<T> is one way to hold many items. Next: IEnumerable<T>, the interface behind every foreach, and IQueryable<T>, which turns C# into SQL.

Knowing the difference is one of the most common interview questions. Lesson 49: IEnumerable vs IQueryable

Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.