Generics: One PagedResult for Every Type – C# | Lesson 48
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
Tand 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
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 write | T becomes | Items holds |
|---|---|---|
PagedResult<Student> | Student | Students |
PagedResult<StudentDto> | StudentDto | Student DTOs |
PagedResult<string> | string | Text |
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:
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:
| Constraint | T must be | Allows the method to |
|---|---|---|
where T : ISoftDeletable | A type implementing the interface | Use IsDeleted |
where T : Person | Person or a derived class | Use FullName, Role |
where T : class | A reference type | Use null |
where T : struct | A value type | Use it as T? meaning Nullable<T> |
where T : new() | A type with an empty constructor | Write 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:
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
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
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
// 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>)}");
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 type | What it describes | Lesson |
|---|---|---|
IEnumerable<T> | Anything foreach can walk through | 49 |
Func<T, TResult> | A method that takes a T and returns a TResult | 50 and 51 |
Task<T> | Work that will produce a T later | 53 |
ILogger<T> | A logger named after the class T | Module 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 write | Less | More |
| Methods offered | The same for every table, needed or not | Exactly what the feature needs |
| Special queries, such as "by admission number" | Awkward to add | Natural |
| Deleting marks or payments | Offered automatically, even where forbidden | Only 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
- Copying a class for each type. Three page classes drift apart. Fix: one generic class.
- Using
objectto "support any type". Errors move to run time and values get boxed. Fix: a type parameter. - Calling members on
Twithout a constraint. The compiler refuses. Fix: add the constraint that promises those members. - Constraints that are too strict. A method that only reads
IsDeletedshould not requirePerson. Fix: constrain to the smallest interface that works. - Generic code for one type only. Extra angle brackets with no benefit. Fix: make it generic when the second type appears.
In real 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 asTKeyandTValue. - Add the smallest constraint that the method needs.
- Prefer generic collections over
object-based ones such asArrayList, 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
Tfrom 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>andActiveOnly<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:
BK-0002: The Jungle Book by Rudyard Kipling
MG-0001: Young Scientist; BK-0099: not found
Books: 2; magazines: 1
Show the solution
// 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
What is T in PagedResult<T>?
Show answer
Answer: B. A type parameter filled in by the user of the class PagedResult<Student> puts Student in place of T.
Paging.ToPage(students, 1, 10) has no angle brackets. How does the compiler know T?
Show answer
Answer: B. Type inference from the List<Student> argument The argument's type tells the compiler what T must be.
What does where T : ISoftDeletable allow inside the method?
Show answer
Answer: B. Using IsDeleted and the other ISoftDeletable members The constraint promises every T implements the interface.
Why is List<decimal> safer than ArrayList for marks?
Show answer
Answer: B. Adding a string fails at compile time instead of at run time Type mistakes become build errors.
What does default return for a reference type T?
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.