FP Mojo: functional programming and exhaustive matching in pure Mojo
Hi everyone,
Meet FP Mojo! It’s a functional programming library written in pure Mojo, released under the Apache License 2.0. Compose functions into pipelines, process collections lazily, handle failures with typed results, and define algebraic and inductive data types from your own structs. Functions and closures keep their native types and ownership conventions, with no Python runtime dependency.
Start with two expression constructors, Num and Add. The same two-clause evaluator below handles a million levels of nesting and a shared expression whose fully expanded tree would contain more than a quintillion leaves:
import fp
from fp.adt import Cases, Data, Node, Value
@fieldwise_init
struct Num(Copyable):
var value: Int
@fieldwise_init
struct Add[R: Value](Movable):
var left: Self.R
var right: Self.R
struct Expr(Data):
comptime Layer[R: Value] = Cases[Num, Add[R]]
comptime E = Node[Expr]
def evaluate(expression: E) -> Int:
return fp.match(
expression,
lambda (n: Num) -> Int: n.value,
lambda (a: Add[Int]) -> Int: a.left + a.right,
)
def main():
var one: E = Num(1)
var shared: E = one
for _ in range(60):
shared = Add(shared, shared)
print("60 shared additions:", evaluate(shared))
var deep: E = Num(0)
for _ in range(1_000_000):
deep = Add(deep, one)
print("1,000,000 levels deep:", evaluate(deep))
Output:
60 shared additions: 1152921504606846976
1,000,000 levels deep: 1000000
The first loop builds just 61 distinct nodes. Each addition shares its two operands, so it doubles the result without duplicating the preceding expression. With the copyable Int result used here, each shared node is evaluated once per match. The answer is 2**60.
The second loop builds an expression a million additions deep. The Add[Int] clause receives both child results already evaluated; fp.match manages the traversal with an explicit stack, so the depth does not overflow the native call stack. Remove either constructor’s clause and the compiler rejects the incomplete match. The recursive data tutorial also shows branch selection and expression rewriting.
The library provides:
- Function composition.
pipepasses a value through functions from left to right.flowandcomposebuild reusable compositions,partialbinds a positional prefix, andpiped(...).then(...)supports longer chains. - Lazy iteration and folds.
map,filter,filter_map,flat_map, andscan_leftproduce iterators. Terminal operations such asfold_left,find, andcollect_listpull the values they need. - Typed results and errors.
Result[T, E]represents success withOkand failure withErr.flat_mapskips later callbacks after anErr;attemptandraise_on_errbridge returned and raised errors while preserving the error type. - Your own data types.
Nodestores immutable, shared recursive values.Choicestores non-recursive alternatives in place. Pattern matching checks constructor coverage at compile time, andfp.rewritetransforms recursive values from the leaves upward. - Native ownership and borrowing. Use ordinary Mojo functions, closures, collections, and structs, including move-only values and borrowed views. Borrowed values retain their origins.
- Algebra and effects. Functor, Applicative, Monad, and Traversable operations work across Identity, Optional, Result, and List instances. Reader, State, Writer, and monad transformers let you compose computations that read an environment, pass state, accumulate a log, or handle failure.
- Loops as expressions.
while_loop,fori_loop, andscancarry values through a computation and return its result.
For an everyday example, build a pipeline with separate stages for filtering, mapping, folding, and formatting:
from fp.functions import piped
from fp.iteration import FilterIterator, MapIterator, filter, fold_left, map
def positive(value: Int) -> Bool: return value > 0
def square(value: Int) -> Int: return value * value
def add(total: Int, value: Int) -> Int: return total + value
comptime Positives = FilterIterator[Int, List[Int].IteratorOwnedType]
comptime Squares = MapIterator[Int, Int, Positives]
def report(total: Int) -> String:
return "sum of positive squares = " + String(total)
def main():
var values: List[Int] = [3, -1, 4, -1, 5]
var result = (
piped(values^)
.then(lambda (var items: List[Int]) -> Positives: filter(positive, items^))
.then(lambda (var items: Positives) -> Squares: map(square, items^))
.then(lambda (var items: Squares) -> Int: fold_left(add, 0, items^))
.then(report)
.get()
)
print(result)
Output:
sum of positive squares = 50
values^ moves the list into the pipeline. Positives and Squares name the lazy iterator types passed between stages. The first two .then(...) calls construct adapters without processing any elements. When fold_left pulls, filter keeps 3, 4, and 5, map squares them, and the fold adds 9, 16, and 25. No intermediate lists are created. The final stage passes the total to report, and .get() returns the formatted string.
For validation, a Result keeps failure explicit and stops the success path at the first error:
from fp.data import Err, Ok, Result
def parse_port(text: String) -> Result[Int, String]:
try:
return Ok(atol(text))
except:
return Err("not an integer: " + text)
def valid_port(port: Int) -> Result[Int, String]:
if port < 1 or port > 65535:
return Err(String("port must be between 1 and 65535"))
return Ok(port)
def endpoint(port: Int) -> String:
return "localhost:" + String(port)
def rejected(error: String) -> String:
return "invalid: " + error
def main():
for text in ["8080", "70000", "oops"]:
print(parse_port(text).flat_map(valid_port).fold(endpoint, rejected))
Output:
localhost:8080
invalid: port must be between 1 and 65535
invalid: not an integer: oops
"70000" parses successfully, then fails validation. "oops" fails to parse, so valid_port never runs. fold selects either endpoint or rejected to produce the final string. These failures remain values; returning an Err does not raise an exception.
FP Mojo is verified with Mojo 1.1.0 on Linux x86-64 and macOS arm64 and is available as fp_mojo in the Modular community channel. With Pixi installed, create a project:
pixi init fp-start \
--channel https://conda.modular.com/max \
--channel https://repo.prefix.dev/modular-community \
--channel conda-forge
cd fp-start
pixi add fp_mojo "mojo==1.1.0"
Save any one of the programs above as showcase.mojo beside pixi.toml. From that directory, run:
pixi run mojo run showcase.mojo
The package name is fp_mojo; Mojo imports use fp, with names imported from their owning modules, such as fp.functions and fp.data.
The documentation includes a nine-part tutorial, runnable examples, an API reference, and the architecture behind the ownership, error, and matching guarantees.
I’d love to hear what you build with it! Examples, API feedback, and contributions are welcome.