syntax represented by the free monad for a functor that provides a signature.
The duct module provides a composition pattern for declaring monadic computation using abstract morphisms. It offers a category-theory-inspired algebra that enables structured composition of transformations (ฦ: A โผ B), forming composable computation pipelines (๐: A โผ B). Instead of executing computations directly, duct constructs an abstract syntax tree (AST) that represents the computation declaratively.
%%{init: {'theme':'neutral'}}%%
graph LR
subgraph "๐: A โผ Z"
direction LR
A([๐: A โผ B])
B([๐: B โผ C])
C([๐: C โผ ...])
Z([๐: ... โผ Z])
A --> B
B --> C
C --"..."--> Z
end
subgraph "๐: B โผ C"
direction LR
B0([๐: B โผ C])
B1([๐: ... โผ ...])
B2([๐: ... โผ C])
B --> B0
B0 --"..."--> B1
B1 --"..."--> B2
B2 --> B
end
subgraph "๐: ... โผ ..."
direction LR
B10([๐: ... โผ ...])
B11([๐: ... โผ ...])
B12([๐: ... โผ ...])
B1 --> B10
B10 --"..."--> B11
B11 --"..."--> B12
B12 --> B1
end
This approach is particularly useful for metaprogramming, where the separation of concerns is crucial. By modeling computation as a structured transformation pipeline, duct allows introspection, optimization, and transformation before execution. This makes it especially powerful in domains like query builders, code generation and infrastructure as a code, where the representation of computation is as important as the computation itself.
With duct, computations are first-class entities, enabling dynamic modification, analysis, and execution strategies that extend beyond traditional function composition.
Quick Example
Assuming initial and terminal objects โจ๐ฐ,๐ปโฉ. The initial object ๐ฐ has exactly one identity morphism ๐ฐ โผ A, can be thought of as the starting point in the system. Consequntly, the terminal object ๐ป has morphism B โผ ๐ป, can be thought of as the end point in the system.
๐: ๐ฐ โผ ๐ป
๐: ๐ฐ โผ A
๐: A โผ B
๐: B โผ ๐ป
Therefor, the morphism ๐: ๐ฐ โผ ๐ป is defined using duct's algebra.
f := duct.Yield(
duct.L1[string](/* target */),
duct.Join(
duct.L2[int, string](/* computation */),
duct.From(
duct.L1[int](/* source */),
),
),
)
Given notation has defined a system capable of sourcing int, transforming them to strings and emitting results. By itself this naive example defines clear separation of concerns approach--defining the data flow but abstracting away the specifics of each individual operation. For example, resulting abstract syntax tree can be materialized into queueing system: the initial object represents a source of incoming data from a producer; the terminal object represents the destination where data ends up; and inner morphism if a pure int โผ string transformer. The instantiation of AST in the concern of the application:
var visitor duct.AstVisitor
if err := f.Apply(visitor); err != nil {
// handle error
}
Algebra
duct module offers a category theory-inspired algebra that allows the composition of transformers into high-level morphism.
๐: A โผ B
A morphism Morphism[A, B] is key abstract transformer that maps from category A to category B. It represents an abstract syntax tree of computation. It is composable with combinators defined by this algebra. The objective of the algebra is to collapse the morphism ๐: ๐ฐ โผ ๐ป, marking the completion of the side effect computation.
From(A) = idแดฌ : ๐ฐ โผ A
From[A] initializes a computation by binding a source from category A (e.g. it could correspond to the process of putting data into the system), creating a initial object morphism that does not perform any transformation initially (identity). This is only the valid way to start declaration of morphisms--each morphism is started by this construct.
Join(๐, ๐) = ๐ โ ๐ : A โผ C
Join[A, B, C] represents standard function composition, lifting an ordinary function ๐: B โผ C into the morphism ๐: A โผ B, producing a new morphism ๐: A โผ C.
LiftF(๐, ๐) = ๐(๐) โ ๐ : A โผ ๐โบ(C)
LiftF[A, B, C] enables transformation within a functorial context while preserving the computational structure -- allows composition between a morphism ๐: A โผ ๐(B), where ๐ is is some functor and a function ๐: B โผ C, which transforms elements inside the functor. LiftF builds a free monad that deferred computation representation without immediate execution or collapsing. Unlike standard Kleisli composition, which typically involves monadic binding ๐ โซ ๐, LiftF does not perform joining of nested functorial structures. Instead, it maintains a nested form, allowing further transformations within the computational pipeline. The composition Unit(LiftF(๐, ๐) โ g)) is equivalent to Unit(๐ โซ (๐ โ ๐)) in monads, where ๐ is applied after ๐, and the results are flattened into a single structure. It is a responsibility of creator of such a free monadic value to do something with those nested contexts either yielding individual elements or uniting into the monad.
WrapF(๐) = LiftF(idแดฌ, ๐) : A โผ ๐โบ(B)
WrapF[A, B] is equivalent to LiftF but does not apply any transformationโit simply makes a free monad from existing morphism. It enables further composition (e.g. ๐: B โผ C ) inside the nested context ๐โบ(B). This allows you to yield elements of ๐(B) without additional transformations, enabling further composition with terminal object.
Unit(๐) = ๐ โ ๐โบ : ๐โบ(A) โผ ๐(B)
Unit[A, B] finalizes a transformation context by lifting a morphism into a functorial structure--unit is the monadic return (๐ : B โผ ๐(B)). It acts as the closure operand for free monad, ensuring that all compositions, such as those created by LiftF and WrapF, are fully resolved into a functorial form.
Yield(๐) = โแดฎ โ ๐ : B โผ ๐ป
Yield[A, B] binds a morphism ๐: A โผ B to a target in category B, effectively consuming the computation. This means yield represents a terminal operation, finalizing the morphism pipeline (e.g. a side effect, storage, output). As the final step, it does not return anything, indicating an end of computation.
Examples
Let's consider this algebra from trivial example: "Giving the recommendation on relevant products in category for user". There is an external system that signals account id. For each account, recommendation system obtains user's profile, recommends N most relevant categories and discovers K relevant product per category, resulting in N ร K product recommendations:
// id
// โ (Account โผ User)
// โ (User โผ ๐(Category))
// โ ๐(Category โผ ๐(Product))
// โ โ
a := duct.From(duct.L1(/* source */))
b := duct.Join(duct.L2[Account, User](/* ... */), a)
c := duct.Join(duct.L2[User, []Category](/* ... */), b)
d := duct.Unit(duct.LiftF(duct.L2[Category, []Product](/* ... */), c))
e := duct.Yield(duct.L1(/* target */), d)
The definition above yield [][]Product at once when the morphism completes. Alternatively, you can yield each Product recommendation.
// id
// โ (Account โผ User)
// โ (User โผ ๐โบ(Category))
// โ (Category โผ ๐โบ(Product))
// โ โ
a := duct.From(duct.L1(/* source */))
b := duct.Join(duct.L2[Account, User](/* ... */), a)
c := duct.Join(duct.L2[User, []Category](/* ... */), b)
d := duct.WrapF(duct.LiftF(duct.L2[Category, []Product](/* ... */), c))
e := duct.Yield(duct.L1(/* target */), d)
The declaration result abstract syntax tree. The application defines own principles of its materialization (e.g it can use infrastructure as a code to deploy the computation pipeline).
Why This Abstraction Implements a Free Monad Structure
This module provides an AST-based Free Monad, enabling composable and deferred computations while preserving functorial transformations. The structure adheres to category theory principles and satisfies the definition of a Free Monad as follows:
A Free Monad over a functor ๐ is defined as:
Free ๐ A = A + ๐(Free ๐ A)
This definition implies:
- It consists of either
A pure value (Pure) representing an already computed result or inner computation, capturing a functorial transformation without immediate execution.
- It enables chained computations (flatMap) without collapsing intermediate steps prematurely.
- It maintains compositional structure until explicitly resolved.
This API implements a Free Monad using an AST-based execution model, where transformations are staged and composed before execution. The core functions that establish this structure are:
-
LiftF(f, m): Morphism[A, C] Lifts a functorial transformation ๐: B โผ C into a deferred computation. This is analogous to ๐(Free ๐ A), capturing computations without execution.
-
Join(f, m): Morphism[A, C] composes a transformation ๐: B โผ C with an existing morphism ๐: A โผ B, producing a new staged computation. This behaves like flatMap, applying a function inside the monadic context.
-
Unit(m): Morphism[A, []B] resolves and finalizes all staged transformations.
This collapses the monadic structure, much like evaluating a Free monad via an interpreter.
The abstraction satisfies the monad laws:
Left Identity (return x >>= f behaves the same as f(x)). Unit(Join(๐, WrapF(๐))) ensures that lifting and joining a function preserves its behavior.
Right Identity (m >>= return behaves the same as m.). Since Join(id, ๐) == ๐, composing with an identity function preserves structure.
Associativity ((m >>= f) >>= g behaves the same as m >>= (fun x -> f x >>= g)). Unit(Join(g, LiftF(f))) == flatMap(f โ g), ensuring that function composition follows monadic associativity.
Read How the Free Monad and Functors Represent Syntax if your are looking deep-diving into Free Monands for a powerful way to build expressions in a domain-specific language.
How To Contribute
The library is MIT licensed and accepts contributions via GitHub pull requests:
- Fork it
- Create your feature branch (
git checkout -b my-new-feature)
- Commit your changes (
git commit -am 'Added some feature')
- Push to the branch (
git push origin my-new-feature)
- Create new Pull Request
The build and testing process requires Go version 1.18 or later.
Build and run in your development console.
git clone https://github.com/fogfish/golem
cd golem/duct
go test
License
