“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.