Type Classes and Higher-Kinded Types: Why TypeScript Fights You, and How fp-ts Wins Anyway (Part 10 of the Functional Programming Series)
Part 10 of the Functional Programming in Practice series. Part 9 left us with a wall: we have a map for Option, for Either, for Array, but no way to write one map for all of them. This part is the honest story of why, and what to do about it.
Hey there, Coding Chefs! 👨💻
By now you have met Semigroup, Monoid, and Functor as separate ideas. Here is something you might not have noticed: they are all the same kind of thing. Each one is a way of saying "any type that supports these operations belongs to this club." Everything that can concat is a Semigroup. Everything with a lawful map is a Functor. Spotting that they share a shape is the key that turns fp-ts from an intimidating wall of generics into something obvious.
But there is a catch, and I am not going to hide it from you. TypeScript cannot express this shape natively. The thing that would let us write one map for every Functor is a language feature TypeScript does not have, and the workaround is exactly why fp-ts code looks the way it does. This part is the honest tour: what the shared shape is, why TypeScript fights us, and how the libraries win anyway. It is the most "language internals" part of the series, and it will make fp-ts finally make sense. Let's get cracking.
Two Kinds of Polymorphism
Start with a word: polymorphism. It breaks into "poly" (many) and "morph" (form). Polymorphism is the ability of one thing to take many forms. There are two flavors, and the distinction is the whole foundation here.
Parametric polymorphism is what you call generics. One implementation that works for a whole range of types, because it does not care about the specifics of any of them.
const identity = <A>(a: A): A => a; // one body, works for every type A
identity has a single implementation that works for number, string, User, anything. The logic is genuinely type-agnostic. That is parametric polymorphism: one implementation, many types.
Ad-hoc polymorphism is different. Here you want a different implementation for each type, all sharing one name. Consider turning a value into a string. The way you stringify a number is different from how you stringify a boolean, which is different from how you stringify a user. Same operation name, type-specific implementations.
interface Show<A> {
toString: (a: A) => string;
}
const numberShow: Show<number> = { toString: (n) => `${n}` };
const booleanShow: Show<boolean> = { toString: (b) => (b ? "yes" : "no") };
Notice there is no single toString implementation. Each type brings its own, bundled in a Show instance. That is ad-hoc polymorphism: many implementations, one interface, selected by type.
The Type Class
The tool for organizing ad-hoc polymorphism is the type class. A type class groups types by the behavior they support, and lets you treat the whole group as one entity.
Picture all concrete types as points scattered in space. Now draw a circle around every type that knows how to convert itself to a string. Call that circle Show. Integer is inside it, string is inside it, boolean is inside it. When you hold a value and know it is "in the Show circle," you know you can stringify it, without caring which specific type it is. The type class is that circle: a named group of types unified by a shared set of operations.
And here is the recognition that ties the whole series together. You have met type classes already, you just did not have the name:
- Magma is the type class of "types that have a closed
concat." - Semigroup is "types whose
concatis associative." - Monoid is "Semigroups that also have an
empty." - Functor is "type constructors that have a lawful
map."
Each is a circle drawn around the types that support a particular behavior. That is all a type class is. In TypeScript we implement them with interface plus instances, the way we did Show, Semigroup, and Monoid in earlier parts. (Worth a note: interfaces come from an object-oriented lineage and center on objects, while type classes center on types. We are borrowing the interface as a tool to express a type-class idea, which works but is not a perfect fit, and that imperfection becomes the whole problem in a moment.)
Kinds: The Types of Types
To understand why TypeScript struggles, we need one more concept: kinds. A kind is, roughly, the "type of a type."
- A concrete type like
numberorbooleanorUserhas kindType. It is a finished type, you can have a value of it directly. Optionis not a finished type. You cannot have a value of "Option," only ofOption<number>orOption<string>.Optionis a type constructor: feed it a type, get a type back. Its kind isType -> Type.Eithertakes two types to make a finished one, so its kind isType -> Type -> Type.
So far in this series we have grouped concrete types (kind Type) into classes like Monoid. But Functor is a circle drawn around type constructors (kind Type -> Type), things like Option, Array, Either-with-one-slot-fixed, all of which are mappable. To talk about "any Functor F," we need to abstract over type constructors themselves. Abstracting over type constructors is called using higher-kinded types, and it is the feature that would let us finally write one map to rule them all.
Where TypeScript Fights Us
Here is the wall, stated plainly. TypeScript (as of version 5) does not support higher-kinded types. You cannot pass a type constructor like Option as a type parameter to a generic. There is no legal way to write something like:
// NOT valid TypeScript. F is a type constructor, and TS won't allow that as a parameter.
interface Functor<F> {
map: <A, B>(f: (a: A) => B) => (fa: F<A>) => F<B>;
}
That F<A> where F is itself a parameter is precisely what TypeScript forbids. The language lets you abstract over types (kind Type), but not over type constructors (kind Type -> Type). So we are stuck writing a separate mapOption, mapEither, and mapArray, never a single generic map, exactly the limitation we hit at the end of the Functor part. The compiler is not being difficult on purpose; it simply lacks the feature.
This is the honest, slightly deflating truth about doing serious functional programming in TypeScript. The language was not designed with higher-kinded types in mind, and several proposals to add them have not landed. Until one does, we work around it.
How fp-ts Wins Anyway
The workaround is clever, and understanding it is the thing that makes fp-ts code stop looking like hieroglyphics. Since we cannot pass Option as a real type parameter, fp-ts simulates higher-kinded types using a technique built on an interface it calls HKT (short for Higher-Kinded Type). Instead of passing the constructor directly, you pass a string-like tag that stands in for the constructor, and the library has machinery that resolves that tag back to the real type when needed.
The cost of the simulation is boilerplate, and now you know why it is there when you see it. Because the trick cannot fully unify constructors of different arities, fp-ts ends up with arity-indexed versions of each type class. There is a Functor1 for constructors that take one type parameter (like Option), a Functor2 for two (like Either), and so on. When you define a Functor instance for Option you reach for Functor1; for Either you reach for Functor2. That numbered-interface clutter you have seen in fp-ts is not gratuitous complexity, it is the visible scar of faking a missing language feature.
// the SHAPE of what fp-ts does (simplified): a tag stands in for the constructor,
// and Functor1/Functor2 exist because the simulation can't unify arities.
// You don't write this machinery; you consume it. Now you know why it looks like this.
There is a newer library, Effect (the successor lineage to fp-ts), that takes a more modern approach to encoding these ideas and smooths over a lot of the rough edges. If you are starting fresh today, it is worth a look. But fp-ts remains the clearest teaching tool, and the concepts transfer directly.
A Note on the Strategy Pattern
One bridge to object-oriented intuition, because it demystifies the whole thing. Ad-hoc polymorphism, passing around a bundle of type-specific behavior (a Show instance, a Monoid instance) as a value, is the functional face of the Strategy pattern you may know from OOP. A type-class instance is a strategy object: a packaged implementation you hand to a generic function so it knows how to operate on a specific type. If you have ever passed a comparator into a sort function, you have used this idea. Type classes are that, organized and named at the level of types.
But Everything Has a Cost
The honest caveats, and this part has real ones.
The boilerplate is genuinely heavier in TypeScript. In a language with native higher-kinded types and type classes, like Haskell or PureScript, all of this is given to you out of the box with clean syntax. In TypeScript you pay for the simulation: the HKT encoding, the arity-numbered interfaces, more ceremony per instance. That is the tax for using these patterns in a language that was not built for them.
It is often more than a typical app needs. Pulling in fp-ts or Effect and going full type-class is powerful, and on the right team and problem it pays off enormously in safety and composability. But it has a steep learning curve and it can alienate teammates who have not climbed it. For a lot of everyday TypeScript, using Option-like and Either-like patterns lightly, without the full higher-kinded machinery, is the right ROI. Reach for the full apparatus when the problem and the team justify it, not as a default flex.
Do not confuse type class with class. The word "class" in "type class" has nothing to do with OOP classes. A type class is a group of types unified by behavior. An OOP class is a blueprint for objects. Same word, unrelated concepts. The naming is unfortunate.
The Honest Conclusion
Semigroup, Monoid, and Functor were never three unrelated ideas, they are all type classes: named circles drawn around the types that support a given behavior. Type classes are how functional programming does ad-hoc polymorphism, many type-specific implementations sharing one interface, selected by type. To talk about "any Functor," you have to abstract over type constructors, which means higher-kinded types.
And that is exactly where TypeScript runs out of road. It has no native higher-kinded types, so it cannot express one universal map. fp-ts wins anyway by simulating the feature, and the price of that simulation, the HKT encoding and the Functor1/Functor2 clutter, is the very thing that makes fp-ts look intimidating. Now you know it is not arbitrary complexity, it is the honest cost of carrying a feature the language does not have.
fp-ts is not complicated for fun. It is carrying the weight of a feature TypeScript does not have yet, and once you see the shape of that weight, the library stops being scary.
Next up: We answer the question every reader has been holding since the very first part. If functions cannot have side effects, how do you build a real app that talks to a database, calls an API, or reads the clock? Next we meet IO and Task, and side effects stop being a paradox and become an architecture.