Architecture

Functions and partial application

fp.functions provides eager application with identity and pipe, function composition with flow and compose, argument flipping with flip, and partial application with partial. The reusable callables implement the fixed-arity protocols in fp.callables. Pipelines and compositions share the same stage planner and traversal.

Semantics

identity(x)                      = x
pipe(x)                          = x
pipe(x, f0, f1, ..., fn)         = fn(...f1(f0(x))...)
flow(f0, f1, ..., fn)(args)      = fn(...f1(f0(args))...)
compose(fn, ..., f1, f0)(args)   = fn(...f1(f0(args))...)
flip(f)(a, b, rest)              = f(b, a, rest)
partial(f, a, b)(rest)           = f(copy(a), copy(b), rest)

These equations describe logical calls. args is zero, one or two arguments for flow and compose (the arity of the first-applied stage), the two swapped arguments plus any remaining ones for flip, and a borrowed argument pack for partial.

The rules shared by every form:

  1. Construction does not call the target. Creating a composition, flipped callable or partial stores what a later call needs. partial copies its bound arguments at construction; the others only move their components in.
  2. Each reached stage runs once, in order. A raised error stops the chain: later stages do not run, and the payload keeps its exact type.
  3. Values move or borrow as the receiving stage declares. There are no intermediate collections, dynamic dispatch or extra copies.
  4. The first stage sees the call's arguments; later stages see one value. A stage that returns a tuple, a Result or a pointer passes it whole. Nothing is unpacked or short-circuited implicitly.
  5. Adjacent stages must agree at compile time. An input type that does not equal the previous stage's output is a compile error naming the stage.

Function composition and monadic bind differ in how they handle a Result. pipe, flow and compose pass the whole value to the next stage, including an Err. Result.flat_map calls the next computation only for Ok; an Err passes through unchanged. Generic code uses flat_map[ResultFamily[E]], and native Optional.and_then keeps its standard meaning. Separately, std.iter.chain concatenates sequences.

Pipelines

pipe(value, stages...) applies stages left to right. Every stage the planner sees is a library value whose own contract fixes its types. Two to eight plain functions reach it through fixed-arity overloads that spell each stage as a thin signature, infer its error (Never for a non-raising function) and store it as a _ThinFunction; a variadic pack keeps a plain function's type but not its signature, which is why these overloads are per stage count. Closures become stages through as_unary, which moves them into a NativeUnary. Partials and compositions are stages as they are.

A pipeline's result type is inferred from the stages. functions._stages builds one compile-time plan (_Plan) from the stages' types: it tabulates each stage's result type as a TypeList with a known length and reduces it to the final type. It uses a known-length tabulate because Mojo 1.1's general TypeList slice leaves a deferred bound that hides concrete result types from callers.

The plain-function overloads of pipe, flow and compose are generated by scripts/generate_plain_overloads.py, which a tooling test keeps in step with the source. Each later stage names its own input type, so a call whose functions do not line up still selects its overload and reaches the per-stage check, which names the stage and both types instead of a generic mismatch.

Errors combine by identity. Stages that do not raise, including those declared raises Never, add no error type. If every stage is non-raising, the pipeline is too. Otherwise every inhabited error type must be identical, and that type is the pipeline's error. Different error types must be normalized explicitly. The explicit forms, pipe[E=Failure](...) and pipe[Results, E](...), state the error or every result type directly.

Inside an enclosing generic function that forwards library-value stages, store pipe[E=E](value^, *stages), assert type_of(result) == R at compile time and return rebind_var[R](result^). The assertion is required: on Mojo 1.1 a rebind alone accepts some distinct structs with identical layout, so it cannot serve as the nominal type check.

Chained pipelines

piped(value) returns a Piped[T] holding the value, and each then(f) applies one stage and returns Piped[R]. A chain is therefore a sequence of ordinary calls, each with one function-typed parameter, so Mojo infers every stage's types without a planner and without a stage limit. Non-raising stages and library Unary values go through the single-stage pipe(value, f) overloads; a raising stage is called directly, because forwarded through pipe its error would be the callback's F.E, which Mojo 1.1 cannot prove equal to the stated E. There is no common-error check across a chain: each then raises its stage's own error.

Composition

flow(f^, g^, ...) and compose(..., g^, f^) return one concrete type, Composition, parameterized by its stages, their execution order and the arity of the first-applied stage. It stores a native tuple of Optional components. Optional lets a consuming call move each component out exactly once and gives native cleanup on every error path, without partial tuple moves. The same _Plan and stage traversal serve pipe and stored compositions; there is no second loop and no fixed stage count. Plain functions arrive through the same kind of fixed-arity overloads as in pipe (two to eight functions, the first-applied one taking zero, one or two arguments) and are stored as _ThinFunction components.

The first-applied stage may be a thunk, a unary or a binary; the composition takes its arguments and implements Thunk, Unary or Binary accordingly. Every later stage is unary. A composition has exactly the receiver modes its components allow:

Call Components allowed
chain(...), chain.call(...) Shared components only
chain.call_mut(...) Shared or mutable components
chain^.call_once(...) Any components; each runs at most once

State persists between calls. A composition is copyable only when every component is. flow[T]() gives Identity[T]; one component returns the original callable unchanged, keeping every capability it had.

Mojo 1.1 cannot select a conformance from a where clause that inspects a function type directly, so the arity is an explicit parameter of Composition, computed once by flow and compose.

Flip

flip has two forms. For a Binary value it returns Flipped, which calls the target with its two arguments swapped and has the target's receiver modes. For a plain function with two or more parameters it returns FlippedFunction, which stores the function converted to a signature with two owned leading parameters and a read pack for the rest, the same technique partial uses.

Partial application

partial(f, a0, ..., ak) binds a positional prefix of a plain function (no captures) and returns a Partial:

  • Target. A plain function, raising or not. E is inferred and is Never for a non-raising target, so the partial is non-raising too.
  • Bound arguments. Zero to eight, each copied once into the partial. Every call passes the target a fresh copy, so a bound parameter may be read or owned, and the stored values are never consumed or changed. A value that is not Copyable is rejected; it never silently becomes a borrow.
  • Remaining arguments. Borrowed, never copied or moved. The remaining parameters must therefore be read parameters.
  • Results. A result cannot refer to a per-call copy or to the stored values.
  • Integration. Partial is Copyable. With zero, one or two remaining arguments it implements Thunk, Unary or Binary, so the algebra, effects and owned-argument iteration functions accept it directly.

Copying protects only as far as the bound values' copy semantics: copying a shared handle still shares its referent.

Representation

One struct holds the target and a native Tuple of bound values. At the construction site the target is converted to the signature for its bound count, for example

def(var a0: A0, var a1: A1, *args: *Rs) raises E thin -> R

with every type inferred from the function. A call copies each bound value and forwards the remaining arguments as one native pack: one native call, with no binder, dictionary or callable box. At O3, partial(scale, 3)(x) compiles to the same instructions as scale(3, x). A client that uses partial builds cold in about three seconds.

Why the limits exist

The limits follow directly from Mojo 1.1.0 (8189361e), checked against the compiler and the mojo/v1.1.0 standard library source:

Mojo 1.1 behavior Consequence
A stored value can precede a forwarded pack only through its own declared parameter: "concatenating unpacked positional arguments is not supported". A Tuple cannot be unpacked into a call, a signature has one variadic pack, and VariadicPack has no public constructor. Each bound count needs its own spelled signature and overload, so the count is capped at eight.
A stored function cannot be re-typed to take more leading parameters. Nested partials cannot be flattened, so they are rejected.
Converting a function to a signature that passes a read parameter in an owned pack hands the target a dangling value when the type is not ImplicitlyCopyable (a List arrives with the right length and wrong contents). A read pack is correct for every type. Remaining arguments travel as a read pack, so remaining parameters must be read.
Keyword packs are homogeneous, cannot be forwarded into named parameters, and function parameter names are not reflected. No keyword binding.
A capturing closure does not convert to a pack-typed signature, and a generic struct cannot conform to an inferable function-type trait. Targets are plain functions; Partial joins library functions through Thunk/Unary/Binary.
A pack-typed function and a fixed-arity function have different calling conventions, so rebind between them is rejected. Rebinding between two pack-typed signatures that agree at instantiation works. Partial.call reuses the pack-typed function by rebinding to the one- or two-element pack type.

A native closure expresses everything partial does not: keyword binding, reordering, skipping or binding more than eight arguments, owned or mut remaining parameters, capturing targets and building on another partial. Its capture list copies bound values, and it keeps the target's defaults and exact error type:

var base = 2
def basetwo(text: String) raises {base} -> Int:
    return atol(text, base=base)

The library could remove these restrictions if Mojo gains the corresponding native support. Pack concatenation or a public pack constructor would remove the argument cap and allow nested partials to be flattened. Correct owned-pack conversion would allow owned remaining parameters, and heterogeneous keyword packs forwarded into named parameters would allow keyword binding.