Generic Types

Learn how to define and use generic classes and methods to create type-safe, reusable code that works with any data type.

Consider the following Holder class:

public class Holder
{
public string[] Items { get; private set; }
public Holder(int holderSize)
{
Items = new string[holderSize];
}
public override string ToString()
{
// Use string.Join to efficiently concatenate items
return "Items inside: " + string.Join(", ", Items);
}
}
A class designed specifically to hold an array of strings
  • Line 3: Defines a property Items that holds an array of strings.

  • Lines 5–8: The constructor initializes the array with a specific size provided by the holderSize parameter.

  • Line 13: Overrides the ToString method to return a readable list of the items contained in the array.

It has an Items property of type string. What if we need a similar class that holds integers? The functionality is the same, but the type is different.

We might define a class like this:

public class IntHolder
{
// Now, the Items property holds integers
public int[] Items { get; private set; }
public IntHolder(int holderSize)
{
Items = new int[holderSize];
}
public override string ToString()
{
return "Items inside: " + string.Join(", ", Items);
}
}
A separate class designed specifically to hold an array of integers
  • Line 4: The Items property is now an int array.

  • Line 8: The array is initialized as an integer array.

This works. However, is this feasible if we need this behavior for many different types? We would end up copying the same code repeatedly and changing only the data type of the internal array.

A better approach uses generics.

Generic types

Generics allow us to define classes with placeholders for types. We specify the actual type when we declare variables and create objects. Instead of defining separate IntHolder and StringHolder classes, we can define a single class and instantiate it like this:

Holder<int> intHolder = new Holder<int>(3); // Holder class holds integers
Holder<string> stringHolder = new Holder<string>(3); // Holder class holds strings

With a generic Holder class, the type of the Items property is specified during variable declaration instead of being hard-coded inside the class.

C# 14.0
namespace GenericTypes
{
// <T> means the class will accept a type parameter
public class Holder<T>
{
// This type parameter is then injected into code
public T[] Items { get; private set; }
public Holder(int holderSize)
{
Items = new T[holderSize];
}
public override string ToString()
{
return "Items inside: " + string.Join(", ", Items);
}
}
}
  • Line 4: The <T> syntax indicates that Holder is a generic class. T is a placeholder for a specific type.

  • Line 7: T is used as the type for the Items array. If we create a Holder<int>, this becomes int[].

  • Line 11: We initialize an array of type T.

With the generic class defined, we can now use it in our main program to create instances for different data types.

C# 14.0
using GenericTypes;
// Holds integers
Holder<int> intHolder = new Holder<int>(3);
intHolder.Items[0] = 2;
intHolder.Items[1] = 7;
intHolder.Items[2] = 6;
// Holds strings
Holder<string> stringHolder = new Holder<string>(2);
stringHolder.Items[0] = "Hello";
stringHolder.Items[1] = "World!";
Console.WriteLine(intHolder.ToString());
Console.WriteLine(stringHolder.ToString());
  • Line 4: We instantiate Holder with int. The compiler replaces T with int for this instance.

  • Lines 6–8: We assign integer values to the array elements. Since T is int, the compiler ensures we only assign integers.

  • Line 11: We instantiate Holder with string. The compiler replaces T with string for this instance.

  • Lines 13–14: We assign string values to the array elements. Since T is string, the compiler ensures we only assign strings.

  • Lines 16–17: We print the contents. The first call outputs integers, and the second outputs strings.

We declare Items to be an array of type T. This placeholder is replaced by a concrete type (like int or string) when we create a Holder<T> object. The type specified inside the angle brackets <> is the type argument, which replaces the type parameter T.

C# 14.0
using GenericTypes;
Holder<int> intHolder = new Holder<int>(3);
Holder<string> stringHolder = new Holder<string>(3);
// Let's see the type of the Items array
Console.WriteLine(intHolder.Items.GetType());
Console.WriteLine(stringHolder.Items.GetType());
  • Lines 3–4: We create two instances of Holder with different type arguments.

  • Lines 7–8: We use GetType() to verify the underlying type of the Items array. The output will confirm one is System.Int32[] and the other is System.String[].

Default values

When writing generic code, we often do not know if T will be a value type or a reference type. We cannot simply assign null because T might be an int. We also cannot assign 0 because T might be a string.

To handle this, C# provides the default keyword, which returns the appropriate default value for any type:

  • Reference types (e.g., string, List<T>): null

  • Numeric types (e.g., int, double): 0

  • Boolean (bool): false

  • Structs: All fields initialized to their default values

public class Job<T>
{
public T Name { get; set; } = null; // Incorrect
public T JobPosition { get; set; } = default;
}
Assigning default values in a generic class
  • Line 3: This line is incorrect because T might be a value type (like int), which cannot be null.

  • Line 4: Depending on the actual type used, default will return the correct default value (e.g., 0 for int, null for string).

Accept multiple type parameters

A generic class can accept multiple type parameters. We don’t have to limit ourselves to the letter T when choosing a placeholder:

public class SystemBootstrapper<A, B>
{
public A FirstParameter { get; set; }
public B SecondParameter { get; set; }
}
A generic class definition with two type parameters
  • Line 1: The class accepts two generic parameters, A and B.

  • Line 3: The property FirstParameter is of type A.

  • Line 4: The property SecondParameter is of type B.

Generic methods

Besides generic types, we can create generic methods that accept type parameters:

C# 14.0
// We use 'string?' to allow nulls because the default for string is null
string? defaultString = GetDefault<string>();
int defaultInt = GetDefault<int>();
if (defaultString == null)
{
Console.WriteLine("Default string: null");
}
Console.WriteLine($"Default integer: {defaultInt}");
// Generic method definition
T GetDefault<T>()
{
// Use 'default!' to suppress null warnings for reference types
return default!;
}
  • Line 2: We call GetDefault specifying string as the type parameter. The result is stored in a nullable string (string?) because default for string is null.

  • Line 3: We call GetDefault specifying int. The result is 0.

  • Lines 5–8: We check if defaultString is null. Since the default value of a reference type like string is null, this block executes and prints “Default string: null”.

  • Line 10: We print the value of defaultInt. Since int is a value type, its default is 0.

  • Line 14: The method signature defines a generic type parameter <T> and returns type T.

  • Line 17: We return default!. The ! (null-forgiving operator) tells the compiler we intentionally accept that T might be null (e.g., for strings), suppressing the compiler warning.