C# Generics: Type-Safe Reusable Code

Learn C# Generics: Type-Safe Reusable Code with practical examples, explanations, best practices, and real-world coding techniques.

Generics in C# allow you to write classes, methods, interfaces, and other types that work with different data types while preserving compile-time type safety. Instead of creating separate implementations for integers, strings, decimal values, and custom objects, you can define one reusable implementation using a type parameter.

For example, a collection that stores integers should not accidentally accept a string. Generics help the compiler enforce this rule before your application runs. They also reduce unnecessary type conversions and, when working with value types, can avoid the boxing overhead associated with non-generic APIs that store values as object.

Understanding Generic Types

A generic type uses a type parameter as a placeholder for a concrete type. By convention, a single type parameter is often named T, although descriptive names such as TKey, TValue, and TResult are also common.

Consider a generic class that stores a value:

public class Box<T>
{
    public T Value { get; set; }

    public Box(T value)
    {
        Value = value;
    }
}

Box<int> numberBox = new Box<int>(100);
Box<string> textBox = new Box<string>("Hello C#");

Console.WriteLine(numberBox.Value);
Console.WriteLine(textBox.Value);

The Box<T> class can store values of different types without changing its implementation. When you create Box<int>, the compiler treats its value as an integer. When you create Box<string>, the value is a string.

Notice that Box<int> and Box<string> are different constructed types. A box created for integers cannot be used interchangeably with a box created for strings.

Generic Collections

One of the most common uses of generics is working with collections. The .NET collection library provides generic types such as List<T>, Dictionary<TKey, TValue>, Queue<T>, and Stack<T>.

using System;
using System.Collections.Generic;

List<int> scores = new List<int> { 85, 92, 78, 96 };

scores.Add(88);

foreach (int score in scores)
{
    Console.WriteLine(score);
}

Dictionary<int, string> employees = new()
{
    [101] = "Alex",
    [102] = "Jordan",
    [103] = "Taylor"
};

Console.WriteLine(employees[102]);

The list accepts integers, while the dictionary associates integer keys with string values. The compiler checks these types, reducing the chance of invalid data being added or retrieved incorrectly.

Generic collections are generally preferable to older non-generic collections when the element types are known. They provide stronger type checking and avoid repeated casts when reading values.

Writing Generic Methods

A generic method declares its own type parameter and can be used with different types. Generic methods are useful when the operation remains the same even though the data type changes.

public static void Display<T>(T value)
{
    Console.WriteLine(value);
}

Display<int>(42);
Display<string>("Generics");
Display<decimal>(19.95m);

The compiler can often infer the type argument from the supplied value, so the calls can also be written as Display(42) and Display("Generics").

Here is another example that swaps two values using a generic method:

static void Swap<T>(ref T first, ref T second)
{
    T temporary = first;
    first = second;
    second = temporary;
}

int firstNumber = 10;
int secondNumber = 20;

Swap(ref firstNumber, ref secondNumber);

Console.WriteLine(firstNumber);  // 20
Console.WriteLine(secondNumber); // 10

The same method can swap strings or other compatible variable types without requiring a separate implementation for each type.

Generic Constraints

A type parameter can represent many types, but a generic method cannot automatically access members that are not guaranteed to exist on every possible type. Constraints specify the capabilities or characteristics that a type argument must satisfy.

For example, the following method finds the greater of two values by requiring the type to implement IComparable<T>:

static T Max<T>(T left, T right)
    where T : IComparable<T>
{
    return left.CompareTo(right) >= 0 ? left : right;
}

Console.WriteLine(Max(10, 25));
Console.WriteLine(Max("Apple", "Orange"));

The constraint makes CompareTo available inside the method. The string example uses the string type's comparison behavior, which is not necessarily the same as a culture-aware or case-insensitive ordering requirement.

Common generic constraints include:

  • where T : class — requires a reference type.
  • where T : struct — requires a non-nullable value type.
  • where T : notnull — disallows nullable type arguments under the relevant nullable analysis rules.
  • where T : new() — requires a public parameterless constructor, and must appear after other applicable type constraints.
  • where T : SomeBaseClass — requires the type to derive from a specified base class.
  • where T : ISomeInterface — requires the type to implement a specified interface.
  • where T : U — requires one type parameter to be compatible with another type parameter.

A type parameter can have multiple constraints when necessary. Choose only the constraints that the implementation genuinely requires so the generic API remains useful for as many compatible types as possible.

Generic Interfaces

Interfaces can also declare type parameters. Generic interfaces are useful when different implementations provide the same operations for different data types.

public interface IRepository<T>
{
    T? GetById(int id);

    void Add(T item);
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
}

public class ProductRepository : IRepository<Product>
{
    public Product? GetById(int id)
    {
        // A real implementation would retrieve the product.
        return null;
    }

    public void Add(Product item)
    {
        Console.WriteLine($"Added product: {item.Name}");
    }
}

The repository interface describes operations for a type T. The product repository specializes that contract for Product. A different implementation could use the same interface with an Order or another entity type.

This pattern can help separate application logic from data access. In a real application, consider whether an abstraction is needed rather than introducing repositories automatically for every entity.

Building a Generic Result Type

Generics are useful for representing a common operation whose successful result may have different types. A result type can carry either a value or an error message, allowing multiple operations to share a consistent return structure.

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

    private Result(T value)
    {
        Value = value;
        Error = null;
    }

    private Result(string error)
    {
        Value = default;
        Error = error;
    }

    public static Result<T> Success(T value) => new(value);

    public static Result<T> Failure(string error) => new(error);
}

Result<int> success = Result<int>.Success(200);
Result<string> failure =
    Result<string>.Failure("The requested item was not found.");

if (success.IsSuccess)
{
    Console.WriteLine(success.Value);
}

if (!failure.IsSuccess)
{
    Console.WriteLine(failure.Error);
}

The same result structure can represent an integer, a string, or a custom object. This example assumes that a successful result has no error and a failed result has an error; production implementations should validate inputs and consider nullable reference types and default values carefully. In particular, a successful result containing a null value may require an explicit success flag instead of inferring success from the error alone.

Generics and Type Safety

Generics help catch incompatible types during compilation. Compare a generic list with a collection that stores values as object:

using System.Collections;
using System.Collections.Generic;

List<int> numbers = new List<int>();
numbers.Add(10);
// numbers.Add("Twenty"); // Compile-time error

ArrayList legacyNumbers = new ArrayList();
legacyNumbers.Add(10);
legacyNumbers.Add("Twenty");

int first = (int)legacyNumbers[0];
// Casting the second item to int would fail at runtime.

The generic list prevents an incompatible string from being added. The non-generic ArrayList accepts both values because its elements are stored as objects, leaving the caller responsible for safe casting.

Generics can also improve performance. Collections such as List<int> store integer values without boxing each value into an object. Actual performance depends on the operation and implementation, so measure performance-sensitive code rather than assuming a generic abstraction is always faster in every situation.

Common Mistakes and Best Practices

  • Using object unnecessarily: Prefer a type parameter when an operation should preserve the actual data type.
  • Adding unnecessary constraints: Do not restrict a generic type unless the implementation depends on that requirement.
  • Creating meaningless generic APIs: A type parameter should improve reuse or type safety, not simply make a declaration look more flexible.
  • Ignoring nullable reference types: Consider whether a generic value may be null and design the API accordingly.
  • Assuming every type supports the same operations: Use an appropriate interface or base-type constraint before accessing members through a type parameter.
  • Overcomplicating public APIs: Use descriptive type parameter names when multiple parameters have different responsibilities, such as TKey and TValue.

For related topics, explore C# collections and dictionaries to understand how generic collections organize data, and C# interfaces and abstraction to learn how generic contracts can define reusable application components.

Key Takeaways

Generics make C# code reusable without sacrificing compile-time type checking. Generic classes encapsulate data for different types, generic methods reuse operations, generic interfaces define type-safe contracts, and constraints allow implementations to depend on specific capabilities.

When designing a generic API, start with the operation you want to reuse, identify the type information that must be preserved, and add only the constraints needed to implement the behavior safely. This approach produces code that is easier to understand, maintain, test, and extend.

Code Examples

using System;

static T Max<T>(T left, T right)
    where T : IComparable<T>
{
    return left.CompareTo(right) >= 0 ? left : right;
}

Console.WriteLine(Max(15, 42));
Console.WriteLine(Max("Apple", "Orange"));
    
using System;

public class Container<T>
{
    public T Item { get; }

    public Container(T item)
    {
        Item = item;
    }

    public void Display()
    {
        Console.WriteLine(Item);
    }
}

var number = new Container<int>(100);
var message = new Container<string>("Hello generics");

number.Display();
message.Display();
    

Pro Tip

Use descriptive type parameter names when a generic declaration has multiple types, such as TKey and TValue. Add constraints only when the implementation requires specific capabilities, and use nullable annotations deliberately.

🎨 IgKnight Docs

Master HTML, CSS, JS, Python, .NET and more with easy-to-follow lessons and practical code examples.

© 2025 IgKnight Learning Platform. All rights reserved.