“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 C \mathcal{C} C consists of:
Objects (think: types like number , string , User )
(think: types like , , ) Morphisms (arrows between objects—think: functions)
(arrows between objects—think: functions) Composition (if you have f : A → B f: A \to B f : A → B and g : B → C g: B \to C g : B → C , you get g ∘ f : A → C g \circ f: A \to C g ∘ f : A → C )
(if you have and , you get ) Identity morphisms (every object has an id A : A → A \text{id}_A: A \to A id A : A → A )
A f B g C Composition: g ∘ f
The category laws must hold:
(Associativity) ( h ∘ g ) ∘ f = h ∘ ( g ∘ f ) \text{(Associativity)} \quad (h \circ g) \circ f = h \circ (g \circ f) (Associativity) ( h ∘ g ) ∘ f = h ∘ ( g ∘ f )
(Identity) f ∘ id A = f = id B ∘ f \text{(Identity)} \quad f \circ \text{id}_A = f = \text{id}_B \circ f (Identity) f ∘ id A = f = id B ∘ f
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 → number const double = ( x : number ) : number => x * 2 ; // Morphism g: number → string const toString = ( x : number ) : string => `Value: ${ x }` ; // Composition g ∘ double: number → string const doubleAndShow = ( x : number ) : string => toString ( double (x));
Functors: Structure-Preserving Maps
A functor F : C → D F: \mathcal{C} \to \mathcal{D} F:C→D maps one category to another while preserving the structure (composition and identity).
In programming terms: a functor is a type constructor F F F with a map function that:
Takes a function f : A → B f: A \to B f : A → B
Returns a function F ( f ) : F ( A ) → F ( B ) F(f): F(A) \to F(B) F ( f ) : F ( A ) → F ( B )
Category C Category D A f B F(A) F(f) F(B) F Functor F: C → D preserves structure
// Array is a functor const numbers : number [] = [ 1 , 2 , 3 ]; const doubled : number [] = numbers. map ( x => x * 2 ); // [2, 4, 6] // Promise is a functor const promise : Promise < number > = Promise . resolve ( 5 ); const mapped : Promise < number > = promise. then ( x => x * 2 );
The functor laws ensure map preserves structure:
F ( id A ) = id F ( A ) (preserves identity) F(\text{id}_A) = \text{id}_{F(A)} \quad \text{(preserves identity)} F ( id A ) = id F ( A ) (preserves identity)
F ( g ∘ f ) = F ( g ) ∘ F ( f ) (preserves composition) F(g \circ f) = F(g) \circ F(f) \quad \text{(preserves composition)} F ( g ∘ f ) = F ( g ) ∘ F ( f ) (preserves composition)
Natural Transformations: Mappings Between Functors
A natural transformation α : F ⇒ G \alpha: F \Rightarrow G α:F⇒G is a family of morphisms that “translate” one functor into another, in a way that respects the structure.
For each object A A A, we have a component α A : F ( A ) → G ( A ) \alpha_A: F(A) \to G(A) αA:F(A)→G(A).
F(A) αₐ G(A) Natural transformation α: F ⇒ G
// 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 >([]); // none
The naturality condition ensures this diagram commutes:
G ( f ) ∘ α A = α B ∘ F ( f ) G(f) \circ \alpha_A = \alpha_B \circ F(f) G ( f ) ∘ α A = α B ∘ F ( f )
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 M M M with two additional operations:
unit (or pure , return ): η A : A → M ( A ) \eta_A: A \to M(A) η A : A → M ( A ) — wraps a value
(or , ): — wraps a value join (or flatten ): μ A : M ( M ( A ) ) → M ( A ) \mu_A: M(M(A)) \to M(A) μ A : M ( M ( A )) → M ( A ) — flattens nested contexts
Alternatively, with flatMap (Kleisli composition):
flatMap : M ( A ) → ( A → M ( B ) ) → M ( B ) \text{flatMap}: M(A) \to (A \to M(B)) \to M(B) flatMap : M ( A ) → ( A → M ( B )) → M ( B )
unit (η) A η M(A) join (μ) M M μ M(A) Monad M: unit η and join μ
// Promise is a monad const pure = < A >( a : A ) : Promise < A > => Promise . resolve (a); // flatMap chains dependent async operations const fetchUser = ( id : string ) : Promise < User > => /* ... */ ; const fetchProfile = ( user : User ) : Promise < Profile > => /* ... */ ; // Without monad: callback hell fetchUser (id). then ( user => fetchProfile (user). then ( profile => /* ... */ ) ); // With flatMap thinking: fetchUser (id) . then ( user => fetchProfile (user)) . then ( profile => /* ... */ );
The monad laws ensure predictable composition:
μ ∘ M ( η ) = id M = μ ∘ η M (unit laws) \mu \circ M(\eta) = \text{id}_M = \mu \circ \eta_M \quad \text{(unit laws)} μ ∘ M ( η ) = id M = μ ∘ η M (unit laws)
μ ∘ M ( μ ) = μ ∘ μ M (associativity) \mu \circ M(\mu) = \mu \circ \mu_M \quad \text{(associativity)} μ ∘ M ( μ ) = μ ∘ μ M (associativity)
In Kleisli form:
η ( a ) ≫ = f = f ( a ) \eta(a) \gg\!= f = f(a) η ( a ) ≫ = f = f ( a ) (left identity) m ≫ = η = m m \gg\!= \eta = m m ≫ = η = m (right identity) ( m ≫ = f ) ≫ = g = m ≫ = ( λ x . f ( x ) ≫ = g ) (m \gg\!= f) \gg\!= g = m \gg\!= (\lambda x. f(x) \gg\!= g) ( m ≫ = f ) ≫ = g = m ≫ = ( λ x . f ( x ) ≫ = g ) (associativity)
Adjunctions: The Source of Monads
An adjunction F ⊣ G F \dashv G F⊣G is a pair of functors F : C → D F: \mathcal{C} \to \mathcal{D} F:C→D and G : D → C G: \mathcal{D} \to \mathcal{C} G:D→C with a special relationship. They’re “almost inverses” but not quite.
The key components:
Unit η A : A → G ( F ( A ) ) \eta_A: A \to G(F(A)) η A : A → G ( F ( A )) — “lift then lower doesn’t get you back”
— “lift then lower doesn’t get you back” Counit ε B : F ( G ( B ) ) → B \varepsilon_B: F(G(B)) \to B ε B : F ( G ( B )) → B — “lower then lift can be collapsed”
C D unit η η G F counit ε F G ε Adjunction F ⊣ G: unit η and counit ε
The triangle identities must hold:
ε F ( A ) ∘ F ( η A ) = id F ( A ) \varepsilon_{F(A)} \circ F(\eta_A) = \text{id}_{F(A)} ε F ( A ) ∘ F ( η A ) = id F ( A )
G ( ε B ) ∘ η G ( B ) = id G ( B ) G(\varepsilon_B) \circ \eta_{G(B)} = \text{id}_{G(B)} G ( ε B ) ∘ η G ( B ) = id G ( B )
ε_F ∘ F(η) η ε = id_F F Triangle identity: zig-zag = straight line
Here’s the magic: Every adjunction F ⊣ G F \dashv G F⊣G gives rise to a monad M = G ∘ F M = G \circ F M=G∘F where:
η M = η \eta^M = \eta η M = η (unit)
(unit) μ M = G ( ε F ) \mu^M = G(\varepsilon_F) μ M = G ( ε F ) (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 F F F and object A A A:
Nat ( Hom ( A , − ) , F ) ≅ F ( A ) \text{Nat}(\text{Hom}(A, -), F) \cong F(A) Nat ( Hom ( A , − ) , F ) ≅ F ( A )
In plain English: natural transformations from the hom-functor to F are in one-to-one correspondence with elements of F(A).
Yoneda A (→B) run F(B) ≅ Element F(A) Yoneda: ∀B. (A → B) → F(B) ≅ F(A)
What does this mean for programmers? The Yoneda type is:
Yoneda [ F , A ] = ∀ B . ( A → B ) → F ( B ) \text{Yoneda}[F, A] = \forall B.\, (A \to B) \to F(B) Yoneda [ F , A ] = ∀ B . ( A → B ) → F ( B )
It’s a function that, given any function from A A A to B B B, produces an F ( B ) F(B) F(B). The “forall B” is key—it must work for any type the caller chooses.
// Yoneda in TypeScript interface Yoneda < F , A > { run : < B >( f : ( a : A ) => B ) => F ; } // Lift F<A> into Yoneda const 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) → R is 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 you map for free, without knowing anything about F ’s structure. Maps accumulate as function composition until you “lower” back. Fusion optimization: Multiple maps fuse into a single traversal:
// Without Yoneda: 3 traversals const 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 ∘ f
Coyoneda: The Dual
Coyoneda is the dual of Yoneda, using an existential quantifier:
Coyoneda [ F , A ] = ∃ X . ( F ( X ) , X → A ) \text{Coyoneda}[F, A] = \exists X.\, (F(X), X \to A) Coyoneda [ F , A ] = ∃ X . ( F ( X ) , X → A )
It stores an F ( X ) F(X) F(X) for some unknown type X X X, along with a function X → A X \to A X→A.
Coyoneda F(X) X→A fmap f F(A) ≅ Direct F(A) Coyoneda: ∃X. (F(X), X → A) ≅ F(A)
The remarkable property: Coyoneda gives you a Functor for free, even if F F F 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 return null ? You end up with nested if checks. 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
for recognizing when two seemingly different patterns are the same structure Laws that guarantee when refactoring is safe
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 explicit const 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 Option const 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 (pure) Counit (extract) Functor Map FlatMap (bind) Triangle Laws Category Diagram ← Prev ▶ Play Next → Category C Category D F → ← G F ⊣ G η (unit) A G(F(A)) Step 1 of 1 The unit η lifts a value into the monad M = G∘F TypeScript Hide // Unit is "pure" or "return" const pure = <A>(a: A): Promise<A> => Promise.resolve(a); // For Array: const pure = <A>(a: A): A[] => [a]; Practical Note Every Promise.resolve(x) or [x] is the unit—the entry point into the monad. Legend F (left adjoint) G (right adjoint) η (unit/pure) ε (counit)
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)
lifts a value into a Promise: G: Async → Sync would be… await . But await only 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
checks → you’re missing an Option monad When you see try/catch scattered everywhere → you’re missing an Either/Result type
scattered 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)
: 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
: Side effects aren’t contained in a proper monad structure Missing laws: The library’s flatMap doesn’t satisfy associativity, leading to subtle bugs
4. Design Better Interfaces
When designing your own utilities:
// Bad: Ad-hoc, doesn't compose function maybeGetUser ( id : string ) : User | null { ... } function maybeGetProfile ( user : User ) : Profile | null { ... } // Good: Monadic, composes naturally function 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
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.then is flatMap , and understand why it “feels right”
is , 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.