Skip to main content

C# Generics Tutorial: Write Type-Safe Code (2026 Guide)

Master C# generics with this complete tutorial. Learn generic classes, methods, constraints & real-world examples. Start writing type-safe C# code today!

If you've ever used List<T> or Dictionary<TKey, TValue>, you've already used C# generics — but do you know how to build your own? In this C# generics tutorial, you'll learn how generics let you write reusable, type-safe code once and use it with any type, without sacrificing performance or compile-time safety. We'll cover generic classes, generic methods, constraints, and real-world use cases like repositories and result types that you'll actually use in production applications.

Whether you're a beginner wondering what <T> means, or an intermediate developer looking for generics best practices, this guide walks through everything with runnable code examples.

What Are Generics in C#?

Generics, introduced in C# 2.0, allow you to define classes, methods, interfaces, and delegates with a type parameter — a placeholder for a type that's specified later by the caller. Instead of writing separate code for int, string, and Customer, you write it once with T and the compiler fills in the concrete type.

Here's the classic motivating example. Before generics, developers used object-based collections like ArrayList:

// The old, dangerous way (pre-generics)
var list = new System.Collections.ArrayList();
list.Add(42);
list.Add("hello"); // Compiles fine — but is it what you wanted?

int number = (int)list[1]; // 💥 InvalidCastException at RUNTIME

The problem: the compiler can't help you. Errors show up at runtime, in production, at 2 AM. Compare that with a generic collection:

// The type-safe way with generics
var numbers = new List<int>();
numbers.Add(42);
// numbers.Add("hello"); // ❌ Compile-time error — caught before you even run

int number = numbers[0]; // No cast needed, no runtime surprise

Why Generics Beat the Alternatives

  • Type safety: Mistakes are caught at compile time, not runtime. This is the single biggest reason generics exist.
  • Performance: With value types like int, generics avoid boxing — the costly process of wrapping a value type in an object on the heap. List<int> stores raw integers; ArrayList boxes every single one.
  • Code reuse: One implementation works for every type, so there's no copy-pasted IntStack, StringStack, CustomerStack.
  • Cleaner code: No casts scattered everywhere, and IntelliSense knows exactly what type you're working with.

How to Create a Generic Class in C#

Let's build a generic class from scratch. A type parameter goes in angle brackets after the class name:

public class Box<T>
{
    private T _content;

    public void Put(T item) => _content = item;

    public T Take() => _content;
}

// Usage — T becomes a concrete type at the call site
var intBox = new Box<int>();
intBox.Put(100);
int value = intBox.Take(); // 100, no cast

var stringBox = new Box<string>();
stringBox.Put("C# generics are great");
string text = stringBox.Take();

Why this matters: when you write Box<int>, the .NET runtime generates a specialized version of the class for int. For value types, each gets its own optimized code; for reference types, one shared implementation is reused. You get the flexibility of object with the speed and safety of hand-written, type-specific code.

You can use multiple type parameters, too — just like the built-in Dictionary<TKey, TValue>:

public class Pair<TFirst, TSecond>
{
    public TFirst First { get; }
    public TSecond Second { get; }

    public Pair(TFirst first, TSecond second)
    {
        First = first;
        Second = second;
    }
}

var coordinates = new Pair<double, double>(51.5074, -0.1278);
var userScore = new Pair<string, int>("alice", 2450);

Generic Methods in C#

You don't need a generic class to use generics — any method can declare its own type parameters:

public static class ArrayHelper
{
    public static void Swap<T>(ref T a, ref T b)
    {
        T temp = a;
        a = b;
        b = temp;
    }

    public static T FirstOrDefault<T>(T[] items, Func<T, bool> predicate)
    {
        foreach (T item in items)
        {
            if (predicate(item))
                return item;
        }
        return default; // default(T): 0 for int, null for reference types
    }
}

// The compiler INFERS the type — you rarely need to specify it
int x = 1, y = 2;
ArrayHelper.Swap(ref x, ref y); // Swap<int> inferred

string[] names = { "Ann", "Bob", "Carla" };
string match = ArrayHelper.FirstOrDefault(names, n => n.StartsWith("B"));

Notice type inference: you call Swap(ref x, ref y), not Swap<int>(ref x, ref y). The compiler figures out T from the arguments. This is exactly how LINQ methods like Where and Select feel so seamless — they're all generic methods under the hood.

Generic Constraints: Telling the Compiler What T Can Do

By default, the compiler knows almost nothing about T, so you can only call object members on it. Constraints (the where clause) let you promise what T will be, unlocking its members:

public class Repository<T> where T : class, IEntity, new()
{
    private readonly List<T> _items = new();

    public T Create()
    {
        var item = new T();          // allowed because of new()
        item.CreatedAt = DateTime.UtcNow; // allowed because of IEntity
        _items.Add(item);
        return item;
    }

    public T? FindById(int id) =>
        _items.FirstOrDefault(x => x.Id == id);
}

public interface IEntity
{
    int Id { get; set; }
    DateTime CreatedAt { get; set; }
}

The most common constraints, in order of how often you'll use them:

  • where T : class — T must be a reference type.
  • where T : struct — T must be a value type (used heavily with Nullable<T>).
  • where T : ISomeInterface — T must implement the interface. This is the workhorse constraint.
  • where T : SomeBaseClass — T must inherit from a base class.
  • where T : new() — T must have a public parameterless constructor, so you can write new T().
  • where T : notnull — T must be non-nullable (great with nullable reference types enabled).

Why constraints matter: they're not a limitation — they're documentation and capability combined. A constraint tells callers exactly what types are acceptable, and tells the compiler exactly what operations are legal. When you find yourself casting T to something inside a generic method, that's a signal you should add a constraint instead.

Real-World Use Cases for C# Generics

1. The Generic Repository Pattern

Almost every business application talks to a database, and the CRUD operations look identical for every entity. Generics let you write them once. This is one of the most searched-for generics patterns, and Entity Framework Core's DbSet<T> is built on exactly this idea:

public interface IRepository<T> where T : class, IEntity
{
    Task<T?> GetByIdAsync(int id);
    Task<IReadOnlyList<T>> GetAllAsync();
    Task AddAsync(T entity);
    Task DeleteAsync(int id);
}

public class EfRepository<T> : IRepository<T> where T : class, IEntity
{
    private readonly AppDbContext _db;

    public EfRepository(AppDbContext db) => _db = db;

    public Task<T?> GetByIdAsync(int id) =>
        _db.Set<T>().FirstOrDefaultAsync(e => e.Id == id);

    public async Task<IReadOnlyList<T>> GetAllAsync() =>
        await _db.Set<T>().AsNoTracking().ToListAsync();

    public async Task AddAsync(T entity)
    {
        _db.Set<T>().Add(entity);
        await _db.SaveChangesAsync();
    }

    public async Task DeleteAsync(int id)
    {
        var entity = await GetByIdAsync(id);
        if (entity is not null)
        {
            _db.Set<T>().Remove(entity);
            await _db.SaveChangesAsync();
        }
    }
}

// Register once in DI, use with ANY entity:
// services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));

2. A Generic Result Type for Error Handling

Instead of throwing exceptions for expected failures (invalid input, not-found), many teams use a Result<T> type — a pattern popularized in functional programming and now common in modern C# APIs:

public class Result<T>
{
    public bool IsSuccess { get; }
    public T? Value { get; }
    public string? Error { get; }

    private Result(bool ok, T? value, string? error)
        => (IsSuccess, Value, Error) = (ok, value, error);

    public static Result<T> Success(T value) => new(true, value, null);
    public static Result<T> Failure(string error) => new(false, default, error);
}

public Result<Order> PlaceOrder(Cart cart)
{
    if (cart.Items.Count == 0)
        return Result<Order>.Failure("Cart is empty.");

    var order = new Order(cart.Items);
    return Result<Order>.Success(order);
}

// The caller is FORCED to consider failure — no forgotten try/catch
var result = PlaceOrder(cart);
if (result.IsSuccess)
    Console.WriteLine($"Order placed: {result.Value!.Id}");
else
    Console.WriteLine($"Failed: {result.Error}");

3. Generic Caching Helper

public class TypedCache
{
    private readonly IMemoryCache _cache;

    public TypedCache(IMemoryCache cache) => _cache = cache;

    public async Task<T> GetOrCreateAsync<T>(
        string key,
        Func<Task<T>> factory,
        TimeSpan ttl)
    {
        if (_cache.TryGetValue(key, out T? cached) && cached is not null)
            return cached;

        T value = await factory();
        _cache.Set(key, value, ttl);
        return value;
    }
}

// Works for any type without casts at the call site
var products = await cache.GetOrCreateAsync(
    "products:featured",
    () => productService.GetFeaturedAsync(),
    TimeSpan.FromMinutes(10));

C# Generics Best Practices

  • Use meaningful type parameter names. T is fine for a single parameter, but prefer descriptive names with a T prefix when there are several: TKey, TValue, TResult, TEntity.
  • Prefer constraints over casting. If your generic code needs T to do something, express it with where T : ISomething rather than runtime casts and type checks.
  • Use default correctly. default(T) is null for reference types but zero-initialized for value types. Combine with nullable annotations (T?) so the compiler warns callers about possible nulls.
  • Don't over-genericize. If a class only ever works with one type, generics add complexity for nothing. Introduce T when you have (or realistically foresee) at least two concrete uses.
  • Know your variance. Interfaces can declare out T (covariant, like IEnumerable<out T>) and in T (contravariant, like IComparer<in T>). That's why you can assign IEnumerable<string> to IEnumerable<object> — but not List<string> to List<object>.

Common Pitfalls to Avoid

  • Assuming List<Cat> is a List<Animal>. Generic classes are invariant. If this were allowed, you could add a Dog to a list of cats. Use IEnumerable<Animal> for read-only covariant scenarios instead.
  • Comparing T values with ==. Unconstrained type parameters don't support ==. Use EqualityComparer<T>.Default.Equals(a, b), which works for both value and reference types and respects custom equality.
  • Static fields in generic classes surprise people. Counter<int>.Count and Counter<string>.Count are separate fields — each closed generic type gets its own statics.
  • Reflection over generics gets tricky. typeof(List<>) (open generic) and typeof(List<int>) (closed generic) are different types. If you're doing DI registration or serialization with reflection, you'll need MakeGenericType.
  • Overusing new(). The new() constraint uses reflection-like activation under the hood in some scenarios and can't pass constructor arguments. Consider accepting a Func<T> factory delegate instead.

Conclusion: Key Takeaways on C# Generics

C# generics are the foundation of modern .NET — collections, LINQ, async (Task<T>), dependency injection, and Entity Framework all depend on them. Mastering them changes how you design code, not just how you consume libraries.

  • Generics give you compile-time type safety, better performance (no boxing), and genuine code reuse from a single implementation.
  • Create generic classes with class Name<T> and generic methods with Method<T>(...) — the compiler usually infers T for you.
  • Use constraints (where T : ...) to unlock members on T and document what types your code accepts.
  • Reach for proven generic patterns: repositories (IRepository<T>), result types (Result<T>), and caching helpers.
  • Watch out for invariance, default(T) semantics, and per-type static fields.

The best way to internalize this C# generics tutorial is to refactor real code: find a class in your project that exists in two nearly-identical versions for different types, and merge them into one generic implementation. You'll immediately feel the payoff in less code, fewer bugs, and a compiler that has your back.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...