C# Generics
C# generics let you write one class, method, interface, or delegate that works with many types while still staying strongly typed. Instead of writing separate code for int, string, and Customer, you write code with a type parameter such as T. Generics matter because they remove duplicate code, avoid unsafe casts, and let the compiler catch type mistakes before your program runs.
Overview: How C# Generics Work
A generic type has one or more placeholders for types. The most familiar example is List<T>. When you write List<string>, T becomes string; when you write List<int>, T becomes int. The class definition is reusable, but every use is still specific and type-safe.
Generics solve a problem older collection styles had. Without generics, a collection might store object. That lets anything go in, but reading values back requires casts, and value types such as int may be boxed into objects. A generic collection such as List<int> stores integers as integers and returns integers as integers. The compiler rejects list.Add("text") because the list was created for int.
Generic code is compiled with metadata describing its type parameters and constraints. At run time, the CLR creates efficient representations for constructed generic types such as Box<string> and Box<int>. Reference-type instantiations can share much of the same machine code because references have a common representation. Value-type instantiations usually get specialized code so operations can avoid boxing and preserve the exact value layout. This is why generics are both type-safe and efficient.
You can put type parameters on classes, structs, records, interfaces, delegates, and methods. A generic class keeps its type parameter for the whole type: Box<T> can have fields, properties, constructors, and methods that use T. A generic method has its own type parameter for that method call: PrintPair<T> can be used inside a non-generic class.
Sometimes generic code needs to know more about T. Constraints express those requirements. For example, where T : IComparable<T> lets the method call CompareTo. where T : class requires a reference type, where T : struct requires a non-nullable value type, and where T : new() requires a public parameterless constructor. Constraints are compile-time promises that unlock members safely.
Syntax
class Name<T>
{
public T Value { get; set; }
}
static TResult Convert<TInput, TResult>(TInput input)
{
// use TInput and TResult here
}
static T Max<T>(T left, T right) where T : IComparable<T>
{
return left.CompareTo(right) >= 0 ? left : right;
}
| Part | Meaning |
|---|---|
<T> |
Declares a type parameter. T is a placeholder for a real type supplied later. |
<TInput, TResult> |
Declares multiple type parameters when input and output types may differ. |
Name<int> |
Constructs the generic type by replacing T with int. |
where T : ... |
Adds a constraint so generic code can rely on a capability of T. |
default(T) |
Produces the default value for the type: usually null for reference types, 0 for numbers, and a zeroed value for structs. |
Type parameter names are usually short when their role is obvious, such as T. Use clearer names such as TKey, TValue, TInput, or TResult when a generic type has more than one parameter.
Examples
A Generic Box Class
using System;
class Program
{
static void Main()
{
Box<string> message = new Box<string>("Ready");
Box<int> retries = new Box<int>(3);
Console.WriteLine(message.Value.ToUpperInvariant());
Console.WriteLine(retries.Value + 2);
}
}
class Box<T>
{
public Box(T value)
{
Value = value;
}
public T Value { get; }
}
Output:
READY
5
Box<T> stores one value of whatever type is chosen. Box<string> exposes a string property, so string methods such as ToUpperInvariant are available. Box<int> exposes an int, so arithmetic works without casting.
A Generic Method with Type Inference
using System;
class Program
{
static void Main()
{
PrintPair("Language", "C#");
PrintPair(10, 20);
PrintPair<decimal>(7.5m, 8.25m);
}
static void PrintPair<T>(T first, T second)
{
Console.WriteLine($"{first} | {second} ({typeof(T).Name})");
}
}
Output:
Language | C# (String)
10 | 20 (Int32)
7.5 | 8.25 (Decimal)
The compiler usually infers T from the arguments. The first call uses string, the second uses int, and the third explicitly supplies decimal. All arguments for one T must fit the same type, which keeps the method predictable.
Using Constraints to Compare Values
using System;
class Program
{
static void Main()
{
Console.WriteLine(Max(42, 17));
Console.WriteLine(Max("pear", "apple"));
}
static T Max<T>(T left, T right) where T : IComparable<T>
{
return left.CompareTo(right) >= 0 ? left : right;
}
}
Output:
42
pear
The method can call CompareTo because the constraint promises that T implements IComparable<T>. Without that constraint, the compiler only knows that T is some type, so comparison members are not available.
A Realistic Generic Result Type
using System;
class Program
{
static void Main()
{
Result<int> parsed = TryParseCount("12");
Result<int> failed = TryParseCount("many");
Console.WriteLine(parsed.IsSuccess ? $"Count: {parsed.Value}" : parsed.Error);
Console.WriteLine(failed.IsSuccess ? $"Count: {failed.Value}" : failed.Error);
}
static Result<int> TryParseCount(string text)
{
if (int.TryParse(text, out int number))
{
return Result<int>.Success(number);
}
return Result<int>.Failure("Not a whole number");
}
}
class Result<T>
{
private Result(bool isSuccess, T value, string error)
{
IsSuccess = isSuccess;
Value = value;
Error = error;
}
public bool IsSuccess { get; }
public T Value { get; }
public string Error { get; }
public static Result<T> Success(T value)
{
return new Result<T>(true, value, "");
}
public static Result<T> Failure(string error)
{
return new Result<T>(false, default!, error);
}
}
Output:
Count: 12
Not a whole number
This pattern wraps either a successful value or an error message. The same Result<T> type can later become Result<User>, Result<decimal>, or Result<Order>. The default! expression is used only for the unused value in the failure case; production code often protects access to Value more strictly.
How It Works Step by Step
- The compiler reads the generic declaration and records its type parameters, such as
T. - When code uses
Box<int>or callsPrintPair("a", "b"), the compiler checks that all uses ofTare consistent. - If constraints exist, the compiler verifies that the chosen type satisfies them. A type that does not implement
IComparable<T>cannot be passed to the constrainedMaxmethod. - The compiled assembly stores generic metadata instead of copying source code for every possible type.
- At run time, the CLR creates or reuses an implementation for the constructed generic type. Value types get efficient handling that avoids ordinary object boxing.
- Inside the method body, members are available only when the compiler can prove they exist through
objectmembers, interfaces, base-class constraints, or other constraints.
This last point is important: generics are not templates that paste text and hope it works for each type. C# type-checks the generic body when you compile it. That is why a generic method cannot use +, >, or custom members on T unless the language and constraints make those operations valid.
Common Mistakes
Assuming Any Operation Works on T
static T Bigger<T>(T left, T right)
{
return left > right ? left : right;
}
This does not compile because the compiler cannot assume that every possible T supports the > operator. Use a constraint and an interface-based comparison when you need ordering.
using System;
class Program
{
static void Main()
{
Console.WriteLine(Bigger(4, 9));
}
static T Bigger<T>(T left, T right) where T : IComparable<T>
{
return left.CompareTo(right) >= 0 ? left : right;
}
}
Output:
9
Confusing List<string> with List<object>
List<string> names = new List<string> { "Ada" };
List<object> values = names;
This does not compile. Generic classes such as List<T> are invariant: List<string> is not a List<object>. If that assignment were allowed, someone could add an int through values, breaking the original string list. Copy the values into a new list when you need a different element type.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
List<string> names = new List<string> { "Ada", "Grace" };
List<object> values = new List<object>(names);
values.Add(123);
Console.WriteLine(string.Join(", ", values));
Console.WriteLine(string.Join(", ", names));
}
}
Output:
Ada, Grace, 123
Ada, Grace
Overusing Generics
Do not make a type generic just because it feels flexible. If a class only ever represents an email address, EmailAddress is clearer than ValueObject<T>. Generics are best when the algorithm or container is truly independent of the concrete type.
Best Practices
- Use generics for reusable containers, algorithms, result wrappers, factories, repositories, and strongly typed callbacks.
- Prefer built-in generic types such as
List<T>,Dictionary<TKey, TValue>,IEnumerable<T>,Func<T, TResult>, andAction<T>before creating your own. - Name type parameters by role when there is more than one:
TKey,TValue,TItem,TResult. - Add the narrowest useful constraint. For example, require
IComparable<T>when you compare, not a broad base class that brings unrelated assumptions. - Keep generic methods small and focused. Complex generic APIs can become hard to read even when they are technically correct.
- Avoid using
objectand casts when a generic type parameter can express the relationship directly. - Remember that
default(T)may benull,0,false, or a zeroed struct. Do not treat it as a meaningful missing value unless your design says so. - Use interfaces such as
IReadOnlyList<T>orIEnumerable<T>in parameters when callers should not need to provide a specific collection class.
Practice Exercises
- Write a generic method
FirstOrDefaultValue<T>that returns the first item from aList<T>, ordefault(T)when the list is empty. Test it with strings and integers. - Create a generic
Pair<TFirst, TSecond>class with two read-only properties. Instantiate it asPair<string, int>for a product name and quantity. - Write a constrained generic method
Min<T>that works for anyTimplementingIComparable<T>. Test it withintandstring.
Summary
- Generics let one type or method work with many concrete types while preserving compile-time type safety.
Tis a type parameter;List<int>andBox<string>are constructed generic types.- Generic methods often use type inference, so callers usually do not need to write the type argument explicitly.
- Constraints such as
where T : IComparable<T>let generic code use specific capabilities safely. - The CLR supports generics efficiently, especially for value types where boxing can often be avoided.
- Use generics when the code is truly type-independent, and keep generic APIs clear, constrained, and purposeful.
