Category Theory

Package Version Hex Docs

Use expressions are Gleam’s flavour of Haskell’s do notation or Scala’s for comprehensions. Cat’s main feature is the Monad type that can be used with use expressions to create clean chains of code. The rest of the package implements several category theory concepts, following this book by Bartosz Milewski. These concepts prove extremely useful in understanding generic programming, a concept handled very neatly in Gleam.

gleam add cat

Monad examples

import cat/instances/monad.{option_monad as opt}
import gleam/option.{None, Some}

pub fn main() -> Nil {
  let sum = {
    use x <- opt().bind(Some(1))
    use y <- opt().bind(Some(2))
    use z <- opt().map(Some(3))
    x + y + z
  }
  let none = {
    use x <- opt().bind(Some(1))
    use y <- opt().bind(None)
    use z <- opt().map(Some(3))
    x + y + z
  }
  echo sum
  // -> Some(6)
  echo none
  // -> None
  Nil
}
import cat/instances/monad.{list_monad as lsm}
import gleam/int

pub fn main() -> Nil {
  let grocery_list = {
    use quantity <- lsm().bind([2, 3, 4])
    use fruit <- lsm().map(["apples", "oranges"])
    int.to_string(quantity * 10) <> " " <> fruit
  }
  echo grocery_list
  // -> ["20 apples", "20 oranges", "30 apples", "30 oranges", "40 apples", "40 oranges"]
  Nil
}
import cat.{type State, State}
import cat/instances/monad.{state_monad as st}

// Custom binary tree type
pub type Tree(a) {
  Leaf(val: a)
  Node(left: Tree(a), right: Tree(a))
}

// Get a new id
pub fn fresh() -> State(Int, Int) {
  State(run: fn(x) { #(x, x + 10) })
}

// Label a tree with new ids
pub fn label(t: Tree(a)) -> State(Int, Tree(Int)) {
  case t {
    Leaf(_) -> {
      use x <- st().bind(fresh())
      st().return(Leaf(x))
    }
    Node(left, right) -> {
      use left2 <- st().bind(label(left))
      use right2 <- st().map(label(right))
      Node(left2, right2)
    }
  }
}

pub fn main() -> Nil {
  let tree = Node(Node(Leaf("a"), Leaf("b")), Leaf("c"))
  // Relabel this tree using the state monad
  echo label(tree).run(5).0
  // -> Node(Node(Leaf(5), Leaf(15)), Leaf(25))
  Nil
}

A note on generic programming in Gleam

Gleam is designed to avoid unnecessary complexity. While this is a great design choice, the lack of type classes and other expressive features raises some issues when trying to design a library with moderate to high abstractions. This package can also be considered an experiment in pushing Gleam’s simple type system to the limit.

Let’s take a real example from this package, the Functor type, and see what problems we run into by trying to define it. At it’s most basic, a Functor instance consists of an implementation for the fmap function: fmap :: (a -> b) -> f a -> f b. We would like to define a sort of template that the user can later instantiate for specific types, such as List: fmap :: (a -> b) -> List(a) -> List(b).

The first limitation is Gleam’s lack of ad hoc polymorphism, as it does not support function overloading or type classes. That means the user would need to define separate fmap functions for each instantiation: list_fmap, option_fmap, result_fmap etc. While this results in readable code, it simply shortcircuits the generic aspect of the functor interface. The solution to this problem is wrapping fmap in a custom type, resulting in: list_functor.fmap, option_functor.fmap etc.

The next problem is that Gleam does not yet support Higher-Kinded Types. We would ideally want to write the functor type like this:

pub type Functor(f) { Functor(fmap: fn(fn(a) -> b) -> fn(f(a)) -> f(b)) }

However, this is not valid Gleam code as f is a simple type, not a type constructor. Compromise: we can rename f(a) and f(b) as the simple types fa and fb with the convention that they are types constructed by the Functor, such as Option(a) and Option(b). Now, it falls on the user to keep this convention as the gleam compiler doesn’t know the relationship between a and fa.

The third, and bigest, limitation is that gleam doesn’t allow free generics inside custom types. That means that the following code still doesn’t compile:

pub type Functor(f) { Functor(fmap: fn(fn(a) -> b) -> fn(fa) -> fb) }

Since the compiler does not recognize the generic types a, b, fa, and fb, they need to be passsed in like so:

pub type Functor(f, a, b, fa, fb) { Functor(fmap: fn(fn(a) -> b) -> fn(fa) -> fb) }

This final code snippet does compile and is the actual way the Functor type is implemented in cat. There is one big issue that arises from this implementation. Say we want to use the option_functor twice with the functions f :: fn(Int) -> String and g :: fn(Int) -> Bool, we cannot use the same option_functor as each instance requires the bound types a and b to be Int and String/Bool. So the types are not truly generic for the functor instance of Option. Even if we define the option_functor as Functor(f, a, b, Option(a), Option(b)), the concrete types get inferred at compile time. Therefore, we need to call option_functor()twice to get two separate instances: Functor(f, Int, String, Option(Int), Option(String)) and Functor(f, Int, Bool, Option(Int), Option(Bool)).

Finally, we choose to keep passing in the unused type f as to soleadify the fa and fb convention. This will be a phantom type like pub type OptionF that will then be used to return a yet-to-be-bound generic option functor: fn option_functor() -> Functor(OptionF, a, b, Option(a), Option(b)) { ... }.

Other CT examples

Functor & Applicative example

import cat/functor as fun
import cat/instances/applicative.{list_applicative}
import cat/instances/functor.{list_functor, option_functor}
import gleam/option.{None, Some}

pub fn main() -> Nil {
  echo Some([1, 2, 3])
    |> fun.functor_compose(option_functor(), list_functor()).fmap(fn(x) {
      x % 2 != 0
    })
  // -> Some([True, False, True])
  echo [Some(1), None, Some(3)]
    |> {
      [fn(x) { x * 2 }, fn(x) { x + 10 }]
      |> list_functor().fmap(option_functor().fmap)
      |> list_applicative().apply
    }
  // -> [Some(2), None, Some(6), Some(11), None, Some(13)]
  Nil
}

Monoid Example

import cat.{type Either, Left, Right}
import cat/monoid as mono

pub fn main() -> Nil {
  let either_sum_monoid =
    mono.Monoid(
      mempty: Left(0),
      mappend: fn(e1: Either(Int, String), e2: Either(Int, String)) -> Either(
        Int,
        String,
      ) {
        case e1, e2 {
          Right(s), _ -> Right(s)
          _, Right(s) -> Right(s)
          Left(a), Left(b) -> Left(a + b)
        }
      },
    )
  echo either_sum_monoid
    |> mono.mconcat([Left(2), Left(3), Left(4)])
  // -> Left(9)
  echo either_sum_monoid
    |> mono.mconcat([Left(2), Right("error"), Left(4)])
  // -> Right("error")
  Nil
}

Bifunctor Example

import cat
import cat/bifunctor as bf
import cat/instances/bifunctor.{either_bifunctor}
import cat/instances/functor.{const_functor, identity_functor}
import cat/instances/types.{
  type BiCompF, type ConstF, type EitherBF, type IdentityF,
}
import gleam/int

pub fn main() {
  // Either bifunctor
  let either_bf = either_bifunctor()
  // Const () functor
  let const_f = const_functor()
  // Identity functor
  let id_f = identity_functor()
  // Constructing the maybe functor:
  // Maybe b = Either (Const () a) (Identity b)
  let maybe_functor = fn() -> bf.Bifunctor(
    BiCompF(EitherBF, ConstF(Nil), IdentityF),
    a,
    b,
    c,
    d,
    cat.Either(cat.Const(Nil, a), cat.Identity(b)),
    cat.Either(cat.Const(Nil, c), cat.Identity(d)),
  ) {
    bf.bifunctor_compose(either_bf, const_f, id_f)
  }
  echo cat.Left(cat.Const(Nil))
    |> maybe_functor().bimap(fn(_) { panic }, int.to_string)
  // -> cat.Left(cat.Const(Nil))
  echo cat.Right(cat.Identity(3))
    |> maybe_functor().bimap(fn(_) { panic }, int.to_string)
  // -> cat.Right(cat.Identity("3"))
  Nil
}

Further documentation can be found at https://hexdocs.pm/cat.

Search Document