📝 en ~ 16 min read ~ ☕☕
Category Theory for JavaScript/TypeScript Developers
Share this post
“Category theory is not a theory, it is a language.”
— attributed to Peter Freyd, co-creator of the Freyd-Mitchell embedding theorem and pioneer of categorical logic
Since we have Category Theory for Programmers by Bartosz Milewski, which leans heavily on Haskell, could we create something tailored for JavaScript and TypeScript developers? Can we have cheese functions? This post explores that idea—building a small embedded DSL that makes categorical structure explicit, and revealing why your everyday .then() and .flatMap() are actually deep mathematical concepts in disguise.
Why Category Theory Matters for JS/TS Developers
The abstract machinery of adjunctions and monads isn’t academic indulgence—it’s a compression algorithm for design patterns. Once you see the structure, you stop reinventing it poorly.
Already familiar with Category Theory? Skip to the TypeScript implementation →
Category Theory Crash Course
If you’ve never encountered Category Theory before, don’t worry. This section gives you the essential concepts. If you’re already comfortable with categories, functors, and natural transformations, feel free to skip ahead.
What is a Category?
A category consists of:
- Objects (think: types like
number,string,User) - Morphisms (arrows between objects—think: functions)
- Composition (if you have and , you get )
- Identity morphisms (every object has an )
The category laws must hold:
The key insight: this is exactly how types and functions work in programming! TypeScript types are objects, functions between them are morphisms.
// Objects: number, string// Morphism f: number → numberconst double = (x: number): number => x * 2;
// Morphism g: number → stringconst toString = (x: number): string => `Value: ${x}`;
// Composition g ∘ double: number → stringconst doubleAndShow = (x: number): string => toString(double(x));Functors: Structure-Preserving Maps
A functor maps one category to another while preserving the structure (composition and identity).
In programming terms: a functor is a type constructor with a map function that:
- Takes a function
- Returns a function
// Array is a functorconst numbers: number[] = [1, 2, 3];const doubled: number[] = numbers.map(x => x * 2); // [2, 4, 6]
// Promise is a functorconst promise: Promise<number> = Promise.resolve(5);const mapped: Promise<number> = promise.then(x => x * 2);The functor laws ensure map preserves structure:
Natural Transformations: Mappings Between Functors
A natural transformation is a family of morphisms that “translate” one functor into another, in a way that respects the structure.
For each object , we have a component .
// Natural transformation: Array → Option (get first element)const head = <A>(arr: A[]): Option<A> => arr.length > 0 ? some(arr[0]) : none;
// This works for any type A - that's what makes it "natural"head([1, 2, 3]); // some(1)head(['a', 'b']); // some('a')head<number>([]); // noneThe naturality condition ensures this diagram commutes:
In code: head(arr.map(f)) === head(arr).map(f) — mapping then transforming equals transforming then mapping.
Monads: Composing Effects
A monad is a functor with two additional operations:
- unit (or
pure,return): — wraps a value - join (or
flatten): — flattens nested contexts
Alternatively, with flatMap (Kleisli composition):
// Promise is a monadconst pure = <A>(a: A): Promise<A> => Promise.resolve(a);
// flatMap chains dependent async operationsconst fetchUser = (id: string): Promise<User> => /* ... */;const fetchProfile = (user: User): Promise<Profile> => /* ... */;
// Without monad: callback hellfetchUser(id).then(user => fetchProfile(user).then(profile => /* ... */ ));
// With flatMap thinking:fetchUser(id) .then(user => fetchProfile(user)) .then(profile => /* ... */);The monad laws ensure predictable composition:
In Kleisli form:
- (left identity)
- (right identity)
- (associativity)
Adjunctions: The Source of Monads
An adjunction is a pair of functors and with a special relationship. They’re “almost inverses” but not quite.
The key components:
- Unit — “lift then lower doesn’t get you back”
- Counit — “lower then lift can be collapsed”
The triangle identities must hold:
Here’s the magic: Every adjunction gives rise to a monad where:
- (unit)
- (join)
This is why monads appear everywhere in programming—they emerge naturally from the fundamental relationships between type constructors!
The Yoneda Lemma: The Most Important Result
The Yoneda Lemma is often called the most important result in category theory. It states that for any functor and object :
In plain English: natural transformations from the hom-functor to F are in one-to-one correspondence with elements of F(A).
What does this mean for programmers? The Yoneda type is:
It’s a function that, given any function from to , produces an . The “forall B” is key—it must work for any type the caller chooses.
// Yoneda in TypeScriptinterface Yoneda<F, A> { run: <B>(f: (a: A) => B) => F;}
// Lift F<A> into Yonedaconst toYoneda = <A>(arr: A[]): Yoneda<A[], A> => ({ run: <B>(f: (a: A) => B) => arr.map(f)});
// Lower back to F<A> (run with identity)const fromYoneda = <A>(yoneda: Yoneda<A[], A>): A[] => yoneda.run((a: A) => a);Why does this matter?
-
CPS is Yoneda: The type
(A → R) → Ris exactly the Yoneda embedding applied to the Identity functor. Continuation-passing style isn’t just a technique—it’s category theory! -
Free functor mapping:
Yoneda<F, A>gives youmapfor free, without knowing anything aboutF’s structure. Maps accumulate as function composition until you “lower” back. -
Fusion optimization: Multiple maps fuse into a single traversal:
// Without Yoneda: 3 traversalsconst result1 = arr.map(f).map(g).map(h);
// With Yoneda: 1 traversal (maps compose)const yoneda = toYoneda(arr);const mapped = yonedaMap(yonedaMap(yonedaMap(yoneda, f), g), h);const result2 = fromYoneda(mapped); // Single traversal with h ∘ g ∘ fCoyoneda: The Dual
Coyoneda is the dual of Yoneda, using an existential quantifier:
It stores an for some unknown type , along with a function .
The remarkable property: Coyoneda gives you a Functor for free, even if isn’t a functor!
interface Coyoneda<F, A> { value: F; // F<X> for some X transform: (x: unknown) => A; // X → A}
// Map is always free—just compose!const coyonedaMap = <F, A, B>( coyoneda: Coyoneda<F, A>, f: (a: A) => B): Coyoneda<F, B> => ({ value: coyoneda.value, transform: (x) => f(coyoneda.transform(x))});This is incredibly powerful for building DSLs and interpreters—you can defer the actual functor operations until interpretation time.
The Problem It Solves
JavaScript developers constantly face the same compositional challenges:
-
Sequencing operations that might fail — you chain
.then()on Promises, but what about operations that might returnnull? You end up with nestedifchecks. -
Combining effects — logging, async, error handling, state. Each one infects your function signatures differently, and combining them creates exponential complexity.
-
Refactoring safely — when can you reorder operations? When is
f(g(x))equivalent to some other composition? Without laws, you’re guessing.
Category theory gives you:
- A vocabulary for recognizing when two seemingly different patterns are the same structure
- Laws that guarantee when refactoring is safe
- Composition rules that let you build complex behavior from simple pieces
Why TypeScript Can Express This
TypeScript’s type system has grown sophisticated enough to express many categorical concepts. You can encode functors, natural transformations, and even some higher-kinded type patterns (with workarounds).
The ecosystem already has libraries like fp-ts and Effect that bring these ideas into practice, so there’s a bridge between theory and something developers already encounter.
JavaScript’s first-class functions and closures give you the compositional building blocks. And the prevalence of Promises/async-await means developers already think in terms of monadic patterns, even if they don’t call them that.
Building an Embedded DSL
Rather than relying on implicit patterns, let’s make categorical structure explicit and manipulable.
Explicit Morphism Representation
Instead of just using functions directly, we wrap them in a structure that carries metadata and enforces composition laws:
interface Morphism<A, B> { source: string; // for debugging/visualization target: string; apply: (a: A) => B;}
const compose = <A, B, C>( g: Morphism<B, C>, f: Morphism<A, B>): Morphism<A, C> => ({ source: f.source, target: g.target, apply: (a) => g.apply(f.apply(a))});
const identity = <A>(label: string): Morphism<A, A> => ({ source: label, target: label, apply: (a) => a});Now composition is a first-class operation you can inspect, not just function application.
Functors as Explicit Mappings
Rather than relying on the implicit convention that “a functor has a map method,” we define functors as objects that explicitly transform both objects and morphisms:
interface Functor<F> { // Maps morphisms between categories fmap: <A, B>(f: Morphism<A, B>) => Morphism<F<A>, F<B>>;}
// Array functor made explicitconst ArrayFunctor: Functor<Array> = { fmap: (f) => ({ source: `Array<${f.source}>`, target: `Array<${f.target}>`, apply: (arr) => arr.map(f.apply) })};Natural Transformations as First-Class Values
interface NaturalTransformation<F, G> { component: <A>(fa: F<A>) => G<A>;}
// Example: the "head" transformation from Array to Optionconst headTransform: NaturalTransformation<Array, Option> = { component: (arr) => arr.length > 0 ? some(arr[0]) : none};Adjunctions: Where Monads Come From
An adjunction is a pair of functors going in opposite directions with a special relationship. The classic example: the “free” and “forgetful” functors between sets and monoids.
Adjunction & Monad Visualizer
// Unit is "pure" or "return"
const pure = <A>(a: A): Promise<A> =>
Promise.resolve(a);
// For Array:
const pure = <A>(a: A): A[] => [a];Every Promise.resolve(x) or [x] is the unit—the entry point into the monad.
interface Adjunction<F, G> { // F is the left adjoint, G is the right adjoint left: Functor<F>; right: Functor<G>;
// The unit: A → G(F(A)) // "embed a value into the round-trip" unit: <A>(a: A) => G<F<A>>;
// The counit: F(G(B)) → B // "collapse the round-trip back down" counit: <B>(fgb: F<G<B>>) => B;}The unit and counit must satisfy the triangle identities, which we can express as testable laws:
const triangleLaws = <F, G, A, B>(adj: Adjunction<F, G>) => ({ // F(unit) ∘ counit_F = id_F leftTriangle: <X>(fx: F<X>): boolean => { const up = adj.left.fmap({ apply: adj.unit, source: 'X', target: 'G<F<X>>' }); return adj.counit(up.apply(fx)) === fx; },
// unit_G ∘ G(counit) = id_G rightTriangle: <X>(gx: G<X>): boolean => { const down = adj.right.fmap({ apply: adj.counit, source: 'F<G<X>>', target: 'X' }); return down.apply(adj.unit(gx)) === gx; }});Monads Emerge from Adjunctions
Here’s the magic. Given any adjunction, you get a monad for free by composing the functors:
const monadFromAdjunction = <F, G>(adj: Adjunction<F, G>) => { // The monad's type constructor is G ∘ F // M<A> = G<F<A>>
return { // pure/return is just the unit pure: <A>(a: A): G<F<A>> => adj.unit(a),
// flatMap/bind comes from the counit flatMap: <A, B>( ma: G<F<A>>, f: Morphism<A, G<F<B>>> ): G<F<B>> => { // 1. Apply G(F(f)) to get G<F<G<F<B>>>> const lifted = adj.right.fmap(adj.left.fmap(f)); const nested: G<F<G<F<B>>>> = lifted.apply(ma);
// 2. Use counit inside G to flatten const flatten = adj.right.fmap({ source: 'F<G<F<B>>>', target: 'F<B>', apply: adj.counit });
return flatten.apply(nested); },
// join is just G(counit) join: <A>(mma: G<F<G<F<A>>>>): G<F<A>> => { return adj.right.fmap({ source: 'F<G<F<A>>>', target: 'F<A>', apply: adj.counit }).apply(mma); } };};Promise is a Monad
Now we can show that Promise is a monad, and why:
const PromiseMonad = { pure: <A>(a: A): Promise<A> => Promise.resolve(a),
flatMap: <A, B>( pa: Promise<A>, f: Morphism<A, Promise<B>> ): Promise<B> => pa.then(f.apply),
join: <A>(ppa: Promise<Promise<A>>): Promise<A> => ppa.then(x => x)};The monad laws become testable:
const monadLaws = <M, A, B, C>( monad: Monad<M>, a: A, f: Morphism<A, M<B>>, g: Morphism<B, M<C>>) => ({ // Left identity: pure(a).flatMap(f) === f(a) leftIdentity: async () => { const left = await monad.flatMap(monad.pure(a), f); const right = await f.apply(a); return left === right; },
// Right identity: m.flatMap(pure) === m rightIdentity: async (ma: M<A>) => { const left = await monad.flatMap(ma, { apply: monad.pure }); return left === ma; },
// Associativity associativity: async (ma: M<A>) => { const left = await monad.flatMap(monad.flatMap(ma, f), g); const right = await monad.flatMap(ma, { apply: (a) => monad.flatMap(f.apply(a), g) }); return left === right; }});Practical Payoffs
Before: Nested Null Checks
function processUser(id: string) { const user = getUser(id); if (user === null) return null;
const profile = getProfile(user.profileId); if (profile === null) return null;
const settings = getSettings(profile.settingsId); if (settings === null) return null;
return formatOutput(user, profile, settings);}After: Monadic Composition
const processUser = (id: string) => getUser(id) .flatMap(user => getProfile(user.profileId)) .flatMap(profile => getSettings(profile.settingsId)) .map(settings => formatOutput(settings));The second version isn’t just prettier. The monad laws guarantee that you can refactor, reorder, and compose these operations predictably. You’re not relying on convention—you’re relying on mathematics.
The Async Boundary: An Imperfect Adjunction
Consider the adjunction between synchronous and asynchronous code:
- F: Sync → Async lifts a value into a Promise:
Promise.resolve(x) - G: Async → Sync would be…
await. Butawaitonly works inside async functions.
This asymmetry—that F is easy but G is constrained—is exactly what adjunctions capture. The fact that you can’t have a “true” right adjoint here is why async/await “infects” your codebase.
Category theory gives you the language to articulate why this happens, not just that it does.
Practical Takeaways: What Changes Tomorrow?
So you’ve read about morphisms, functors, adjunctions, and monads. What actually changes in your daily JavaScript/TypeScript work?
1. Recognize the Pattern, Then Use the Library
You don’t need to implement Option or Either yourself. Libraries like fp-ts and Effect already have battle-tested implementations. What category theory gives you is the recognition:
- When you see nested
if (x !== null)checks → you’re missing an Option monad - When you see
try/catchscattered everywhere → you’re missing an Either/Result type - When you see callback pyramids → you’re missing proper monadic composition
2. Trust the Laws for Refactoring
Before category theory: “I think I can move this .then() around, let me test it.”
After category theory: “The monad laws guarantee associativity, so a.then(f).then(g) equals a.then(x => f(x).then(g)). I know this refactoring is safe.”
3. Understand Why Some APIs Feel Wrong
Ever used an API that just felt… off? Category theory gives you vocabulary:
- Lack of composability: The API doesn’t form a proper functor (map doesn’t preserve identity or composition)
- Effect leakage: Side effects aren’t contained in a proper monad structure
- Missing laws: The library’s
flatMapdoesn’t satisfy associativity, leading to subtle bugs
4. Design Better Interfaces
When designing your own utilities:
// Bad: Ad-hoc, doesn't composefunction maybeGetUser(id: string): User | null { ... }function maybeGetProfile(user: User): Profile | null { ... }
// Good: Monadic, composes naturallyfunction getUser(id: string): Option<User> { ... }function getProfile(user: User): Option<Profile> { ... }
// Now this just works:getUser(id).flatMap(getProfile).map(formatProfile)5. Stop Fighting Async/Await Infection
Now you understand why async spreads through your codebase: there’s no true right adjoint. Instead of fighting it:
- Accept that async boundaries are real architectural decisions
- Use Effect or similar libraries that give you better control over the effect boundary
- Structure your code so async boundaries are intentional, not accidental
The Meta-Takeaway
Category theory won’t make you write different code tomorrow. But it will make you see the code differently. You’ll notice patterns you didn’t before. You’ll understand why some designs feel elegant and others feel brittle. And when you reach for fp-ts or Effect, you’ll know why those abstractions work, not just how to use them.
Further Reading
- Category Theory for Programmers by Bartosz Milewski
- fp-ts - Functional programming in TypeScript
- Effect - A powerful effect system for TypeScript
- Professor Frisby’s Mostly Adequate Guide to Functional Programming
Conclusion
The jump from “Category Theory for Programmers” to something tailored for JS/TS developers is substantial but worthwhile. By building an explicit DSL, we can:
- See that
Promise.thenisflatMap, and understand why it “feels right” - Recognize that the monad laws aren’t arbitrary—they’re the triangle identities of an underlying adjunction
- Construct new monads by identifying adjunctions in our domain
The goal isn’t to write category theory proofs in production code. It’s to develop intuition for compositional patterns that you already use daily—and to know when they’ll compose predictably, and when they won’t.
Explore the Companion Library
All the code from this post—and much more—is available as a TypeScript library you can install and experiment with:
github.com/ibrahimcesar/category-theory-for-the-javascript-typescript-developers
The library includes:
- Complete implementations of Morphisms, Functors, Natural Transformations, Adjunctions, and Monads
- The Yoneda Lemma and Coyoneda, including CPS (Continuation-Passing Style)
- Practical monads: Option, Either, Reader, Writer, State, and IO
- Laws verification functions to test your own implementations
- Working examples demonstrating each concept
Whether you want to learn by reading code, contribute improvements, or just explore category theory through TypeScript—jump in! Issues, PRs, and stars are all welcome.