Why Generics Transform Your Code
Generics let you write code that works with any type while keeping full type safety. Before generics, you'd use object for everything and cast constantly, hoping you got the types right. Those casts failed at runtime, not compile time, making bugs expensive to find.
With generics, the compiler checks types for you. You write a class or method once with a type parameter, and it works for int, string, or your custom types without modification. This eliminates casting, prevents runtime type errors, and makes your code faster by avoiding boxing for value types.
You'll learn how to create generic classes and methods, apply constraints to limit type parameters, and understand when generics make sense versus when they add unnecessary complexity.
Building Generic Classes
Generic classes use type parameters that get filled in when you create instances. Instead of hardcoding specific types, you use placeholders like T that callers specify. This makes the class reusable across any type while maintaining type safety throughout.
The classic example is a container class. You want it to hold any type, but you don't want to cast every time you retrieve items. Generic classes solve this by letting you declare what type they contain, and the compiler enforces that choice everywhere.
public class Result<T>
{
public bool Success { get; }
public T? Value { get; }
public string? ErrorMessage { get; }
private Result(bool success, T? value, string? errorMessage)
{
Success = success;
Value = value;
ErrorMessage = errorMessage;
}
public static Result<T> Ok(T value)
{
return new Result<T>(true, value, null);
}
public static Result<T> Fail(string errorMessage)
{
return new Result<T>(false, default, errorMessage);
}
}
// Usage with different types
var numberResult = Result<int>.Ok(42);
var textResult = Result<string>.Ok("Success");
var errorResult = Result<decimal>.Fail("Invalid amount");
The T parameter becomes whatever type you specify when creating a Result. The compiler ensures you can't accidentally mix types, and you get full IntelliSense support for the Value property based on the type argument you provided.
Creating Reusable Generic Methods
Generic methods let you write functions that work with any type without making the entire class generic. This is useful for utility methods where the type varies per call but the logic stays the same. The method declares its own type parameters independent of any class-level generics.
Type inference often lets you skip explicitly specifying type arguments. The compiler figures out the types from your parameters, making generic methods feel natural to call while still providing type safety behind the scenes.
public static class ArrayHelpers
{
public static T[] CreateArray<T>(int size, T defaultValue)
{
var array = new T[size];
for (int i = 0; i < size; i++)
{
array[i] = defaultValue;
}
return array;
}
public static void Swap<T>(ref T first, ref T second)
{
T temp = first;
first = second;
second = temp;
}
public static T GetMiddle<T>(T[] array)
{
if (array.Length == 0)
throw new ArgumentException("Array cannot be empty");
return array[array.Length / 2];
}
}
// Type inference in action
int[] numbers = ArrayHelpers.CreateArray(5, 0); // T inferred as int
string[] names = ArrayHelpers.CreateArray(3, "Unknown"); // T inferred as string
int x = 10, y = 20;
ArrayHelpers.Swap(ref x, ref y); // T inferred from parameters
You don't need to write CreateArray<int> because the compiler sees you passed an int and figures it out. This keeps your code clean while giving you the benefits of type safety and reusability across all types.
Applying Type Constraints
Constraints limit which types can be used as generic arguments. Without constraints, you can only use operations available on all types. Constraints let you require specific capabilities like constructors, interfaces, or base classes, enabling you to call more methods on the generic type.
Common constraints include where T : class for reference types, where T : struct for value types, where T : new() for types with parameterless constructors, and where T : ISomeInterface for types implementing specific interfaces. These constraints make your generic code more powerful while keeping it reusable.
public interface IEntity
{
int Id { get; set; }
}
public class Repository<T> where T : IEntity, new()
{
private readonly List<T> _items = new();
private int _nextId = 1;
public T Create()
{
var item = new T(); // new() constraint allows this
item.Id = _nextId++; // IEntity constraint allows this
_items.Add(item);
return item;
}
public T? FindById(int id)
{
return _items.FirstOrDefault(x => x.Id == id);
}
public IEnumerable<T> GetAll()
{
return _items;
}
}
public class Product : IEntity
{
public int Id { get; set; }
public string Name { get; set; } = "";
public decimal Price { get; set; }
}
// Usage
var repo = new Repository<Product>();
var product = repo.Create();
product.Name = "Widget";
product.Price = 29.99m;
The constraint where T : IEntity, new() means T must implement IEntity and have a parameterless constructor. This lets you call Create() safely and access the Id property. Without these constraints, the compiler wouldn't allow new T() or accessing Id.
Try It Yourself
This example brings together generic classes, methods, and constraints to build a simple caching system. You'll see how generics enable type-safe caching without writing separate cache classes for each type.
Steps:
- dotnet new console -n GenericsDemo
- cd GenericsDemo
- Replace Program.cs with the code below
- Create GenericsDemo.csproj as shown
- dotnet run
using System;
using System.Collections.Generic;
public class Cache<TKey, TValue> where TKey : notnull
{
private readonly Dictionary<TKey, CacheEntry<TValue>> _cache = new();
private readonly TimeSpan _expiration;
public Cache(TimeSpan expiration)
{
_expiration = expiration;
}
public void Set(TKey key, TValue value)
{
_cache[key] = new CacheEntry<TValue>
{
Value = value,
ExpiresAt = DateTime.Now.Add(_expiration)
};
Console.WriteLine($"Cached: {key}");
}
public bool TryGet(TKey key, out TValue? value)
{
if (_cache.TryGetValue(key, out var entry))
{
if (DateTime.Now < entry.ExpiresAt)
{
value = entry.Value;
Console.WriteLine($"Cache hit: {key}");
return true;
}
_cache.Remove(key);
Console.WriteLine($"Cache expired: {key}");
}
value = default;
Console.WriteLine($"Cache miss: {key}");
return false;
}
}
public class CacheEntry<T>
{
public T Value { get; set; } = default!;
public DateTime ExpiresAt { get; set; }
}
// Usage
var cache = new Cache<string, int>(TimeSpan.FromSeconds(2));
cache.Set("user:123", 42);
cache.Set("user:456", 99);
if (cache.TryGet("user:123", out int value))
{
Console.WriteLine($"Retrieved value: {value}");
}
Console.WriteLine("Waiting for expiration...");
System.Threading.Thread.Sleep(2500);
if (!cache.TryGet("user:123", out int _))
{
Console.WriteLine("Value expired as expected");
}
This program demonstrates a generic cache that works with any key and value type. The TKey : notnull constraint ensures dictionary keys are valid, while the TValue parameter lets you cache any type safely. The nested CacheEntry class is also generic, showing how generics compose naturally.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Output:
Cached: user:123
Cached: user:456
Cache hit: user:123
Retrieved value: 42
Waiting for expiration...
Cache expired: user:123
Value expired as expected
The output confirms the cache stores and retrieves values with full type safety. You get IntelliSense showing value is an int, not object. The expiration logic works identically regardless of what types you cache, demonstrating how generics enable reusable, type-safe components.
Choosing the Right Approach
Generics aren't always the right choice, even though they provide type safety and performance benefits. Simple scenarios with a single known type work better with concrete classes. If you're only ever working with strings, a generic class adds complexity without benefit. Choose generics when you genuinely need the same logic across multiple types, not as a premature optimization.
Consider using non-generic base classes when you need to store different generic instances together. For example, List<int> and List<string> don't share a common type, but they both inherit from a non-generic IList. This lets you put them in the same collection when you only need non-generic operations. Design your class hierarchy with both generic and non-generic versions when you need this flexibility.
Performance matters more for value types than reference types. Generics eliminate boxing for value types, making List<int> dramatically faster than ArrayList. For reference types, the gains are smaller since they're already heap-allocated. If your generic code only ever uses reference types, you're getting type safety but minimal performance improvement. Measure before assuming generics will make your code faster.
Constraints add power but reduce flexibility. A Repository<T> where T : IEntity is less reusable than one without constraints, but it can do more with each T. Start without constraints and add them only when you need to call specific methods on T. Each constraint you add narrows the types that work with your generic, so balance the trade-off between capability and reusability based on your actual needs.