Generic Abstractions and Type Classes
Parametric polymorphism, ad-hoc polymorphism, and the type-class-as-record pattern that fits TypeScript.
Functional programmers love generic code — code that works for every shape of data, not just one. That's not laziness or showing-off; it's a real engineering discipline: when a function is generic, it has fewer ways to misbehave, because it knows less about its arguments.
Parametric polymorphism: "I don't know, and I don't care"
The simplest generic function: identity.
id is fully generic — its type <A>(a: A) => A doesn't
constrain A at all. Because it knows nothing about A, the
only thing it can do is return its argument. That's a powerful
property: from the type alone, you can prove what the function
does.
This is a deep idea from category theory called parametricity:
the more polymorphic a function is, the fewer behaviors are
compatible with its type. A function <A>(xs: A[]) => A[] might
reverse, sort by some external order, dedupe, or do nothing — but
it cannot invent new A values, because it doesn't know how. The
types tell you a great deal before you've read any code.
A library of generic helpers
Nothing in these functions cares whether A is a number, a string,
or a complex object. That's parametric polymorphism. The compiler
guarantees they all work for every A.
Ad-hoc polymorphism: "different behavior per type"
Sometimes a function does need to know something extra about its type — for example, "I know how to compare two values for equality". In Haskell that's called a type class:
class Eq a where
eq :: a -> a -> BoolTypeScript doesn't have type classes, but you can simulate them with one of the simplest tricks in FP: a type class is a record of functions. You pass the record as an argument; the generic function uses it.
Eq as a record
The pattern is profound: an instance of a type class is just a value. You can:
- have multiple instances for the same type (case-sensitive vs
case-insensitive
Eq<string>), - derive instances from other instances (
eqArrayfrom anyEq<A>), - pass instances around explicitly, log them, swap them in tests.
In Haskell the instance is chosen automatically by the compiler. In TypeScript it's chosen by the caller. Same idea, slightly more ceremony, more flexibility.
Ord as a record
A more useful example: ordering, with Ord derived to compare
tuples lexicographically.
ordPair and reverseOrd are combinators on instances. Instead
of writing one specific comparator per task, you build sort
orderings algebraically.
Type-class hierarchies (briefly)
Some classes extend others. The classical hierarchy from FP looks like:
The arrows mean "extends" — every Ord instance is also an Eq
(it can decide equality from "neither less nor greater"). Every
Monad is an Applicative, etc.
We'll meet Functor, Applicative, and Monad in the next
chapter as patterns rather than required code structure. For
now, what matters is: typical FP abstractions are just bundles of
operations, and TypeScript can model them as records of functions
plus generic functions that consume those records.
Why bother?
A Set<string> that uses case-sensitive equality is, in a real
sense, a different data structure from one that uses
case-insensitive equality — even though the values are the same.
Type classes let you write one set implementation that works
with either, by parameterizing it on Eq<string>.
The same pattern shows up everywhere:
- a JSON encoder parameterized by
Show<A>, - a hash table parameterized by
Hash<A>, - a sort parameterized by
Ord<A>, - a merge parameterized by
Semigroup<A>("how do I combine twoAvalues?"), - a sum parameterized by
Monoid<A>("how do I combine, and what's the empty?").
You write the algorithm once; you pass the behavior in.
A Semigroup challenge
A Semigroup is the simplest possible algebraic abstraction: an associative binary operation. Numbers under addition, strings under concatenation, arrays under concatenation, and "last wins" on objects are all semigroups.
In semigroup.ts, implement combineAll(s, xs)
that combines a non-empty list of A values using a
Semigroup<A> (one operation: concat(x, y) => A).
For an empty list, return undefined.
The provided instances are:
sumSG— numbers under+prodSG— numbers under*stringSG— strings under+
Expected output
10
24
abc
undefined
What does it mean for a TypeScript function to be parametrically polymorphic over type A?
It accepts any argument and uses typeof to dispatch
It must work for some specific union of concrete types
It works uniformly for every choice of A and makes no assumptions about A's structure
It uses unknown everywhere and casts at the boundary