Generic Programming

The problem generics solve is concrete: without them, you either write the same function ten times for ten different types, or you lose all type safety by using Object / any / void*. Generics let you write it once and keep the types.


🟢 Junior

The problem they solve

Say you want a function that returns the first element of an array. Without generics:

function firstString(arr: string[]): string { return arr[0]; }
function firstNumber(arr: number[]): number { return arr[0]; }
function firstAny(arr: any[]): any { return arr[0]; }

The first two are duplication. The third loses the return type — the caller gets any, which defeats the purpose of TypeScript. Generics give you the clean version:

function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const s = first(['a', 'b', 'c']); // inferred as string | undefined
const n = first([1, 2, 3]);       // inferred as number | undefined

T is a type parameter — a placeholder that gets filled in at the call site. TypeScript infers it from the argument, so you rarely need to write it explicitly.

Generic classes and interfaces

The same idea applies to classes and interfaces:

class Stack<T> {
  private items: T[] = [];

  push(item: T): void    { this.items.push(item); }
  pop(): T | undefined   { return this.items.pop(); }
  peek(): T | undefined  { return this.items.at(-1); }
  get size(): number     { return this.items.length; }
}

const numStack = new Stack<number>();
numStack.push(1);
numStack.push(2);
// numStack.push('oops'); // Error — string is not number

Java’s syntax is the same angle-bracket convention. Go uses square brackets (func First[T any](s []T) T). C++ uses template<typename T>.

Multiple type parameters

A function can have more than one type parameter:

function zip<A, B>(as: A[], bs: B[]): [A, B][] {
  return as.map((a, i) => [a, bs[i]]);
}

zip([1, 2, 3], ['a', 'b', 'c']);
// [[1, 'a'], [2, 'b'], [3, 'c']]

🟡 Medior

Constraints (bounds)

Unconstrained generics don’t know anything about T except that it exists. You can’t call methods on it or access properties. Constraints restrict what T can be:

interface HasId { id: number; }

function findById<T extends HasId>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id);
}

const users = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];
findById(users, 1)?.name; // 'Alice'

T extends HasId means “T must have at least the shape of HasId.” The function body can safely access item.id because the constraint guarantees it exists.

In Java, the same concept uses extends for both classes and interfaces: <T extends Comparable<T>>. In C++ (pre-Concepts), violations produce pages of template error messages at instantiation time. C++20 Concepts fix this:

template<std::integral T>
T factorial(T n) { return n <= 1 ? 1 : n * factorial(n - 1); }
// factorial(3.5) → clear error: 3.5 is not an integer type

Variance: covariance, contravariance, invariance

This is where most developers get stuck. Variance describes how generic types relate to each other when their type parameters have a subtype relationship.

Suppose Cat extends Animal. Then:

CovarianceProducer<Cat> is usable as Producer<Animal>. Makes sense: if you produce cats, you’re producing animals. Read-only types (like IEnumerable<T> in C#, or out T in Kotlin) are covariant.

ContravarianceConsumer<Animal> is usable as Consumer<Cat>. Also makes sense: if you can handle any animal, you can certainly handle a cat. Write-only types (like Action<T> in C#, or in T in Kotlin) are contravariant.

InvarianceList<Cat> is NOT usable as List<Animal>. This surprises people, but it’s correct. If List<Cat> were a List<Animal>, you could do list.add(new Dog()) — which would corrupt the list.

// Kotlin makes variance explicit at declaration site
interface Producer<out T> { fun produce(): T }  // covariant, T only in output
interface Consumer<in T>  { fun consume(t: T) } // contravariant, T only in input

// Java uses use-site variance (wildcards)
void readAnimals(List<? extends Animal> list) { ... } // covariant
void addAnimals(List<? super Cat> list)       { ... } // contravariant

TypeScript uses structural typing and infers variance. Java’s wildcard syntax (? extends T, ? super T) is the practical form — the PECS mnemonic (“Producer Extends, Consumer Super”) is the shortcut.

Type erasure vs reification

Java erases generic types at runtime. List<String> and List<Integer> are both just List at runtime. The compiler uses the type information for checking, then strips it. This is why you can’t do new T() in a Java generic method or check instanceof List<String> — the type is gone.

List<String> strings = new ArrayList<>();
List<Integer> ints    = new ArrayList<>();
strings.getClass() == ints.getClass(); // true — both are ArrayList at runtime

C++ reifies (stamps out) a separate concrete class for each template instantiation. vector<int> and vector<string> are genuinely different compiled types. This makes them faster (no boxing, no type checks) but increases binary size.

C#, Kotlin, and Go reify generic types at runtime. List<string> and List<int> are different types you can check with instanceof/is. No boxing for value types in C#, which is a significant performance advantage over Java for List<int>.


🔴 Senior

TypeScript’s structural generics and conditional types

TypeScript’s type system is structural, not nominal — types match if their shapes match, not by name. Combined with conditional types, you can do type-level computation.

infer extracts a type from within a conditional:

type ReturnType<F> = F extends (...args: any[]) => infer R ? R : never;
type Parameters<F> = F extends (...args: infer P) => any ? P : never;

type UnpackPromise<T> = T extends Promise<infer V> ? V : T;

function fetchUser(): Promise<{ id: number; name: string }> { /* ... */ }

type User = UnpackPromise<ReturnType<typeof fetchUser>>;
// { id: number; name: string }

Mapped types with conditional types produce complex transformations that are fully type-safe and zero runtime cost:

type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

type NonNullableFields<T> = {
  [K in keyof T]: NonNullable<T[K]>;
};

C++ templates: compile-time programming

C++ templates are Turing-complete at compile time. Before C++11, template metaprogramming exploited this accidentally. With constexpr and if constexpr, it’s intentional:

template<typename T>
constexpr auto process(T value) {
  if constexpr (std::is_integral_v<T>) {
    return value * 2;       // only compiled for integer types
  } else if constexpr (std::is_floating_point_v<T>) {
    return value * 2.0;     // only compiled for float types
  } else {
    static_assert(false, "unsupported type"); // compile error for anything else
  }
}

if constexpr branches are evaluated at compile time. The non-matching branches are not instantiated, so they don’t need to be valid for the given type. This is categorically different from a regular if — it’s conditional compilation, not runtime branching.

The monomorphization vs boxing trade-off

When a language generates separate machine code per type (C++, Rust, C#), it’s called monomorphization. The result is maximum performance because each version is optimized for the specific types involved. The cost is binary size — a heavily templated C++ program can have many copies of the same logic.

When a language uses a single representation (Java’s erasure, Go’s generics before Go 1.18, boxed generics), it’s smaller but slower for value types due to boxing overhead.

Rust uses monomorphization by default but allows dyn Trait (trait objects) for dynamic dispatch when you need heterogeneous collections. The choice is explicit, which is why Rust is precise about where the cost is.

Go 1.18 introduced generics with a hybrid: generic code is often compiled once using a shared dictionary approach, monomorphized only when performance requires it.

When generics make things worse

Generics are a form of abstraction, and abstraction has a cost: the code becomes harder to read, error messages become longer, and the mental model required to understand it grows.

A function that’s called with one concrete type never benefits from being generic. Generics are justified when the same logic genuinely needs to work with many types — containers, transformations, serialization, graph algorithms. They’re overkill for a utility function used in one place with one type.

In Go, the community guidance is to wait until you have three callers with different types before reaching for generics. That’s a good rule of thumb in any language.