Skip to content

ibrahimcesar/category-theory-for-the-javascript-typescript-developers

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

2 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸ”’ Category Theory for the JavaScript Developer

An embedded DSL that makes categorical structure explicit and manipulable in TypeScript/JavaScript.

Core Concepts | Practical payoffs | Usage | Blog post

Why?

Category Theory for Programmers by Bartosz Milewski is excellent but leans heavily on Haskell. This project bridges that gap for the JS/TS ecosystem.

The goal isn't to write category theory proofs in production code. It's to develop intuition for compositional patterns you already use dailyβ€”and to know when they'll compose predictably, and when they won't.

What You'll Learn

  • Monads aren't magicβ€”they're what you get when you have a round-trip (adjunction) and compose the two functors
  • Promise.then is flatMap, and the reason it "feels right" is because it satisfies the monad laws
  • The laws aren't arbitraryβ€”they're the triangle identities of the underlying adjunction, bubbling up
  • Why async/await "infects" your codebase (hint: it's an imperfect adjunction)

Core Concepts

Morphisms as First-Class Values

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))
});

Functors as Explicit Mappings

interface Functor<F> {
  fmap: <A, B>(f: Morphism<A, B>) => Morphism<F<A>, F<B>>;
}

const ArrayFunctor: Functor<Array> = {
  fmap: (f) => ({
    source: `Array<${f.source}>`,
    target: `Array<${f.target}>`,
    apply: (arr) => arr.map(f.apply)
  })
};

Adjunctions: Where Monads Come From

interface Adjunction<F, G> {
  left: Functor<F>;   // F: C β†’ D (left adjoint)
  right: Functor<G>;  // G: D β†’ C (right adjoint)

  unit: <A>(a: A) => G<F<A>>;           // Ξ·: A β†’ G(F(A))
  counit: <B>(fgb: F<G<B>>) => B;       // Ξ΅: F(G(B)) β†’ B
}

Monads Emerge Naturally

const monadFromAdjunction = <F, G>(adj: Adjunction<F, G>) => ({
  // M<A> = G<F<A>>
  pure: <A>(a: A) => adj.unit(a),

  join: <A>(mma: G<F<G<F<A>>>>): G<F<A>> =>
    adj.right.fmap({ apply: adj.counit }).apply(mma),

  flatMap: <A, B>(ma: G<F<A>>, f: (a: A) => G<F<B>>) =>
    join(adj.right.fmap(adj.left.fmap({ apply: f })).apply(ma))
});

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;

  return formatOutput(user, profile);
}

After: Monadic Composition

const processUser = (id: string) =>
  getUser(id)
    .flatMap(user => getProfile(user.profileId))
    .map(profile => formatOutput(profile));

The monad laws guarantee safe refactoring. You're relying on mathematics, not convention.

Project Structure

src/
β”œβ”€β”€ core/                      # Core categorical abstractions
β”‚   β”œβ”€β”€ morphism.ts            # Morphism type, composition, identity
β”‚   β”œβ”€β”€ functor.ts             # Functor interface (Array, Option, Identity)
β”‚   β”œβ”€β”€ natural.ts             # Natural transformations (head, last, flatten, etc.)
β”‚   β”œβ”€β”€ adjunction.ts          # Adjunction interface and triangle identities
β”‚   β”œβ”€β”€ monad.ts               # Monad (Array, Option, Promise) + Kleisli composition
β”‚   β”œβ”€β”€ yoneda.ts              # Yoneda lemma, Coyoneda, Continuations (CPS)
β”‚   └── index.ts
β”œβ”€β”€ instances/                 # Additional monad implementations
β”‚   β”œβ”€β”€ either.ts              # Either<E, A> - typed error handling (Left/Right)
β”‚   β”œβ”€β”€ reader.ts              # Reader<R, A> - dependency injection pattern
β”‚   β”œβ”€β”€ state.ts               # State<S, A> - stateful computations
β”‚   β”œβ”€β”€ writer.ts              # Writer<W, A> - logging/accumulation with Monoid
β”‚   β”œβ”€β”€ io.ts                  # IO<A> - encapsulating side effects
β”‚   └── index.ts
β”œβ”€β”€ laws/                      # Law verification functions
β”‚   β”œβ”€β”€ functor-laws.ts        # Identity and composition laws
β”‚   β”œβ”€β”€ monad-laws.ts          # Left/right identity, associativity
β”‚   β”œβ”€β”€ natural-laws.ts        # Naturality condition
β”‚   β”œβ”€β”€ adjunction-laws.ts     # Triangle identities
β”‚   └── index.ts
β”œβ”€β”€ examples/                  # Practical demonstrations
β”‚   β”œβ”€β”€ option-chaining.ts     # Safe navigation, Kleisli composition
β”‚   β”œβ”€β”€ async-composition.ts   # Promise composition, TaskEither, retry
β”‚   β”œβ”€β”€ state-monad.ts         # Counter, stack, RNG, game state, parser
β”‚   └── index.ts
└── index.ts                   # Main entry point with all exports

Installation

npm install
npm run build
npm test

Usage

import {
  // Core
  morphism, compose, identity, pipe,
  // Option monad
  some, none, isSome, OptionMonad,
  // Either monad
  left, right, isRight, EitherMonad,
  // Other monads
  ReaderMonad, StateMonad, IOMonad,
  // Law verification
  checkFunctorLaws, checkMonadLaws,
} from 'category-theory-js';

// Create morphisms with explicit types
const double = morphism('number', 'number', (x: number) => x * 2);
const toString = morphism('number', 'string', (x: number) => `Value: ${x}`);

// Compose them (right-to-left, mathematical style)
const doubleAndStringify = compose(toString, double);
console.log(doubleAndStringify.apply(21)); // "Value: 42"

// Option monad - safe null handling
const safeDivide = (a: number, b: number) =>
  b === 0 ? none : some(a / b);

const result = OptionMonad.flatMap(
  OptionMonad.flatMap(some(10), x => safeDivide(x, 2)),
  x => safeDivide(x, 5)
);
console.log(result); // { _tag: 'Some', value: 1 }

// Either monad - typed error handling
const parseNumber = (s: string) => {
  const n = parseFloat(s);
  return isNaN(n) ? left('Not a number') : right(n);
};

const parsed = EitherMonad.flatMap(
  parseNumber('42'),
  n => right(n * 2)
);
console.log(parsed); // { _tag: 'Right', right: 84 }

Available Monads

Monad Type Use Case
Option Option<A> Nullable values, safe property access
Either Either<E, A> Typed error handling, validation
Reader Reader<R, A> Dependency injection, configuration
State State<S, A> Stateful computations, parsing
Writer Writer<W, A> Logging, accumulating output
IO IO<A> Side effects, lazy evaluation
Promise Promise<A> Async operations (built-in)
Array A[] Non-determinism, multiple results

The Laws (Testable!)

import {
  checkFunctorLaws,
  checkMonadLaws,
  verifyNaturality,
  checkAdjunctionLaws,
} from 'category-theory-js';

// Verify functor laws for Array
const functorResult = checkFunctorLaws(
  'Array',
  { fmap: (arr, f) => arr.map(f) },
  [
    { value: [1, 2, 3], f: (x: number) => x * 2, g: (x: number) => x + 1 }
  ]
);
console.log(functorResult.passed); // true

// Verify monad laws for Option
const monadResult = checkMonadLaws(
  'Option',
  OptionMonad,
  [{
    value: 5,
    monadicValue: some(5),
    f: (x: number) => some(x * 2),
    g: (x: number) => some(x + 1),
  }]
);
console.log(monadResult.passed); // true
// Results include: Left Identity, Right Identity, Associativity

The Three Monad Laws

  1. Left Identity: pure(a).flatMap(f) === f(a)
  2. Right Identity: m.flatMap(pure) === m
  3. Associativity: m.flatMap(f).flatMap(g) === m.flatMap(x => f(x).flatMap(g))

Yoneda Lemma

The Yoneda lemma is one of the most profound results in category theory. In programming terms:

import {
  toYoneda, fromYoneda, yonedaMap,
  cont, contMap, contFlatMap, runCont,
  demonstrateFusion,
} from 'category-theory-js';

// Yoneda gives you FREE functor mapping through function composition
// Multiple maps fuse into a single traversal:

const result = demonstrateFusion(
  [1, 2, 3],
  x => x * 2,    // double
  x => x + 1,    // increment
  x => x * x     // square
);
// Without fusion: 3 traversals
// With Yoneda: 1 traversal with composed function (x => ((x * 2) + 1)Β²)
console.log(result.isEqual); // true

// Continuations ARE Yoneda (for Identity functor)
// Cont<A> = βˆ€R. (A β†’ R) β†’ R

const computation = contFlatMap(
  contFlatMap(cont(10), x => cont(x * 2)),
  x => cont(x + 5)
);
console.log(runCont(computation)); // 25

Why it matters:

  • CPS is Yoneda: Continuation-passing style is the Yoneda embedding
  • Fusion: Multiple map calls become one traversal
  • Coyoneda: Get a functor instance for ANY type, for free

Further Reading

Related

License

MIT

About

πŸ”’ Category Theory for JavaScript/TypeScript developers. An embedded DSL making functors, monads, and adjunctions explicit and manipulable.

Topics

Resources

License

Stars

Watchers

Forks