← History

One Function, Many Types: how each language does generics

Six languages, one shared problem: writing a single function that works across many types - and six different bargains struck between safety, dispatch, and ceremony.

RustHaskellOCamlGoPythonGuji

Write first once, run it on a list of integers and a list of strings, and you have walked straight into the oldest question in type-system design: how does one function serve many types? The answer a language gives shapes everything downstream - what its compiler can prove, what its runtime must carry, and how much the programmer must say out loud. Here are six answers.

The parametric core

Most of these languages share a common starting point: parametric polymorphism. A function is parameterized over a type variable, and it must behave uniformly for every type that variable could become. Because it cannot inspect the type, it cannot misuse it - the classic result is what Philip Wadler called "theorems for free": from the signature alone you can predict much of the behaviour.

In Haskell the type variable is just a lowercase name, and the compiler infers it:

first :: [a] -> Maybe a
first []      = Nothing
first (x : _) = Just x

OCaml writes the same idea with a leading apostrophe on the variable and gets the same Hindley-Milner inference, so the signature is optional:

let first = function
  | []     -> None
  | x :: _ -> Some x
(* inferred: 'a list -> 'a option *)

Rust spells the parameter explicitly in angle brackets and ties it to its Option type:

fn first<T>(xs: &[T]) -> Option<&T> {
    xs.first()
}

And guji, the in-house language in this lineup, puts the type parameter in square brackets and leans on its sigils - @ marks a list binding, $ a scalar - so the shape of a value is visible before its type is:

sub first[T](@xs: List[T]): Option[T] {
    if @xs.is_empty() { None } else { Some(@xs[0]) }
}

That guji snippet runs today on the v0 tree-walking evaluator and prints 10 for a list of ints and ada for a list of strings from the same compiled function. Four languages, four syntaxes, one idea: the body never names a concrete type, so it works for all of them.

When uniformity is not enough: constraints

Pure parametricity is powerful precisely because it is restrictive. first works on any [a] only because it never asks anything of a. The moment you want to compare, add, or print the elements, you need a way to say "for any type that supports this operation."

Haskell's answer is its defining feature, the type class. A constraint to the left of => demands that the caller's type provides the named operations, and the compiler threads the right implementation through automatically:

largest :: Ord a => [a] -> Maybe a
largest [] = Nothing
largest xs = Just (maximum xs)

Rust's traits are the same idea, dressed for a systems language. A bound restricts the type parameter, and the compiler resolves it - usually by monomorphization, stamping out a specialized copy of the function per concrete type, so a generic call costs nothing at runtime:

fn largest<T: Ord>(xs: &[T]) -> Option<&T> {
    xs.iter().max()
}

OCaml took a different road. Its core has no type classes; instead it offers modules and functors - functions from modules to modules - to abstract over an implementation of an interface. It is more verbose but separates the "what operations" from the "what types" with unusual clarity, and OCaml's recent modular implicits work aims to recover type-class ergonomics on that foundation.

Guji sits deliberately at the parametric stage. Its v0 spec ships generics over class, enum, and sub, but defers traits as future work; for now its only polymorphism beyond parametric generics is match over sum types. That is a coherent bet, not an oversight - it keeps the type checker simple while the language finds its shape, and the "one obvious way" principle would rather omit a feature than ship a half-formed one.

Go: the late convert

For a decade Go had no generics at all. Its uniform mechanism was the interface: a value satisfies an interface structurally if it has the right methods, and code programmed to interface{} (the empty interface, now any) accepted anything - at the cost of runtime type assertions and lost static checks.

Go 1.18 (2022) finally added type parameters, with a twist: constraints are themselves interfaces, optionally listing concrete types via a type set.

func First[T any](xs []T) (T, bool) {
    if len(xs) == 0 {
        var zero T
        return zero, false
    }
    return xs[0], true
}

Go implements this through GC shape stamping - a hybrid between monomorphization and boxing that shares one instantiation across types with the same memory layout. It is less aggressive than Rust's per-type expansion and a pragmatic fit for a language that prizes fast compiles.

Python: types as an afterthought, on purpose

Python takes the opposite stance from every statically typed language above: at runtime there are no generics because there is no static check to satisfy. Duck typing means a function works on any value that supports the operations it performs, decided live:

def double_all(xs):
    return [x * 2 for x in xs]

double_all([1, 2, 3])   # [2, 4, 6]
double_all("ab")        # ['aa', 'bb']

Both calls run; * simply means something different for ints and strings. The cost is that errors surface at runtime, not compile time. To recover some of that lost safety, modern Python layers on gradual typing with TypeVar, checked by external tools like mypy rather than the interpreter:

from typing import TypeVar, Sequence
T = TypeVar("T")

def first(xs: Sequence[T]) -> T | None:
    return xs[0] if xs else None

The annotations are erased at runtime - they document intent and feed the checker, but the function would run identically without them. (Python 3.12 added cleaner def first[T](...) syntax for the same machinery.)

The spectrum

Lay the six out and a spectrum appears, trading guarantees against ceremony and against runtime cost.

There is no free lunch here, only different lunches. Rust pays in syntax for runtime speed; Python pays at runtime for syntactic freedom; Haskell and OCaml pay in conceptual depth for inference that feels like magic; Go pays in history for pragmatism. Guji, still young, pays the smallest bill by buying the least - which, for a language that would rather be obvious than complete, is exactly the point.