GDKB

Generics

Intermediate25 Minutes readUpdated July 21, 20263 related concepts

A generic type or method is written once against a placeholder type and works correctly for any concrete type substituted in later — `List<T>` works identically whether `T` is `int`, `Enemy`, or `InventoryItem`. Generics let you write a single, type-safe implementation instead of duplicating the same logic for every type it needs to support, or falling back to untyped `object` and losing compile-time safety entirely.

On this page

Generics

Summary

A generic type or method is written once against a placeholder type and works correctly for any concrete type substituted in later — List<T> works identically whether T is int, Enemy, or InventoryItem. Generics let you write a single, type-safe implementation instead of duplicating the same logic for every type it needs to support, or falling back to untyped object and losing compile-time safety entirely.

Why This Matters

Without generics, a reusable container or algorithm has two bad options. Either it's duplicated per type (IntStack, EnemyStack, ItemStack, each with identical logic), which means every bug fix has to be applied N times, or it's written against object, which compiles for any type but pushes every mistake to runtime — pulling an Enemy out of a stack that actually holds InventoryItems throws a cast exception mid-game instead of failing at compile time. Generics give you the reuse of the object approach with the safety of the duplicated approach.

Mental Model

A generic class is a form with a blank on it, not a filled-in document. The form (List<T>) defines the shape — "a growable sequence of T" — without committing to what T is. Filling in the blank (List<Enemy>, List<int>) produces a fully concrete type that behaves exactly as if you'd hand-written a list just for that type, except the compiler generated it from the same form and checked every usage against the type you filled in.

Core Explanation

  • Type parameters are placeholders, resolved at compile time. T in class Stack<T> isn't a real type — it's a variable that stands in for whatever type is supplied when the generic is used (Stack<int>, Stack<Enemy>), and the compiler substitutes it everywhere T appears.
  • Type safety is enforced at every use site. Once you declare Stack<Enemy> enemies, the compiler rejects enemies.Push(someItem) at compile time — the same mistake against an object-based stack would compile fine and fail only when the item is later cast back out.
  • Constraints narrow what T is allowed to be. A generic can require T to implement an interface, have a parameterless constructor, or be a reference/value type (where T : IDamageable, where T : new()), letting the generic code call members on T that a fully unconstrained placeholder wouldn't allow.
  • Generic methods vs. generic types. An entire class can be generic (Stack<T>), or just a single method on an otherwise ordinary class (T Find<T>(...)) — the same substitution rules apply at the smaller scope.

Syntax / Theory

C# (Unity)

csharp
public class ObjectPool<T> where T : Component
{
    private readonly Stack<T> available = new Stack<T>();
    private readonly T prefab;

    public ObjectPool(T prefab) => this.prefab = prefab;

    public T Rent() =>
        available.Count > 0 ? available.Pop() : Object.Instantiate(prefab);

    public void Return(T instance)
    {
        instance.gameObject.SetActive(false);
        available.Push(instance);
    }
}
csharp
// T is inferred from the argument — no need to write Find<Enemy> explicitly.
public T Find<T>(IEnumerable<T> items, System.Func<T, bool> predicate)
{
    foreach (var item in items)
        if (predicate(item)) return item;
    return default;
}

C++ (Unreal — templates)

cpp
template<typename T>
class TObjectPool
{
public:
    T* Rent()
    {
        if (Available.Num() > 0)
        {
            T* Instance = Available.Pop();
            return Instance;
        }
        return NewObject<T>();
    }

    void Return(T* Instance) { Available.Push(Instance); }

private:
    TArray<T*> Available;
};

Unreal's own container types (TArray<T>, TMap<K, V>) are themselves C++ templates, following the identical placeholder-substitution model.

Practical Example

A save system needs to serialize and deserialize many unrelated data types without duplicating the read/write logic for each one.

csharp
public class SaveSlot<T>
{
    public T Data;

    public void Save(string path) =>
        File.WriteAllText(path, JsonUtility.ToJson(Data));

    public static SaveSlot<T> Load(string path) =>
        JsonUtility.FromJson<SaveSlot<T>>(File.ReadAllText(path));
}
csharp
var settings = new SaveSlot<GameSettings> { Data = currentSettings };
settings.Save("settings.json");

var progress = new SaveSlot<PlayerProgress> { Data = currentProgress };
progress.Save("progress.json");

SaveSlot<T> is written once; the compiler generates a correctly-typed version for GameSettings, PlayerProgress, or any future save-data type, with no risk of accidentally loading progress data into a settings slot — that mismatch is a compile error, not a runtime surprise.

Common Mistakes

  • Reaching for object instead of a type parameter for "I want this to work with anything," which discards compile-time checking entirely and pushes every type mismatch to a runtime cast exception.
  • Over-constraining or under-constraining T. Too loose (where T : new() when the code also calls IDamageable.TakeDamage) fails to compile at the call site with a confusing error; too strict (constraining to a concrete class when an interface would do) needlessly limits what the generic can be used with.
  • Treating a generic type as if it shares state across different T fillings. ObjectPool<Enemy> and ObjectPool<Bullet> are two entirely separate types with independent state — nothing is shared between them just because they come from the same generic definition.
  • Writing a generic before there's a second concrete use case. A generic form is justified by having at least two real callers with different types; generalizing prematurely for a hypothetical future type adds indirection with no payoff yet (see [[CON-0008]] on avoiding speculative structure).

Game Development Example

An ObjectPool<T> (Syntax / Theory above) is one of the most common generic uses in gameplay code — bullets, particle effects, and enemies all benefit from pooling, and without generics you'd need a near-identical BulletPool, ParticlePool, and EnemyPool class, each fixing the same bug independently if one is found.

csharp
public class WeaponSystem : MonoBehaviour
{
    [SerializeField] private Bullet bulletPrefab;
    private ObjectPool<Bullet> bulletPool;

    void Awake() => bulletPool = new ObjectPool<Bullet>(bulletPrefab);

    void Fire()
    {
        Bullet bullet = bulletPool.Rent();
        bullet.transform.position = muzzle.position;
        bullet.gameObject.SetActive(true);
    }
}

Best Practices

  • Only generalize a type or method once a second concrete use case exists — a generic with exactly one caller is usually premature.
  • Constrain T as tightly as the generic's implementation actually requires, no tighter — a constraint that's too broad invites misuse, one that's too narrow blocks legitimate callers.
  • Prefer a generic method over a generic class when only one method actually needs the type parameter — it keeps the rest of the class simpler and non-generic.
  • Give type parameters a descriptive name (TItem, TKey) instead of a bare T once a type has more than one parameter, so Dictionary<TKey, TValue> reads clearly at the declaration.

Exercises

  1. Write a generic Pair<T1, T2> class that stores two values of potentially different types and exposes them as First/Second. Instantiate it once with two ints and once with a string and a bool.
  2. Take the ObjectPool<T> example and add a where T : Component isn't present — instead constrain T to a custom IPoolable interface with a ResetState() method, and call it inside Return.
  3. You're deciding between a generic Inventory<T> (one inventory type per item category) and a single non-generic Inventory that stores a common Item base type. Argue which is the better choice for a game with weapons, potions, and quest items, and justify the trade-off.
  4. Research C#'s covariance/contravariance (out T, in T) on generic interfaces and explain, in your own words, why IEnumerable<Enemy> can be used where IEnumerable<object> is expected but List<Enemy> cannot be used where List<object> is expected.
  • Collections — the built-in collection types (List<T>, Dictionary<TKey, TValue>) are the generics a learner encounters first, before writing their own.
  • Interfaces — generic constraints (where T : ISomeInterface) are how a generic gains the ability to call members on the placeholder type.
  • Data Types — understanding the difference between value types and reference types explains why generic constraints like where T : struct or where T : class exist at all.

Further Reading

Related Concepts