C# Generics

Write type-safe code that works with any type using generic methods and classes, add constraints with where, build generic interfaces like a repository, and understand how List<T> and Dictionary<TKey, TValue> work.

Lesson 18 of 43Advanced18 min read

You've used generics since the collections lesson: List<string>, Dictionary<string, decimal>, HashSet<int>. The <T> in their names is a type parameter, a placeholder that you fill in when you use the type. One List<T> class works for strings, numbers, orders and anything else, and the compiler still knows exactly what's inside each list.

Generics let you write that kind of reusable code yourself. Without them you'd have two bad options: write the same class again for every type, or use object for everything and cast values back, losing type safety and adding run-time errors. Generic code is written once, checked by the compiler for every type it's used with, and runs just as fast as code written for a specific type.

In this lesson you'll write generic methods and classes, restrict type parameters with constraints so you can call methods on them, design generic interfaces like a repository, and see how .NET uses generics for collections, LINQ and even maths.

Generic methods

C#
static T FirstOrFallback<T>(List<T> items, T fallback)
{
    return items.Count > 0 ? items[0] : fallback;
}

string name = FirstOrFallback(new List<string>(), "nobody");   // T is string
int number = FirstOrFallback(new List<int> { 7, 8 }, 0);       // T is int
  • The type parameter goes in angle brackets after the method name: <T>.
  • C# usually infers T from the arguments, so you rarely write FirstOrFallback<int>(...).
  • Common names are T, or descriptive ones like TKey, TValue and TResult.

Generic classes

C#
public class Result<T>
{
    public bool Success { get; }
    public T? Value { get; }
    public string? Error { get; }

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

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

Result<int> r = Result<int>.Ok(42);

Each use, such as Result<int> or Result<Order>, is a separate, fully typed version. default gives the default value of whatever T turns out to be: 0 for numbers, null for reference types.

Constraints

Inside generic code, C# only lets you do things that work for every possible T. Constraints narrow down what T can be, which unlocks more operations:

ConstraintMeansLets you
where T : classT is a reference typeUse null
where T : structT is a value typeUse as a non-null value
where T : new()T has a parameterless constructorWrite new T()
where T : IComparable<T>T implements the interfaceCall CompareTo
where T : BaseClassT is or derives from a classUse its members
where T : notnullT is a non-nullable typeUse as a dictionary key
C#
static T Largest<T>(IEnumerable<T> items) where T : IComparable<T>
{
    T best = items.First();
    foreach (T item in items)
        if (item.CompareTo(best) > 0) best = item;
    return best;
}

Several type parameters and per-type statics

A generic type can take more than one type parameter, separated by commas, just as Dictionary<TKey, TValue> does:

C#
record Pair<TFirst, TSecond>(TFirst First, TSecond Second);

var pair = new Pair<string, int>("Ada", 36);
Console.WriteLine(pair);   // Pair { First = Ada, Second = 36 }

Each combination of type arguments, such as Pair<string, int> or Pair<int, bool>, is treated as a separate type. One surprising result is that static fields are not shared between them:

C#
static class Counter<T> { public static int Count; }

Counter<int>.Count++;
Counter<int>.Count++;
Counter<string>.Count++;
Console.WriteLine($"{Counter<int>.Count} {Counter<string>.Count}");   // 2 1

Usually this doesn't matter, but it explains why a static cache inside a generic class keeps a separate cache for every T. When you need one value shared by all of them, put it in a non-generic class instead. Inside generic code, typeof(T) gives you the actual type at run time, so typeof(T).Name is "Double" when T is double.

C# Advanced Syntax

Generic syntax

T Echo<T>(T value) => value;
class Box<T> { public T Value { get; set; } = default!; }
class Pair<TFirst, TSecond>(TFirst first, TSecond second) { }
T Create<T>() where T : new() => new T();
T Max<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b) >= 0 ? a : b;
interface IRepository<T> where T : class { T? Find(int id); void Add(T item); }
T Sum<T>(IEnumerable<T> xs) where T : INumber<T> => xs.Aggregate(T.Zero, (a, b) => a + b);

C# Advanced Summary

  • Generics use type parameters like T to write code once for many types.
  • Type arguments are usually inferred from method arguments.
  • Generic classes such as Result<T> are fully typed for each use.
  • where constraints restrict T and unlock operations such as CompareTo or new T().
  • default(T) gives the default value for any type.

C# Advanced Examples

Each example shows the code, the output it produces and what each part does.

Example 1

A generic method with a constraint

C#
Console.WriteLine(Largest(new[] { 4, 19, 7 }));
Console.WriteLine(Largest(new[] { "pear", "apple", "plum" }));
Console.WriteLine(Largest(new[] { new DateOnly(2026, 3, 1), new DateOnly(2025, 12, 31) }).ToString("yyyy-MM-dd"));

Console.WriteLine(Swap(("left", 1)));

T Largest<T>(T[] items) where T : IComparable<T>
{
    if (items.Length == 0) throw new ArgumentException("No items");
    T best = items[0];
    foreach (T item in items)
    {
        if (item.CompareTo(best) > 0) best = item;
    }
    return best;
}

(B, A) Swap<A, B>((A, B) pair) => (pair.Item2, pair.Item1);
Output
19
plum
2026-03-01
(1, left)
Line-by-line explanation
  1. Lines 1 to 3T is inferred as int, string and DateOnly in turn.
  2. Line 3DateOnly is comparable too; the later date wins and is formatted as year-month-day.
  3. Line 7The constraint guarantees every T has CompareTo.
  4. Line 13CompareTo returns a positive number when item comes after best.
  5. Line 18Two type parameters let the method return them in the opposite order.
Example 2

A generic cache class

C#
var prices = new Cache<string, decimal>(capacity: 2);
prices.Set("apple", 0.50m);
prices.Set("bread", 1.20m);
prices.Set("milk", 0.95m);

Console.WriteLine(prices.Count);
Console.WriteLine(prices.TryGet("apple", out var a) ? $"{a}" : "apple was evicted");
Console.WriteLine(prices.TryGet("milk", out var m) ? $"milk {m}" : "missing");

class Cache<TKey, TValue>(int capacity) where TKey : notnull
{
    private readonly Dictionary<TKey, TValue> items = new();
    private readonly Queue<TKey> order = new();

    public int Count => items.Count;

    public void Set(TKey key, TValue value)
    {
        if (!items.ContainsKey(key))
        {
            if (items.Count == capacity)
                items.Remove(order.Dequeue());
            order.Enqueue(key);
        }
        items[key] = value;
    }

    public bool TryGet(TKey key, out TValue value) => items.TryGetValue(key, out value!);
}
Output
2
apple was evicted
milk 0.95
Line-by-line explanation
  1. Line 1The cache is created for string keys and decimal values.
  2. Line 10notnull is required because dictionary keys can't be null.
  3. Lines 12 to 13Inside the class, TKey and TValue are used like any other types.
  4. Lines 21 to 22When full, the oldest key is removed first.
  5. Line 28The method works for whatever value type the cache was created with.
Example 3

A generic repository interface

C#
IRepository<Customer> customers = new InMemoryRepository<Customer>();
customers.Add(new Customer(1, "Ada"));
customers.Add(new Customer(2, "Grace"));

Console.WriteLine(customers.Find(2)?.Name ?? "not found");
Console.WriteLine(customers.Find(9)?.Name ?? "not found");
Console.WriteLine(customers.All().Count);

interface IEntity { int Id { get; } }

interface IRepository<T> where T : class, IEntity
{
    void Add(T item);
    T? Find(int id);
    IReadOnlyList<T> All();
}

class InMemoryRepository<T> : IRepository<T> where T : class, IEntity
{
    private readonly Dictionary<int, T> store = new();
    public void Add(T item) => store[item.Id] = item;
    public T? Find(int id) => store.GetValueOrDefault(id);
    public IReadOnlyList<T> All() => store.Values.ToList();
}

record Customer(int Id, string Name) : IEntity;
Output
Grace
not found
2
Line-by-line explanation
  1. Line 1Code depends on the generic interface, so a database version could replace the in-memory one.
  2. Line 11Two constraints: T must be a class and have an Id.
  3. Line 21The IEntity constraint is what allows item.Id here.
  4. Line 22GetValueOrDefault returns null for a missing id.
  5. Line 26Any record implementing IEntity can use the same repository.

Common C# Advanced Mistakes

Calling a method T might not have

Don't
T Max<T>(T a, T b) => a > b ? a : b;            // error CS0019: '>' can't be applied to T
Do
T Max<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b) >= 0 ? a : b;

Using object instead of a generic

Don't
List<object> ids = new() { 1, 2, "three" };     // compiles, then casts fail at run time
int first = (int)ids[2];
Do
List<int> ids = new() { 1, 2, 3 };              // "three" would be a compile error

Returning null for any T

Don't
T Find<T>(int id) { return null; }              // error CS0403: T might be a value type
Do
T? Find<T>(int id) where T : class { return null; }
// or return default; to get the default value for any T

C# Advanced in Real Projects

Generics throughout .NET

Every collection in .NET is generic, LINQ methods like Where<T> and Select<T, TResult> are generic, and Task<T> represents an async operation that produces a T. ASP.NET Core injects ILogger<T> and IOptions<T>, Entity Framework exposes tables as DbSet<T>, and many codebases wrap responses in a generic Result<T> or ApiResponse<T>.

C# Advanced FAQ

What does T mean in C#?

T is a type parameter: a placeholder for a real type that's chosen when the generic method or class is used. In List<T>, writing List<string> replaces every T with string. T is just a convention; descriptive names like TKey and TResult follow the same idea.

What does default(T) return?

It returns the default value of whatever type T turns out to be: 0 for numbers, false for bool, and null for classes, strings and other reference types. You can usually write just default and let the compiler work out the type. It's useful when a generic method has no real value to return.

Why can't I use + on a generic type T?

The compiler only lets you use operations that work for every possible T, and most types don't support +. Since .NET 7 you can add a constraint such as where T : INumber<T> from System.Numerics, which allows arithmetic on any numeric type. For older code, pass in a function that does the addition.

What is the difference between List<object> and List<T>?

List<object> can hold anything, but you must cast items back to their real type, mistakes show up only at run time, and value types like int get boxed. List<T> with a specific type, such as List<int>, is checked by the compiler and stores values directly. Use the generic version unless you genuinely need mixed types.

Can I have multiple constraints on a generic type?

Yes. Separate them with commas, as in where T : class, IComparable<T>, new(), and add a separate where clause for each type parameter. class or struct must come first and new() must come last.

C# Advanced Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the method so it works for any type.

T Identity(T value) => value;
Predict the outputeasy

What does this print?

var box = new Box<string>("hi");
Console.WriteLine(box.Value.Length);
class Box<T>(T value) { public T Value { get; } = value; }
Multiple choicemedium

What does where T : new() allow inside the generic code?

Predict the outputmedium

What does this print?

Console.WriteLine(Default<int>() + 1);
T Default<T>() => default!;
Multiple choicehard

Why is List<int> faster than List<object> holding the same numbers?

C# Advanced Quiz

5 questions to check what you learned.

  1. What is T in List<T>?

    • A variable
    • A type parameter
    • A method
    • A namespace
  2. How does C# usually know T in a generic method call?

    • You must always write it
    • It's always object
    • From the return value only
    • It infers it from the arguments
  3. Which keyword adds constraints to a type parameter?

    • when
    • where
    • if
    • requires
  4. Which constraint lets you call CompareTo on T?

    • where T : class
    • where T : new()
    • where T : IComparable<T>
    • where T : struct
  5. What does default give for a reference type T?

    • null
    • 0
    • An empty object
    • An exception