Generics: Parametric Polymorphism

Parametric polymorphism allows you to write functions and data structures that operate uniformly over any type. The 'parametric' part means the behavior is identical regardless of the type...

Key Insights

  • Parametric polymorphism lets you write code once that works uniformly across all types, eliminating duplication while maintaining full type safety—unlike Object/any approaches that sacrifice compile-time guarantees.
  • Bounded generics strike the balance between flexibility and capability: unconstrained type parameters can only be stored and passed around, while constraints unlock operations specific to those bounds.
  • Understanding variance (covariance, contravariance, invariance) prevents subtle bugs when generic types interact with inheritance hierarchies—get this wrong and you’ll either lose type safety or fight the compiler unnecessarily.

What Is Parametric Polymorphism?

Parametric polymorphism allows you to write functions and data structures that operate uniformly over any type. The “parametric” part means the behavior is identical regardless of the type parameter—the code doesn’t inspect or branch on what type it receives.

This differs from two other forms of polymorphism you’ve likely encountered:

Ad-hoc polymorphism (overloading) provides different implementations for different types. When you have add(int, int) and add(string, string), the compiler selects which version to call based on argument types. The implementations can be completely unrelated.

Subtype polymorphism lets you substitute a subtype wherever a supertype is expected. A function accepting Animal can receive a Dog. The behavior varies based on the runtime type through virtual dispatch.

Parametric polymorphism guarantees the same code runs regardless of type. A generic identity<T>(x: T): T function returns its argument unchanged whether T is number, string, or DatabaseConnection. This uniformity is the source of its power—and its constraints.

Most mainstream languages call this feature “generics” (Java, C#, TypeScript, Go, Rust). The academic term “parametric polymorphism” comes from type theory, but understanding the concept matters more than the terminology.

The Problem Generics Solve

Before generics, you had two bad options: duplicate code or abandon type safety.

Consider swapping two values. Without generics, you’d write:

// Option 1: Duplicate everything
public static void swapInts(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

public static void swapStrings(String[] arr, int i, int j) {
    String temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

// Repeat for every type you need...

The logic is identical. You’re paying a maintenance tax for the type system’s limitations.

The alternative—using Object—trades safety for flexibility:

// Option 2: Lose type safety
public static void swap(Object[] arr, int i, int j) {
    Object temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

// Compiles fine, explodes at runtime
Integer[] nums = {1, 2, 3};
String[] strs = {"a", "b", "c"};
swap(nums, 0, 1);  // Works
nums[0] = "oops";  // ArrayStoreException at runtime

Generics solve both problems:

public static <T> void swap(T[] arr, int i, int j) {
    T temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

One implementation. Full type safety. The compiler ensures you can’t put a String into an Integer[].

Generic Functions and Methods

The syntax varies across languages, but the concept is consistent: declare type parameters, then use them as types.

// TypeScript
function identity<T>(value: T): T {
    return value;
}

function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
    const result: U[] = [];
    for (const item of arr) {
        result.push(fn(item));
    }
    return result;
}

// Type inference handles most cases
const num = identity(42);        // T inferred as number
const strs = map([1, 2, 3], n => n.toString());  // T=number, U=string
// Rust
fn identity<T>(value: T) -> T {
    value
}

fn filter<T>(items: Vec<T>, predicate: fn(&T) -> bool) -> Vec<T> {
    let mut result = Vec::new();
    for item in items {
        if predicate(&item) {
            result.push(item);
        }
    }
    result
}

Type inference is your friend. Explicitly annotating type parameters is rarely necessary—do it when the compiler can’t infer or when it improves readability:

// Explicit annotation needed: no argument to infer from
const empty = new Array<string>();

// Explicit annotation for clarity in complex cases
const parsed = JSON.parse(data) as Result<User, ApiError>;

Generic Types: Classes, Structs, and Interfaces

Data structures benefit enormously from generics. Here’s a type-safe stack:

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[this.items.length - 1];
    }

    isEmpty(): boolean {
        return this.items.length === 0;
    }
}

const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
// numberStack.push("three");  // Compile error!

Rust’s Result<T, E> demonstrates a generic type with multiple parameters representing success and error cases:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

fn parse_port(s: &str) -> Result<u16, String> {
    match s.parse::<u16>() {
        Ok(port) => Result::Ok(port),
        Err(_) => Result::Err(format!("Invalid port: {}", s)),
    }
}

fn main() {
    match parse_port("8080") {
        Result::Ok(port) => println!("Port: {}", port),
        Result::Err(msg) => eprintln!("Error: {}", msg),
    }
}

Generic interfaces define contracts that implementations must satisfy for any type parameter:

interface Repository<T, ID> {
    findById(id: ID): Promise<T | null>;
    save(entity: T): Promise<T>;
    delete(id: ID): Promise<void>;
}

class UserRepository implements Repository<User, string> {
    async findById(id: string): Promise<User | null> { /* ... */ }
    async save(user: User): Promise<User> { /* ... */ }
    async delete(id: string): Promise<void> { /* ... */ }
}

Bounded Generics and Constraints

Unconstrained type parameters are limiting. You can store them, pass them around, and return them—but you can’t call methods on them because you don’t know what methods exist.

Constraints unlock capabilities:

// Without bounds: can only use Object methods
public static <T> T max(T a, T b) {
    // return a > b ? a : b;  // Won't compile! No > operator for T
    return a;  // Useless
}

// With bounds: can use Comparable methods
public static <T extends Comparable<T>> T max(T a, T b) {
    return a.compareTo(b) > 0 ? a : b;
}

// Works with any Comparable type
Integer maxInt = max(3, 7);      // 7
String maxStr = max("a", "z");   // "z"

Rust uses where clauses for complex bounds:

use std::fmt::Display;

fn print_and_clone<T>(value: &T) -> T 
where 
    T: Display + Clone 
{
    println!("Value: {}", value);
    value.clone()
}

// Multiple type parameters with different bounds
fn process<T, U>(input: T, transformer: U) -> String
where
    T: AsRef<str>,
    U: Fn(&str) -> String,
{
    transformer(input.as_ref())
}

The rule is simple: constrain only what you need. Over-constraining limits reusability; under-constraining limits what you can do inside the function.

Variance: Covariance, Contravariance, and Invariance

Variance describes how subtyping relationships transfer to generic types. This is where generics get subtle.

If Dog extends Animal, is List<Dog> a subtype of List<Animal>? Your intuition says yes. Your intuition is wrong—for mutable collections.

// This is why List<Dog> can't be List<Animal>
List<Dog> dogs = new ArrayList<>();
dogs.add(new Dog());

List<Animal> animals = dogs;  // Suppose this compiled...
animals.add(new Cat());       // Legal: Cat is an Animal

Dog dog = dogs.get(0);        // Runtime disaster: it's a Cat!

The three variance modes:

Covariant (read-only): Producer<Dog> is a subtype of Producer<Animal>. Safe because you only read values out—a producer of dogs can serve as a producer of animals.

Contravariant (write-only): Consumer<Animal> is a subtype of Consumer<Dog>. Safe because you only write values in—something that consumes any animal can certainly consume dogs.

Invariant (read-write): No subtyping relationship. Mutable collections must be invariant because they both produce and consume.

Java uses wildcards to express variance at use-site:

// Covariant: read-only access
void printAll(List<? extends Animal> animals) {
    for (Animal a : animals) {
        System.out.println(a.getName());
    }
    // animals.add(new Dog());  // Compile error! Can't write.
}

// Contravariant: write-only access  
void addDogs(List<? super Dog> list) {
    list.add(new Dog());
    list.add(new Puppy());  // Puppy extends Dog
    // Dog d = list.get(0);  // Compile error! Can only read as Object.
}

// Both work correctly
List<Dog> dogs = new ArrayList<>();
List<Animal> animals = new ArrayList<>();
printAll(dogs);    // Dog extends Animal: covariant OK
addDogs(animals);  // Animal super Dog: contravariant OK

Generics at Runtime: Erasure vs. Reification

Languages differ in whether type parameters exist at runtime.

Java uses type erasure: generics are a compile-time fiction. List<String> and List<Integer> become identical List objects at runtime.

// These are the same class at runtime
List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();
System.out.println(strings.getClass() == integers.getClass());  // true

// You cannot do this:
// if (obj instanceof List<String>) { }  // Compile error
// T item = new T();                      // Compile error

C# and Rust use reification: type parameters survive to runtime.

// C# preserves type information
public void PrintType<T>() {
    Console.WriteLine(typeof(T).Name);
}

PrintType<string>();  // Prints "String"
PrintType<int>();     // Prints "Int32"

// You can create instances
public T CreateInstance<T>() where T : new() {
    return new T();
}

Erasure’s tradeoffs: simpler runtime, backward compatibility with pre-generics code, but limited reflection and no runtime type checks. Reification costs more memory (separate code for value types in C#) but enables richer runtime behavior.

For day-to-day coding, erasure rarely matters. It bites when you need reflection, serialization, or runtime type discrimination—know your language’s model and design accordingly.

Practical Guidelines

Start without constraints, add them when the compiler complains you’re using operations that require them.

Prefer composition over complex generic hierarchies. If your type signature needs three lines of where clauses, reconsider your design.

Use type inference. Explicit type arguments are noise unless they clarify intent or resolve ambiguity.

Understand your language’s variance model. Java’s use-site variance (wildcards) requires thinking at every call site. Kotlin and C#’s declaration-site variance (out/in keywords) pushes this to type authors.

Generics are the workhorse of modern type systems. Master them and you’ll write less code, catch more bugs at compile time, and build abstractions that compose cleanly.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.