Category theory often sounds like impenetrable academic jargon, but at its core, it is simply the mathematics of composition. In functional programming, we use its concepts to build modular, predictable, and bug-resistant code.
JavaScript isn't a pure functional language like Haskell, but it has first-class functions and treats functions as values. This makes it entirely possible to implement category theory primitives.
Here is how the theoretical concepts map to practical JavaScript.
The Vocabulary of Category Theory
Before jumping into code, let's define the fundamental pieces:
- Categories: A collection of objects and arrows (morphisms) between them.
- Objects: In programming, these are our types or data (e.g., String, Number, Boolean, Array).
- Morphisms: These are our pure functions that transform one type into another (e.g., a function that takes a String and returns a Number).
The goal of functional programming primitives is to create safe wrappers (containers) around our data so we can compose these functions predictably, without side effects or unhandled errors.
1. Functors: The Mappables
A Functor is any type that implements a map method. It is a container holding a value, and map allows you to apply a function to that value without pulling it out of the container.
When you map over a Functor, it returns a new Functor of the same type, preserving the container's structure.
The native JS Functor:
You already use Functors every day. JavaScript Arrays are Functors.
const numbers = [1, 2, 3];
// We apply a function to the values inside, and get a new Array back
const doubled = numbers.map(x => x * 2); // [2, 4, 6]
Building a custom Functor:
Let's build a simple Box container to see how this works for single values.
const Box = x => ({
map: f => Box(f(x)),
fold: f => f(x), // An escape hatch to get the value out
inspect: () => `Box(${x})`
});
// Usage:
const result = Box(' Functional Programming ')
.map(str => str.trim())
.map(str => str.toLowerCase())
.map(str => str.split(' '));
console.log(result.inspect()); // Box(Array(2))
console.log(result.fold(x => x)); // ['functional', 'programming']
Because map always returns a Box, we can chain operations infinitely.
2. Monoids: The Combiners
A Monoid is a type that has two things:
-
An identity value: A value that, when combined with another value, doesn't change it (like
0for addition, or""for strings). -
An associative binary operation: A function that combines two values of the same type into one (like
+orconcat()). "Associative" just means the grouping doesn't matter:(a + b) + c === a + (b + c).
The native JS Monoids:
Strings and Numbers are standard monoids under specific operations.
// String Monoid (Identity is "")
const strConcat = "hello" + ""; // "hello"
// Number Monoid for Addition (Identity is 0)
const numAdd = 5 + 0; // 5
// Array Monoid (Identity is [])
const arrConcat = [1, 2].concat([]); // [1, 2]
Monoids are incredibly powerful for reducing a list of things into a single thing. This is exactly what Array.prototype.reduce relies on.
3. Monads: The Flatteners
The infamous Monad. The standard joke is that "a monad is just a monoid in the category of endofunctors," which is true mathematically but useless for programming.
Practically, a Monad is a Functor that also implements a flatMap (or chain, or bind) method.
The Problem Monads Solve:
If you have a function that returns a Box, mapping it over a Box results in a nested container: Box(Box(value)). A Monad knows how to flatten itself to prevent this nesting.
// A function that returns a container
const half = x => (x % 2 === 0 ? Box(x / 2) : Box("Odd"));
// Using map creates nested Boxes
const nested = Box(4).map(half);
console.log(nested.inspect()); // Box(Box(2))
Adding Monadic Behavior:
To make our Box a Monad, we add chain (the JS convention for flatMap).
const MonadicBox = x => ({
map: f => MonadicBox(f(x)),
chain: f => f(x), // Instead of re-boxing, we just return the result of f(x)
inspect: () => `Box(${x})`
});
const flatResult = MonadicBox(4).chain(half);
console.log(flatResult.inspect()); // Box(2) - No nesting!
The native JS Monads:
-
Array.prototype.flatMapmakes Arrays monads. -
Promiseis heavily inspired by Monads. Calling.then()with a function that returns a new Promise automatically flattens the result. You never getPromise<Promise<Data>>.
4. Applicatives: The Multi-Container Operators
What happens if you have a function wrapped in a Functor, and you want to apply it to a value wrapped in another Functor? A standard Functor's map expects a normal function, not a wrapped one.
An Applicative implements an ap method to solve this.
const AppBox = x => ({
map: f => AppBox(f(x)),
ap: otherBox => otherBox.map(x), // x here is actually a function!
inspect: () => `Box(${x})`
});
const boxWithFunction = AppBox(x => x + 10);
const boxWithValue = AppBox(5);
// Applies the wrapped function to the wrapped value
const result = boxWithFunction.ap(boxWithValue);
console.log(result.inspect()); // Box(15)
Summary of Primitives
Here is a quick reference guide to how these primitives build on top of each other:
| Primitive | Core Interface | Purpose |
|---|---|---|
| Functor | map |
Apply a function to a wrapped value. |
| Applicative | ap |
Apply a wrapped function to a wrapped value. |
| Monad |
chain / flatMap
|
Sequence operations that return containers, preventing nesting. |
| Monoid |
concat + empty
|
Combine values of the same type safely. |
Top comments (0)