Type Algebra and Functional Composition in libfn

Type Algebra and Functional Composition in libfn

By Bronek Kozicki

Overload, 34(195):8-19, October 2026


Large application-wide errors can be hard to handle. Bronek Kozicki introduces a C++ library that chains results and errors.

A C++20 functional programming library, libfn, expresses mathematical structures and their composition through C++ types, extending the vocabulary available in the standard library.

The library operates on two payload types and four computation carriers:

  • pack: Product type containing all fields; a tuple-like data structure.
  • copack: Canonical coproduct containing exactly one alternative; a variant-like disjoint set of types.
  • optional: Computation yielding a value or empty.
  • expected: Computation yielding success or error.
  • choice: Never-failing computation holding one of several alternatives.
  • just: Never-failing computation yielding a single value or a void.

A monadic carrier wraps a value in a computational context, such as possible failure. Carriers can be combined with operations such as transform (mapping), transform_error (error mapping), and_then (sequential monadic binding), or_else (recovery), discard (terminate sequence), operator& (conjunction/simultaneous product composition), operator| (disjunction/simultaneous sum composition), and the n-ary folds fn::conjoin and fn::disjoin. Elimination is apply (multidispatch). Pipeline operations which combine a value or an error wrapped in a carrier with a user-supplied function are called combinators. In the list above, these are the functor forms of transform, transform_error, and_then and or_else. Users may also define their own combinators.

Member vs. pipeline syntax

Some operations are exposed in two forms:

  • Member functions (for example, .transform(), .and_then(), .apply()) called directly on a carrier or payload (e.g., ex.transform(f), cp.apply(f)).
  • Combinators in namespace fn (for example, fn::transform, fn::and_then) applied via operator| (for example, ex | fn::transform(f)).

The combinators recover, fail, filter, inspect, inspect_error, and the pipeline operation discard have no member spelling. The member functions apply, apply_r, apply_type and apply_type_r have no pipeline spelling.

The operator| carries two meanings, told apart by its right operand: a pipeline functor on the right feeds the carrier into that operation, while another carrier on the right is disjunction (explained in Section 7).

Freestanding fn::apply(f, args...) is the general multidispatch entry point: it accepts any mix of scalars, tuple-like structures, packs and copacks, unpacking products and dispatching over alternatives in a single call. Do not confuse with pfn::apply, which is a polyfill for the C++26 std::apply, meant for C++20 compilers. The fn::apply is an extension on top of pfn::apply.

In prose, this document omits prefixes (using apply, transform, and_then, expected, pack) when referring to both forms or core vocabulary types generally.

Storage shape vs. call shape

Although different types can behave identically during application, they remain strictly distinct in memory. For example, pack<A, B>, std::tuple<A, B>, and std::pair<A, B> all unpack into the same call shape f(a, b) during apply, but they are separate C++ types with distinct layouts. Application does not silently convert or unify types on the storage side.

To illustrate these concepts, the examples in this document use a reusable set of value and error types:

  struct Error {};
  struct OtherError {};
  
  struct A {};
  struct B {};
  struct C {};
  struct D {};
  
  struct UserId {};
  struct User {};
  struct FilePath {};
  struct MaximumSize {};
  struct BlockSize {};
  
  struct NotANumber {};
  struct OutOfRange {};
  struct Missing {
    auto operator<=>(Missing const &) const 
      = default;
  };
  struct IoError {};
  struct BadSyntax {};
  struct UnknownKey {};

1. Why compose types as well as values?

In idiomatic C++, error handling usually means picking one application-wide error enumeration, a giant std::variant, or throwing exceptions. If a function only ever fails due to one specific error, returning a large application-wide error variant discards the precise bounds of what the function can actually do.

With libfn, the compiler derives an exact, graded error pipeline. Consider parsing, validating, and loading a user (see Listing 1).

auto parse_id(std::string_view) ->
  fn::expected<UserId, fn::copack<NotANumber>>;
auto validate(UserId) -> fn::expected<UserId,
  fn::copack<OutOfRange>>;
auto load(UserId) -> fn::expected<User,
  fn::copack_for<IoError, Missing>>;

auto graded_pipeline(std::string_view sv) -> void
{
  auto result = parse_id(sv) 
    | fn::and_then(validate) 
    | fn::and_then(load);

  // The exact derived error union of the 
  // pipeline is recorded in the type:
  static_assert(
    std::same_as<decltype(result),
      fn::expected<User, fn::copack_for<IoError,
      Missing, NotANumber, OutOfRange>>>);
}
Listing 1

The resulting expected statically records that the pipeline yields a User on success, or fails with exactly one of NotANumber, OutOfRange, Missing, or IoError. This exact union accumulates automatically via and_then composition.

What does ‘graded’ mean?

With a fixed error type, an expected<T, E> requires inputs and outputs in and_then computations to share a common error type E. This can require defining a common error union upfront – an otherwise unnecessary coupling of types.

libfn supports graded monads, which you opt into by using copack in a monadic carrier, such as expected. A grade is a set of alternative types, and graded composition combines these sets by union. For expected, error alternatives form the grade when chaining computations with and_then; value alternatives form the grade when composing recovery operations with or_else.

The resulting type is graded: it expands (or narrows during mapping) to match the alternatives declared by the participating function signatures. This provides strict static effect tracking with zero boilerplate.

When used on the error side of expected (for example, expected<T, copack<Errs...>>), grading enables libfn to derive the combined error alternatives from the participating computations, without explicitly spelling out their union. The resulting types record the algebraic combinations of success and error alternatives declared by those computations. Section 9 explains grading on both sides, including recovery and widening.

The same type precision extends to computations combined side by side rather than in sequence. Conjunction (operator&) evaluates independent computations and bundles them into a single carrier holding a product (a pack) of the successful values over a sum of possible error types (a copack); disjunction (operator|) is its dual. Sections 6 and 7 cover both.

The two cooperating mechanisms

Behind these precise compiled types are two independent mechanisms that cooperate to derive and eliminate these shapes:

  1. Type algebra records and normalizes the exact stored C++ types using pack and copack as you compose operations.
  2. The application protocol uses apply and ordinary C++ overload resolution to unpack those stored values and route them to your functions or lambdas.

To route multiple alternative paths inside apply, the library provides fn::overload, which fuses unrelated lambdas into a single overload set.

These derived types are the explanation of the library’s design, not an internal template-metaprogramming implementation detail.

2. Types as an algebra: zero, unit, alternatives, and products

libfn uses an algebraic vocabulary over types:

  • copack<> represents 0 (Zero) – an uninhabited type.
  • pack<> represents 1 (Unit) – a type with exactly one state.
  • copack<A, B> represents A + B (Alternatives) – a coproduct where exactly one alternative is present.
  • pack<A, B> represents A × B (Products) – a type where all fields are present simultaneously.

These states can also be used to express the standard vocabulary types:

  • std::optional<T> ≅ 1 + T (It is either empty/unit or contains T, similar to copack_for<T, std::nullopt_t>)
  • std::expected<T, E> ≅ T + E (It contains either success T or error E, similar to copack_for<T, fn::unexpected<E>>)

The symbol ≅ indicates an equivalent state shape (an information-level correspondence), not std::same_as. std::optional<T> is its own distinct C++ type, but algebraically, it behaves as 1 + T.

Zero is not unit

In libfn’s algebra, zero and unit are strictly separated:

  • copack<> is uninhabited: you cannot construct it. Algebraically, it is 0.
  • pack<> is the one nullary product value. You can construct it via pack<>{} or fn::as_pack(). Algebraically, it is 1.

Because pack<> exists, applying a callable to it invokes a nullary function. Because copack<> is uninhabited, providing a callback over copack<> is statically proven to be unreachable code (dead code).

Consider the difference in these carrier states:

Computation Meaning
expected<T, copack<>> An expected value that cannot fail because its error state is uninhabited.
expected<copack<>, E> An expected value that cannot succeed because its success state is uninhabited.
optional<copack<>> An optional that must be empty, as its value state is uninhabited.
expected<void, E> An expected that yields no value on success, but can fail with E.

Copacks use set semantics

A major feature of libfn is copack, which forms canonical sets of types. For copacks, C++ type identity represents equality of sets, while alternatives are selected directly by type – in contrast to the positional indexing of std::variant. Combining copacks takes the union of these sets, so shared alternatives appear only once. Use the fn::copack_for type alias to flatten, deduplicate, and order the types; spelling copack directly requires them to already be in this form. (See Listing 2.)

auto test_copack_set_semantics() -> void
{
  using SetA = fn::copack_for<NotANumber,
    OutOfRange>;
  using SetB = fn::copack_for<Missing,
    OutOfRange>;

  // Flattening, deduplication, and reordering
  // happen automatically:
  using Union = fn::copack_for<SetA, SetB,
    Missing, Missing, fn::copack<IoError>>;

  static_assert(std::same_as<Union, 
    fn::copack_for<IoError, Missing, NotANumber,
    OutOfRange>>);
}
Listing 2

The laws governing copack are:

  • Commutative: Order of types does not change the resulting set.
  • Associative: Nesting copacks is equivalent to flattening them.
  • Idempotent: Duplicate types are collapsed into one.
  • Identity: copack<> represents the empty set, that is the union unit (adding copack<> changes nothing).

Idempotence means a set cannot carry positional meaning: copack_for<bool, bool> is one alternative, copack<bool>, not two. Types combined into a copack should therefore be strongly typed tag structs or distinct domain objects, never generic primitives whose meaning depends on where they sit. Two distinct types that the ordering mechanism cannot distinguish are a different matter: the library rejects them outright via a compile-time assertion rather than silently merging them, guaranteeing that no type is ever silently lost.

The algebra is strictly opt-in

The library relies on explicit consent. It does not silently reinterpret arbitrary C++ types as products or coproducts:

  • std::tuple remains a standard tuple.
  • std::variant does not acquire copack set semantics.
  • Tuple-like participation in apply changes call shape applicability, but it does not change the stored type identity.
  • A plain expected<T, E> does not automatically become graded.

To invoke the algebra, you use the opt-in mechanisms provided by the library:

  • Direct construction of pack and copack_for.
  • Explicit conversions via fn::as_pack and fn::as_copack.
  • Member helpers for explicit type lifting (detailed in Section 9).

If a side is already a copack or pack, forwarding it behaves naturally without nesting. A copack on the error side of expected enables error-set unioning (i.e. error grading). Because monadic operations introduce no grade themselves, and_then widens a graded error side but rejects a differing ungraded one. Section 9 explains the promotion rules.

The role of void

In libfn, void is not a member of the payload type algebra – it cannot be stored in a pack or a copack. Instead it contributes behaviour, which can be described as ‘no value and no type’. Hence, just<void> and expected<void, E> use it to express success without a payload.

In conjunction, a payload-free success contributes no field to the product, as pack<> would. In disjunction, that success must remain a possible outcome – if both operands contribute void then the resulting value type is void. Otherwise, the success without a payload is represented in the resulting sum as pack<>. When mapping a copack, a void result contributes pack<>, either on the value or error side.

3. The computation carriers

To model computation and manage control flow (success, failure, alternatives, and empty states), libfn uses computation carriers (often called ‘monadic types’). The three carrier templates are optional, expected and just. The list below groups them by their fallibility and payload.

The fallible carriers

  • optional<T> (representing 𝑇 + 1): A carrier that either holds a successful value of type T or is empty (std::nullopt).
  • expected<T, E> (representing 𝑇 + 𝐸): A carrier that either holds a successful value of type T or an error of type E.

expected is infallible when its error side is copack<>.

(Note: optional and expected are the fn extensions of the standards-conforming pfn polyfills; Section 15 covers the two layers.)

The infallible (identity) carriers

  • just<T>: Always contains a single successful value of type T.
  • choice<Ts...>: Always contains one of selected alternatives. It is a type alias to just<copack<Ts>>.

Because choice always holds a selected alternative, choice<> is incomplete and cannot be constructed: there are no alternative types, hence no active alternative.

Additionally, the infallible state expected<T, copack<>> (representing 𝑇 + 0 ≅ 𝑇) can never fail because copack<> represents the initial object 0 (the uninhabited type). Lacking any possible error alternatives, it acts as an infallible, graded unit context. Since it is a specialized state of expected rather than a unique template, it is classified under the same computation carrier.

Together, just, choice and expected<T, copack<>> form the identity cluster.

Carriers have control flow; raw data does not

Raw algebraic constructs – such as std::tuple (product), std::variant (sum), or libfn’s own pack and copack – are passive data layouts. They contain no intrinsic control flow, no concept of short-circuiting, and no built-in notion of success versus failure.

Composing computations requires wrapping these values in computation carriers. Product composition (conjunction) operates on both carriers and data payloads (pack and copack). When the left operand is either pack or copack, the right operand can also be a plain value. Sum composition (disjunction) operates only on carriers. The carrier manages success propagation and short-circuits failures.

Carrier bridging: interoperable pipelines

Because these carriers represent different computational contexts, pipelines often need to transition between them. libfn provides cross-carrier bridging via combinators using operator|.

Identity carriers bridge on the success path, via the and_then combinator. Since there is no error, the conversions between identity carriers can be freely performed; they can also bridge to fallible carriers (one way only). Section 10 covers that together with the identity cluster.

Standard fallible carriers can bridge to each other on their error/empty recovery paths via the or_else combinator (for example, expected to optional, or vice versa). On success, the value is preserved by a short-circuit; on failure, the user provided recovery callback can convert a missing value into a concrete error, or replace a detailed error with an empty state (see Listing 3).

auto test_failure_bridge(fn::expected<int, IoError> ex, fn::optional<int> opt) -> void
{
  // Fallible carriers can bridge to each other 
  // on the failure/empty recovery path
  auto expected_to_optional = 
    ex | fn::or_else([](IoError) {
      return fn::optional<int>{}; });
  static_assert(std::same_as<decltype(
      expected_to_optional), fn::optional<int>>);

  auto optional_to_expected = 
    opt | fn::or_else([]() { 
    return fn::expected<int, IoError>{100}; });
  static_assert(std::same_as<decltype(
    optional_to_expected), 
    fn::expected<int, IoError>>);
}
Listing 3

Member functions .or_else and .and_then (see Sections 8 and 10) do not support cross-carrier bridging to avoid coupling between different carriers. Combinators, lying a layer above carriers, can perform these transitions without additional coupling.

4. The sum and product payloads: pack and copack

Modeling complex algebraic structures – such as multi-field products or multi-alternative disjoint sums – requires specialized payload types. libfn provides two core vocabulary types.

pack: all fields are present

A pack stores multiple fields and supports the standard C++ tuple protocol (get, tuple_size, tuple_element, structured bindings) and an append mechanism. Unlike standard tuples, libfn packs are strictly flat: a pack cannot be an element of another pack. Appending a pack splices its fields into the outer pack rather than nesting it.

To explicitly lift scalar values into a pack (which is useful when conjoining scalars with other packs or copacks), use fn::conjoin(...) or pack{x} & ... – both hold scalar arguments by value. To store a reference use fn::as_pack(x) without template parameters – in this form it is deduction-only and preserves the value category of its arguments. Hence as_pack(x) on an lvalue int x yields pack<int&>, while as_pack(42) yields pack<int>.

Spelling the template parameters instead (e.g., as_pack<bool, int>(x, d)) takes deduction out of the picture: each argument is passed as the type you named, enabling implicit conversions to happen at the call boundary. A reference element becomes something you ask for explicitly – as_pack<int const&>(x) yields pack<int const&>. Note that partial template spelling is not supported; all element types must be spelled out explicitly if template parameters are specified. (See Listing 4).

auto test_pack(int x = 12, double d = 3.14) 
  -> void
{
  fn::pack p{UserId{}, User{}}; // CTAD
  [[maybe_unused]] auto [id, user] = p; 
    // Structured bindings work naturally

  // Ordered, non-deduplicated fields
  using P = fn::pack<UserId, User, User>;
  static_assert(std::tuple_size_v<P> == 3);

  // Found via ADL (like std::get)
  using std::get;
  static_assert(std::same_as<decltype(get<0>(p)),
    UserId &>);

  // Splicing scalars or other packs via append:
  auto row = 
    fn::pack{UserId{}}.append(FilePath{});
  auto wider = 
    std::move(row).append(fn::pack{true, 3});

  static_assert(std::same_as<decltype(wider),
    fn::pack<UserId, FilePath, bool, int>>);

  // Explicitly lifting a single scalar value 
  // into a pack:
  auto lifted_lvalue = fn::as_pack(x);
  static_assert(std::same_as<decltype(
    lifted_lvalue), fn::pack<int &>>);

  auto lifted_rvalue = fn::as_pack(42);
  static_assert(std::same_as<decltype(
    lifted_rvalue), fn::pack<int>>);

  // Spelling the element type explicitly can be 
  // used to opt out of reference preservation:
  auto copied = fn::as_pack<int>(x);
  static_assert(std::same_as<decltype(copied),
    fn::pack<int>>);

  // ... or to force a specific reference type 
  // (subject to parameter binding rules):
  auto referenced = fn::as_pack<int const &>(x);
  static_assert(std::same_as<decltype(referenced),
    fn::pack<int const &>>);

  // The explicit form also coerces - the 
  // argument converts at the call boundary:
  auto coerced = fn::as_pack<bool, int>(x, d);
  static_assert(std::same_as<decltype(coerced),
    fn::pack<bool, int>>);
}
Listing 4

copack: one exact alternative is present

As a payload, a copack models a discriminated union of types.

Evaluating a copack via .apply() passes the active alternative to the callback. Because copack is self-flattening, nested copacks do not occur. A selected alternative that is itself tuple-like – such as pack, std::tuple, or std::array – is unpacked one level, passing its immediate constituents as separate arguments. Since normalized shapes are sums of products, one level of unpacking is sufficient to supply the product’s fields as function arguments.

To explicitly lift a single scalar value into a single-alternative coproduct, use copack{value} or as_copack(std::move(value)); to store a reference, use as_copack(value). When a copack contains exactly one alternative type, it is singular. It supports direct value extraction both via the index-less get and via the tuple protocol (including structured bindings). Direct get extraction is not provided for multi-alternative copack types because the active alternative is a run-time property, meaning a multi-alternative get would have no single static return type.

A pack can be lifted into a copack (including packs holding references), but a pack cannot contain a copack. There is algebraic equivalence between a hypothetical pack containing a copack (which is disallowed) and a specific shape of copack containing a pack – see Section 6 for details. A copack holds a pack only by value. (See Listing 5.)

struct IntegerToken {};
struct StringToken {};
auto test_copack() -> void
{
  static constexpr fn::copack_for<IntegerToken,
    StringToken> token = IntegerToken{};

  // Member apply eliminates the copack by 
  // routing the active alternative to an 
  // overload set:
  static constexpr auto value
    = token.apply(fn::overload{
    [](IntegerToken) { return 1; },
    [](StringToken) { return 2; }});
  static_assert(value == 1);

  // Storing a pack inside a copack is allowed, 
  // including a pack holding a reference:
  auto cpr = fn::as_copack(
    fn::as_pack<int const &>(value));
  static_assert(std::same_as<decltype(cpr),
    fn::copack<fn::pack<int const &>>>);

  // Singular lift and direct value extraction 
  // (only allowed for singular copacks):
  auto cp = fn::as_copack(42);
  using std::get;
  static_assert(std::same_as<decltype(get(cp)),
    int &>);
}
Listing 5

A key safety guarantee of copack is exhaustive matching. Operations that evaluate a copack (such as transform and apply) delegate to a multidispatch implementation that enforces compile-time exhaustiveness. If the callback or overload set fails to handle any possible alternative in the copack, program compilation will fail.

5. Mapping values and errors

Mapping changes contained data without altering the computation’s structural success or failure shape. libfn uses transform (functor map) for the successful channel, and transform_error for the error channel. See Listing 6.

auto mapping_values_and_errors() -> void
{
  fn::expected<UserId, fn::copack_for<Missing,
    IoError>> ex{};

  auto mapped_val = ex | fn::transform([](UserId)
    { return User{}; });
  static_assert(
    std::same_as<decltype(mapped_val),
    fn::expected<User, fn::copack_for<IoError,
    Missing>>>);

  auto mapped_err = ex
    | fn::transform_error(fn::overload{
      [](Missing) { return BadSyntax{}; },
      [](IoError e) { return e; }});

  static_assert(
    std::same_as<decltype(mapped_err), 
    fn::expected<UserId, 
    fn::copack_for<BadSyntax, IoError>>>);
}
Listing 6

Key principles of mapping:

  • transform preserves the carrier family.
  • Success and error states are preserved.
  • A bare copack has a member transform to map across alternatives, but takes no pipeline functor (it’s not a carrier).
  • Heterogeneous results inside transform or transform_error form a normalized copack (grading, Section 9).
  • Applying transform_error to a carrier with no error side (such as just or choice) is ill-formed.
  • When a side is uninhabited (copack<>), transformation is well-formed but vacuous: the callback over copack<> is neither invoked nor instantiated. This applies to all of optional<copack<>>, expected<copack<>, E>, and expected<T, copack<>>.
  • A void result is represented in a copack as pack<>.
  • Mapping from copack never yields a plain type – even when all branches return the same type, the result remains copack.
Functorial action on the initial object

In libfn, transform implements the functorial map (fmap). For the initial object 0 – the uninhabited copack<> – there is exactly one map 0 → 𝑈 for each chosen target 𝑈. There is no input value on which to invoke a callback. Haskell expresses this through absurd :: Void -> a.

For an uninhabited side, libfn preserves the input carrier type without inspecting the callback or requiring it to be callable. This is a library convention consistent with initiality. Section 10 describes the same convention for or_else.

6. Product composition with operator& (conjunction)

Conjunction over carriers combines two independent computations into a single carrier. a & b succeeds only if both operands succeed, in which case their values combine into a product. If either operand fails, the result retains the leftmost error; for expected carriers, the result’s error type is a copack (a graded error type), unless both operands share the same plain error type.

  auto operator_and_composition(
    fn::expected<int, Error> a, fn::expected<bool,
    OtherError> b) -> void
  {
    auto result = a & b;
    static_assert(std::same_as<decltype(result),
      fn::expected<fn::pack<int, bool>, 
      fn::copack_for<Error, OtherError>>>);
  }

The runtime semantics are:

  • The result type records every error either operand can produce; at runtime it holds at most one of them – the leftmost failing operand’s.
  • Both operands are fully constructed before operator& runs, because C++ evaluates operands eagerly. This is an error-selection rule, not short-circuiting: the operator makes nothing lazy or parallel.

Conjunction over data

Unlike disjunction (Section 7), operator& applies also to payload types – that is plain types, pack and copack. Conjunction – also known as product composition – combines values from both operands. When either operand is copack, it performs a Cartesian distribution, yielding a copack of packs. Otherwise, it produces a pack, concatenating data from both.

This distribution underpins the rule in Section 4 that a pack cannot contain a copack. The disallowed nested shape – a product over a sum, (A × B) × (C + D) – is algebraically equivalent to (A × B × C) + (A × B × D), a flat copack of packs. This sum-of-products normal form is enforced by the library – demonstrated in the second case below. (See Listing 7.)

auto test_cartesian_distribution(fn::copack_for<A, B> ab, fn::copack_for<C, D> cd) -> void
{
  // Cartesian distribution of copacks: (A + B) 
  // x (C + D) = (A x C) + (A x D) + (B x C) + 
  // (B x D)
  auto result1 = ab & cd;
  static_assert(
    std::same_as<decltype(result1),
      fn::copack_for<fn::pack<A, C>, 
      fn::pack<A, D>, fn::pack<B, C>, 
      fn::pack<B, D>>>);

  // Cartesian distribution of a pack and a 
  // copack: (A x B) x (C + D) = (A x B x C) + 
  // (A x B x D)
  constexpr fn::pack<A, B> Pab = {A{}, B{}};
  auto result2 = Pab & cd;
  static_assert(
      std::same_as<decltype(result2),
      fn::copack_for<fn::pack<A, B, C>,
      fn::pack<A, B, D>>>);
}
Listing 7

Conjunction operator& dispatches on the left operand. To apply it to plain types, lift the left operand, for example as pack{a} & b.

The n-ary fold fn::conjoin(...) operates in two modes:

  • If all arguments are computation carriers, it folds them as a monadic conjunction, equivalent to cascading operator&.
  • If no arguments are carriers, it conjoins them as a data-level product.

Mixing carriers and data in a single call is ill-formed.

Conjunction with the identity cluster

An operand from the identity cluster (Section 10) contributes a value but never a failure:

  • Unchanged Errors: Because identity cluster operands never fail, they add no alternatives to the error channel. A just or choice operand preserves the fallible operand’s error side (plain or graded). An expected<T, copack<>> operand contributes its empty grade to the error union: no active alternative is added, although the resulting error channel is promoted to a graded copack.
  • Value Bundling: The identity cluster operand’s value conjoins with the fallible operand’s value into a product.
  • Void Elision: just<void> and expected<void, copack<>> act as the product’s identity unit and are elided from the value product (for example, expected<int, E> & just<void> yields expected<pack<int>, E>).
  • Choice Distribution: Conjoining a choice with a fallible carrier distributes the coproduct through the product, yielding a copack of packs wrapped in the fallible carrier, subject to void elision.

See Listing 8.

auto test_conjunction_with_identity_cluster(fn::expected<int, Error> ex, fn::just<double> j) -> void
{
  // Conjoining an expected with a just
  auto res1 = ex & j;
  static_assert(std::same_as<decltype(res1),
    fn::expected<fn::pack<int, double>, Error>>);

  // Conjoining with a unit (just<void>) 
  // elides it, and the value becomes a pack
  auto res2 = ex & fn::just<void>{};
  static_assert(std::same_as<decltype(res2),
    fn::expected<fn::pack<int>, Error>>);

  // Conjoining a choice causes distribution 
  // inside the carrier
  fn::choice<bool, double> ch = 1.5;
  auto res3 = ex & ch;
  static_assert(std::same_as< decltype(res3),
    fn::expected<fn::copack_for<fn::pack<int, 
    double>, fn::pack<int, bool>>, Error>>);
}
Listing 8
Strict monoidal structure and distribution

Conjoining pack payloads via operator& models a monoidal category (𝐶, ⊗, 𝐼), with pack as the tensor ⊗ and pack<> as the unit 𝐼.

  • Strictness: because packs are normalized flat, there are no distinct-but-isomorphic spellings for the coherence maps to mediate between. (𝐴 ⊗ 𝐵) ⊗ 𝐶 and 𝐴 ⊗ (𝐵 ⊗ 𝐶) are the same C++ type, as are 𝐼 ⊗ 𝐴, 𝐴 ⊗ 𝐼 and 𝐴 (when both operands are pack types, since a plain type may be automatically lifted). The associator and unitors are therefore identities, and the structure is strict rather than merely coherent.
  • Symmetry: for type union, order does not matter – canonical ordering makes 𝐴 ⊕ 𝐵 and 𝐵 ⊕ 𝐴 the same copack type. For the product, order matters: a pack keeps its fields ordered and undeduplicated, so 𝐴 ⊗ 𝐵 and 𝐵 ⊗ 𝐴 may yield distinct types related by a genuine swap.
  • Distributivity: the tensor distributes over the type union ⊕ (represented by copack), yielding 𝐴 ⊗ (𝐵 ⊕ 𝐶) = (𝐴 ⊗ 𝐵) ⊕ (𝐴 ⊗ 𝐶). This is the Cartesian distribution of pack over copack implemented statically by libfn.

7. Sum composition with operator| (disjunction)

Disjunction evaluates alternative computations, keeping the first successful result. a | b fails only if both operands fail: dual to conjunction, their values union into a copack, and their errors combine into a product. If both operands share the same payload type, the value side is unchanged. Otherwise, a carrier with a value type void contributes a payload-free success, represented by pack<> in the sum.

If either error side is graded, the product distributes over it: (𝐸₁ + 𝐸₂) × 𝐹 → (𝐸₁ × 𝐹) + (𝐸₂ × 𝐹) (the full Cartesian product when both are graded), yielding a canonical copack of packs.

  auto operator_or_composition(fn::expected<int,
    Error> a, fn::expected<bool, 
    OtherError> b) -> void
  {
    auto result = a | b;
    static_assert(std::same_as<decltype(result),
      fn::expected<fn::copack_for<int, bool>, 
      fn::pack<Error, OtherError>>>);
  }

The runtime semantics are:

  • The result type records every combination of failures; at runtime the leftmost operand holding a value wins.
  • Both operands are fully constructed before operator| runs, because C++ evaluates operands eagerly. This is a value-selection rule, not a lazy fallback.

Disjunction over carriers only

Unlike conjunction, disjunction has no data-level form. Neither operator| nor the n-ary fold fn::disjoin(...) accepts a pack, a copack, or a scalar.

Disjunction with the identity cluster

When an operand belongs to the identity cluster (Section 10), the disjunction cannot fail:

  • Error Annihilation: The error side gains an uninhabited factor (copack<>), which annihilates the error product and collapses the channel.
  • Infallible Folding: The result folds into a non-failing just carrier, including when an operand is expected<T,copack<>>. A result of type just<copack<Ts...>> can also be spelled choice<Ts...>.

See Listing 9.

auto test_disjoin(fn::expected<int, Error> a, 
  fn::expected<bool, OtherError> b) -> void
{
  // Multiple fallible operands compose cleanly
  auto res1 = fn::disjoin(a, b);
  static_assert(std::same_as<decltype(res1),
    fn::expected<fn::copack_for<int, bool>, 
    fn::pack<Error, OtherError>>>);

  // Because just<double> cannot fail, the 
  // disjunction with infallible operands becomes 
  // infallible
  auto res2 = 
    fn::disjoin(a, b, fn::just<double>{1.5});
  static_assert(std::same_as<decltype(res2),
    fn::choice<bool, double, int>>);
}
Listing 9
Annihilation and infallibility

Disjunction exchanges the roles of union and product used by conjunction: values add along ⊕ where conjunction multiplies along ⊗, and errors multiply where conjunction unions.

Under conjunction, copack<> is the unit of the error union, 𝐸 ∪ 0 = 𝐸, so an identity operand preserves the set of possible errors. Under disjunction it is the annihilator of the error product, 𝐸 ⊗ 0 ≅ 0, so a single identity operand empties the channel outright.

The resulting error side is uninhabited by construction – not merely empty at runtime, but incapable of holding a value – which is what renders the whole disjunction infallible and folds it into the identity cluster.

8. Sequential composition

Sequential composition chains dependent operations: and_then (monadic bind) supplies successful results to the next operation, while or_else continues on failure.

The callable passed to and_then is a Kleisli arrow; it maps successful input to a monadic carrier of the same kind. For an infallible input, the and_then combinator also permits the callable to return a different carrier family (carrier bridging, explained in Section 10). In contrast, the or_else combinator allows bridging between fallible carriers (Section 3).

Chaining bind operations forms a sequence, as in the pipeline introduced in Section 1. A sequence may also be graded (explained in Section 9), for example:

  auto parse_numeric() -> fn::expected<UserId,
    fn::copack<NotANumber>>;
  auto load_user(UserId) -> fn::expected<User, 
    fn::copack<Missing>>;

  auto sequential_bind() -> void
  {
    auto result = parse_numeric() 
      | fn::and_then(load_user);
    static_assert(
      std::same_as<decltype(result), 
      fn::expected<User, fn::copack_for<Missing, 
      NotANumber>>>);
  }

Because member .and_then cannot change carrier families (Section 3), its Kleisli arrow must return the same carrier family:

  • optional binds to optional.
  • Plain expected<T, E> binds to expected<U, E> (retaining error E) or, via singular lift, to expected<U, copack<E>> (transitioning to a graded context, Section 9).
  • Error-graded expected forms the union of heterogeneous error sets with a copack.
  • Copack-valued inputs join heterogeneous successful branch types into a copack.
  • Branch convergence preserves the exact type without duplicate union states.
  • All-void branches join to void; in mixed void and non-void branches, void contributes pack<>.
  • Callback results returning bare values require transform rather than and_then.

The library formalizes this ‘same-kind’ contract via the fn::same_kind concept, which lets generic templates probe whether two carrier types belong to the same monadic family with compatible error sides:

  static_assert(fn::same_kind<fn::optional<int>,
    fn::optional<User>>);
  static_assert(fn::same_kind<fn::expected<int,
    IoError>, fn::expected<User, IoError>>);
  static_assert(!fn::same_kind<fn::expected<int, 
    IoError>, fn::expected<User, Missing>>);

Recovery with or_else invokes the callback on failure and preserves successful results. For expected, the callback receives the error; for optional, it takes no arguments. In graded recovery, successful value alternatives accumulate by union (Section 9).

9. Graded compositions

Error-graded composition derives the exact union of the error alternatives declared by the participating computations. When an outer computation holds a coproduct of successful values, and each value requires a different operation to proceed, libfn derives a single, normalized expected shape.

Consider a configuration reader whose successful results are: MaximumSize, FilePath, or BlockSize. See Listing 10.

auto read_config() -> 
  fn::expected<fn::copack_for<MaximumSize, 
  FilePath, BlockSize>, fn::copack_for<BadSyntax, 
  UnknownKey>>;
auto config_pipeline() -> void
{
  auto validated
    = read_config() | fn::and_then(fn::overload{
        [](MaximumSize v) { 
          return fn::expected<MaximumSize,
          fn::copack<OutOfRange>>{v}; },
        [](FilePath v) { 
          return fn::expected<FilePath, 
          fn::copack<Missing>>{v}; },
        [](BlockSize v) { 
          return fn::expected<BlockSize, 
          fn::copack<OutOfRange>>{v}; }});

  // The result exactly bounds both the 
  // successful paths and the error paths
  static_assert(
    std::same_as<decltype(validated),
      fn::expected<fn::copack_for<BlockSize, 
        FilePath, MaximumSize>,
      fn::copack_for<BadSyntax, Missing,
        OutOfRange, UnknownKey>>>);
}
Listing 10

Two independent joins occur during and_then:

  1. The successful branch values formed the normalized value copack.
  2. The existing outer errors (BadSyntax, UnknownKey) and the new branch errors (OutOfRange, Missing) formed the normalized error copack.

Unioning allows different grades of expected to share the same carrier family. Although ungraded expected<T, E> requires the identical error type E (or its singular lift copack<E>) to participate in monadic bind – meaning expected<int, IoError> and expected<User, Missing> are not same_kind – any two error-graded expected types are same_kind regardless of how their error sets differ, as the compiler can always derive their union:

  static_assert(
    fn::same_kind<fn::expected<int,
    fn::copack<IoError>>, fn::expected<User,
    fn::copack<Missing>>>);

expected supports copack on both its error and value sides; optional supports it on its value side. Value joining and error grading are independent: branch values can join while the error side stays plain, as in expected<copack_for<A, B>, E>.

Recovery via or_else behaves symmetrically. It handles input error alternatives and joins any new errors produced by the recovery branches, while preserving the successful value path. Heterogeneous recovery values require a suitable copack-valued input. Any original error handled by a branch is removed from the resulting error type unless a branch explicitly re-returns it. When there are no error alternatives to handle – such as in expected<T, copack<>> – there is nothing to recover from: the callback is neither invoked nor instantiated.

During sequential composition, libfn derives the promoted type from the copack you supply:

  • In and_then (success binding), a plain error type E is promoted to copack<E> if the returning error type of the callback is copack<E>.
  • In or_else (recovery/error binding), a plain success type T is promoted to copack<T> if the returning success type of the callback is copack<T>.
  • For a void success type in or_else, the corresponding lift is copack<pack<>>.

An ungraded computation thus enters a graded composition without manual lifting.

If you need to perform this promotion explicitly on the carrier itself before entering a composition, libfn provides direct member helpers:

  • .copack_error() on expected explicitly lifts the error, transforming expected<T, E> to expected<T, copack<E>>.
  • .copack_value() on expected explicitly lifts the success value, transforming expected<T, E> to expected<copack<T>, E>.
  • For expected<void, E>, the result is expected<copack<pack<>>, E>.
  • .copack_value() on optional symmetrically lifts the value, transforming optional<T> to optional<copack<T>>.

These helper methods provide a compact, explicit alternative to the automatic promotions. See Listing 11.

auto test_explicit_lifting(fn::expected<User, IoError> result, fn::optional<User> opt) -> void
{
  // Explicitly lift the error side of expected:
  auto graded_err = 
    std::move(result).copack_error();
  static_assert(
    std::same_as<decltype(graded_err),
    fn::expected<User, fn::copack<IoError>>>);

  // Explicitly lift the value side of expected:
  auto graded_val = 
    std::move(result).copack_value();
  static_assert(
    std::same_as<decltype(graded_val),
    fn::expected<fn::copack<User>, IoError>>);

  // Explicitly lift the value side of optional:
  auto graded_opt = 
    std::move(opt).copack_value();
  static_assert(
    std::same_as<decltype(graded_opt),
    fn::optional<fn::copack<User>>>);
}
Listing 11

Widening is subeffecting

A narrow error set can be widened during composition, but narrowing requires explicit handling. Implicit narrowing (without handling the removed errors) is unsafe and rejected by the compiler: transform_error explicitly remaps error alternatives and requires the callback to handle every input alternative (exhaustive matching of types). However, several alternatives may map to one common error type – a property which can be used for explicit narrowing.

The bottom error grade is copack<>:

  template <typename T>
    using cannot_fail_t = fn::expected<T, 
    fn::copack<>>;

This computation cannot fail, but it is algebraically prepared to widen if later composition introduces possible errors.

A concrete example of this is expected<void, copack<>> (aliased as fn::expected_unit in the library). It has one payload-free success state and no error states. It therefore represents the unit type.

In practice, expected<void, copack<>> acts as an entry point for graded pipelines. Initiating a chain with this value trigger opts subsequent and_then bindings into graded error-set unioning without requiring mock starting errors or manual wrapping. Because its starting error set is empty (copack<>), unioning it with subsequent steps’ errors (such as IoError) yields exactly those errors. An alternative unit type without an error channel is just<void> (Section 10).

Graded monads

In the pomonoid-graded formulation used here, a graded monad is a family of endofunctors indexed by a partially ordered monoid, equipped with unit and binding operations satisfying graded identity and associativity laws.

In libfn that pomonoid is carried by the finite sets of C++ types:

  • Grades (𝐸): Finite sets of alternative types (errors).
  • Monoidal multiplication (⋅): Set union (∪), representing effect accumulation.
  • Identity (𝐼): The empty set (∅), representing no alternatives on the graded side.
  • Partial order (≤): Subset relation (⊆), which licenses effect approximation (subeffecting/widening).

Taking union as the multiplication makes this monoid commutative and idempotent – a join-semilattice – whose order is the one it induces, 𝐸 ≤ 𝐹 ⟺ 𝐸 ∪ 𝐹 = 𝐹. Those are exactly the copack laws of Section 2: widening directly to a given superset agrees with widening through intermediate subsets of that same target.

For a standard monad 𝑀, the binding operation maps 𝑀⟨𝐴⟩ → (𝐴 → 𝑀⟨𝐵⟩) → 𝑀⟨𝐵⟩. Write 𝐺𝐸⟨𝐴⟩ for an expected with value type 𝐴 and error alternatives 𝐸. For graded success binding with and_then, error grades combine by union:

𝑏𝑖𝑛𝑑 𝐺𝐸⟨𝐴⟩ → (𝐴 → 𝐺𝐹⟨𝐵⟩) → 𝐺𝐸 ∪ 𝐹⟨𝐵⟩

Conversely, recovery with or_else binds errors and accumulates successful value alternatives by union. Write 𝐻𝑉⟨𝑋⟩ for an expected with value alternatives 𝑉 and error type 𝑋. Recovery then has the form:

𝑟𝑒𝑐𝑜𝑣𝑒𝑟𝑦 : 𝐻𝑉⟨𝑋⟩ → (𝑋 → 𝐻𝑊⟨𝑌⟩) → 𝐻𝑉 ∪ 𝑊⟨𝑌⟩

For success binding, the unit injects a value into an expected with no error alternatives. For recovery binding, the unit injects an error into an expected with no successful value alternatives. In both cases, the unit uses the empty grade, which contributes no alternatives during composition.

10. The identity cluster

Certain operations behave like an identity functor across carriers. Because some states correspond structurally, libfn licenses specific cross-carrier behavior to avoid boilerplate.

The identity cluster consists of three infallible carrier forms:

Carrier Algebraic State Shape
just<T> T (A single value)
choice<Ts...> Ts... (A coproduct of values)
expected<T, copack<>> T + 0 ≅ T (A value and an uninhabited error)

These carriers add no failure or empty state to the outcomes represented by their payloads.

Because no member can hide failure, pipeline bind operations can transition across these boundaries:

  auto test_identity_cross() -> void
  {
    fn::just<UserId> j{UserId{}};

    // Cross-carrier pipeline bind to another 
    // identity carrier
    auto result = j | fn::and_then([](UserId u) {
      return fn::expected<UserId, fn::copack<>>{u};
      });
    static_assert(std::same_as<decltype(result),
      fn::expected<UserId, fn::copack<>>>);
  }

The bind operation adopts the carrier family of the provided callback – a crossing only the pipeline-scoped functors are licensed to make (Section 3).

Success-path bridging

Fallible carriers (excluding expected<T, copack<>>) and optional cannot transition to infallible carriers, because doing so would risk silently discarding an active error or empty state.

In contrast, identity carriers can bridge to any fallible carrier via pipeline fn::and_then. Because identity carriers are statically infallible, transitioning to optional or a standard expected merely introduces potential downstream failure. No pre-existing failure is discarded, as none can exist upstream. See Listing 12.

auto test_success_bridge(fn::just<int> j) -> void
{
  // An identity carrier can bridge to fallible 
  // carriers on the success path
  auto to_opt = j | fn::and_then([](int i) {
    return fn::optional<int>{i}; });
  static_assert(std::same_as<decltype(to_opt), 
    fn::optional<int>>);

  auto to_exp = j | fn::and_then([](int i) { 
    return fn::expected<int, IoError>{i}; });
  static_assert(std::same_as<decltype(to_exp),
    fn::expected<int, IoError>>);

  // Bridging a multi-alternative choice to 
  // fallible optional with heterogeneous success
  // join:
  auto choice_to_opt
    = fn::choice_for<int, bool>{true}
      | fn::and_then(fn::overload{
        [](int) -> fn::optional<char> { 
          return {'a'}; },
        [](bool) -> fn::optional<long> { 
          return {2L}; }});
  static_assert(std::same_as<decltype(
    choice_to_opt), 
    fn::optional<fn::copack_for<char, long>>>);
}
Listing 12

All three cluster members (just, choice, and expected<T, copack<>>) can bridge to fallible carriers.

Monadic operations on the identity cluster:

  • Success Mapping (transform): Preserves the carrier family. A copack returned from a callable mapped over a just produces a just of that copack, which is the choice over its alternatives (Section 11).
  • Sequential Binding (and_then): Allows cross-carrier transitions within the identity cluster (such as just to expected<U, copack<>>) when using pipeline fn::and_then.
  • Recovery & Error Mapping (transform_error, or_else, recover, inspect_error): Because just and choice lack an error side, these are ill-formed. On expected<T, copack<>>, they are vacuously well-formed but the error callback is statically unreachable.
  • Short-Circuiting (fail, filter): Ill-formed for identity carriers, as no failure (error or empty state) can be constructed from an infallible context.
  • Elimination Fallbacks (value_or): Ill-formed on just and choice. On expected<T, copack<>>, it remains well-formed to support generic code, but the fallback must still initialize T even though its branch is statically unreachable. If the value side is void, no fallback is accepted.
  • Neutral Observation (inspect, discard): Supported and behave normally.

11. choice: identity over a coproduct

choice<Ts...> is an infallible carrier whose operations dispatch to the active alternative. choice{x} copies scalar value x into the single-alternative choice, or a choice over its alternatives if x is a copack. To store a reference to scalar x, use fn::as_choice(x). Empty choice<> is an incomplete type.

Mapping into a choice

transform on a just whose callable returns a copack yields a choice – the same identity functor, its payload now a coproduct. See Listing 13.

auto test_identity_transformation(
  fn::just<UserId> j) -> void
{
// Transforming a just with a callable 
// returning a copack produces a choice
  auto mapped
    = j | fn::transform([](UserId) { 
      return fn::copack_for<Missing,
      FilePath>{Missing{}}; });
  static_assert(std::same_as<decltype(mapped),
    fn::choice_for<FilePath, Missing>>);
}
Listing 13

Similarly, member and_then on a just can return a choice directly. Pipeline fn::and_then also permits a transition to expected<T, copack<>>.

A callback passed to member and_then must return a just, including just<void> or a choice. Choice branches may return different just types. If those types agree after cv/ref removal, the result keeps that type; otherwise their payloads form a normalized choice. An ordinary just<T> contributes T, a returned choice contributes its alternatives, and just<void> contributes pack<>, as a void side does in a disjunction.

Inside its domain, choice behaves differently from bare copack:

  • copack is self-flattening data; a copack returned from a branch dissolves into the result.
  • choice is an atomic, never-failing computation; a choice returned from a branch remains an alternative unless and_then joins it.

Consider a scenario where different branches of a switch return different choice types (Listing 14).

auto test_choice_mapping(fn::choice<User, UserId> ch) -> void
{
  constexpr auto mapper = fn::overload{
    [](UserId){ return 
      fn::as_choice(Missing{}); },
    [](User) { return
      fn::as_choice(FilePath{}); }};

  // transform nests the returned choice as a 
  // mapped value
  auto mapped = ch | fn::transform(mapper);
  static_assert(
    std::same_as<decltype(mapped), 
    fn::choice_for<fn::choice<FilePath>,
    fn::choice<Missing>>>);

  // and_then joins and flattens them into a 
  // normalized superset choice
  auto bound = ch | fn::and_then(mapper);
  static_assert(std::same_as<decltype(bound),
  fn::choice_for<FilePath, Missing>>);
}
Listing 14

A callback returning a bare value requires transform rather than and_then; choice’s and_then rejects bare value returns with a compile-time diagnostic.

Why copack always flattens, but choice does not

Categorically, copack<Ts...> represents a coproduct payload (disjoint sum ⊕ 𝑇𝑖), whereas choice<Ts...> places that payload in an identity-monad carrier. These two entities have different purposes, hence also different behaviour:

  1. copack normalizes the payload algebra:

    Normalization deliberately removes grouping between sums (for example, (𝐴 + 𝐵) + 𝐶 ≅ 𝐴 + 𝐵 + 𝐶): a nested copack is ill-formed by design. The constraint is intentional: it allows transform on copack to collapse every branch’s results into a flat, deduplicated set instead of an ever-nesting type.

  2. choice preserves the carrier boundary:

    To distinguish mapping from monadic composition, the monadic carrier choice<Ts...> wraps the sum in an ‘identity layer’ to preserve structural depth: choice<choice<T>> ≠ choice<T>. That layer holds eager flattening in check.

    Since choice<Ts...> is an alias for just<copack<Ts...>>, the underlying identity-wrapper model is 𝑀(𝐴) = 𝑗𝑢𝑠𝑡⟨𝐴⟩ with 𝐴 being the coproduct payload in the case of choice. In this model, the unit and multiplication correspond to wrapping and unwrapping the carrier:

    • Unit/return η𝐴 : 𝐴 → 𝑀(𝐴) : wraps the coproduct in the choice layer. Building a choice from a single alternative composes this with the coproduct’s own injection ι𝑖: 𝑇𝑖 → ⊕𝑗 𝑇𝑗.
    • Join/flatten μ𝐴 : 𝑀(𝑀(𝐴)) → 𝑀(𝐴) : removes one just layer.
    • Bind: maps, then flattens explicitly via join. Making that step explicit is what grants control over when flattening occurs.

  3. When binding combines different returned choice types, their payload alternatives are flattened and deduplicated according to the copack rules. This determines the resulting payload type, separately from removing the carrier layer.

12. Elimination and multidispatch

Eliminating computation structures yields an ordinary C++ value. This is typically achieved via apply or apply_r. Singular copack supports direct extraction via get (Section 4), and just supports total value extraction via .value(). On fallible carriers, .value() is partial: it yields the value if present, and otherwise throws (bad_expected_access, bad_optional_access).

transform and apply differ in whether the structure survives:

  • transform remains inside the carrier or copack, producing a new carried type.
  • apply eliminates the structure: the result type is deduced from the branches, which must all return the same type.
  • apply_r<R> permits any branch results that convert to R.

Application expands one selected level only. The call shapes are straightforward:

Type Eliminated Call Shape
A f(A)
pack<A, B> f(A, B)
pack<> f()
std::tuple<A, B> f(A, B)
copack<A, B> f(A) or f(B)
copack<pack<A, B>, C> f(A, B) or f(C)

A whole-carrier expected application cleanly handles both success and error paths into one result type:

  auto test_elimination(fn::expected<UserId,
    fn::copack<Missing>> ex) -> int
  {
    return ex.apply(fn::overload{[](UserId) {
      return 1; }, [](Missing) { return 0; }});
  }

Exhaustiveness is statically constrained. If no handler can accept a possible input, the compilation fails. fn::overload is merely a helper; final selection always relies on ordinary C++ overload resolution.

Warning — catch-all handlers defeat exhaustiveness

Exhaustiveness requires a valid handler invocation for every alternative. An unconstrained [](auto&&...) accepts all alternatives, so a copack that later gains an alternative still compiles – the new alternative routes into the catch-all instead of failing where its branch is missing. Where one handler should serve several alternatives, name a concept instead of auto – for example [](MyConcept auto &&) – and the exhaustiveness check still fires for anything the concept does not match.

Type-tagged elimination

Because storage shape and call shape are distinct, untagged apply can erase structural context (such as in expected<int, int>). To preserve context and prevent implicit conversions from conflating different states, libfn provides apply_type (and apply_type_r) member functions.

Eliminating a carrier using apply_type passes an explicit state tag as the first argument to the handler, followed by the unpacked payload:

  • expected: The success arm receives std::in_place and the success value (or std::in_place alone if void), while the error arm receives fn::unexpect and the error.
  • optional: The success arm receives std::in_place and the value, while the empty arm receives std::nullopt.
  • copack and choice: The active alternative arm receives std::in_place_type<T> and the payload.
  • just: The arm receives std::in_place_type<T> and the value. Symmetrically, just<void>’s arm receives std::in_place_type<void> alone, representing success without a payload.
Elimination of algebraic structures

The two eliminations are not dual to one another – the dual of coproduct elimination is product introduction, pairing – and they differ in how much the category determines.

  • Coproduct: eliminating 𝐴 + 𝐵 is canonical. Supplying 𝑓 : 𝐴 → 𝐶 and 𝑔 : 𝐵 → 𝐶 determines a unique mediating morphism [𝑓, 𝑔] : 𝐴 + 𝐵 → 𝐶 by the universal property, and in libfn ordinary C++ overload resolution is what computes it.
  • Product: its universal property characterizes maps into 𝐴 × 𝐵; the operation 𝐴 × 𝐵 → 𝐶 must be supplied by the caller. What apply supplies is the bridge between the product as stored, a pack, and the product as an argument list, which C++ keeps distinct.

Carrier elimination (apply_type) additionally preserves the injections: the state tag – such as std::in_place, fn::unexpect or std::in_place_type<T> – tells the handler which injection morphism placed the value into the structure.

13. The monadic operations map

A reference of libfn operations, organized by channel and effect:

Success channel

  • transform: Maps the successful value. Stays inside the carrier.
  • and_then: Sequences success-path computations. The mechanism for introducing new errors into a graded expected.
  • filter: Enters a short-circuit state if a predicate fails.
  • inspect: Observes the successful value transparently.
  • fail: Intercepts success and forces a transition to a failure state.

Error/empty channel

  • transform_error: Maps the error value. Stays inside the carrier and can narrow a graded error set (Section 9).
  • or_else: Sequences computations based on errors. Joins recovery values.
  • recover: Intercepts failure and forces a transition back to a success state.
  • inspect_error: Observes the error value transparently.
  • value_or: Supplies a fallback. Member .value_or(x) eliminates the carrier to return the value; pipeline fn::value_or(x) preserves the carrier, returning it engaged with either its own value or the fallback.

Neutral

  • discard: Discards the carrier and returns void to signal that the return value is deliberately ignored.

Elimination

  • apply: Routes the stored state to an overload set, leaving the algebra with an ordinary C++ value.
  • apply_type: The same elimination, keyed by an explicit state tag.

Composition & combination

  • operator& (conjunction): Combines independent computations (values into a pack, errors as a union).
  • operator| (disjunction): Combines alternative computations (values into a copack disjoint sum, errors as a product pack).
  • fn::conjoin: An n-ary fold of operator&, over monadic carriers or over packs, copacks and scalars – not a mix of the two.
  • fn::disjoin: An n-ary fold of operator| over monadic carriers, supporting total disjunction with the identity cluster.

Key architectural rules of the map

  • fail and recover are duals: fail transitions success to failure (Success ⇒ Failure), and recover transitions failure to success (Failure ⇒ Success). Neither operation widens a graded error set.
  • Error-graded and_then: Combines input and callback error alternatives by union of types.
  • Value-graded or_else: Similarly combines input and callback value alternatives.
  • filter and fail: Enter an existing short-circuit state; the carrier must already support the failure state.
  • Error-side operations (such as transform_error, or_else, recover, and inspect_error): Require a carrier with an error or empty side. They are ill-formed on just and choice, and remain vacuously well-formed on expected<T, copack<>> (Section 10).

14. Laws as C++ equalities

When results can be compared in a constant expression, instances of the laws can be checked by the compiler. The following checks illustrate functor identity and monad left identity (Listing 15).

constexpr auto test_laws() -> void
{
  constexpr fn::expected<int, 
            fn::copack<Missing>> ex{42};

  // Functor Identity: mapping with identity 
  // yields the same value
  constexpr auto id = [](auto v) { return v; };
  static_assert((ex | fn::transform(id)) == ex);

  // Monad Left Identity: pure(x) >>= f is
  // equivalent to f(x)
  constexpr auto pure = [](int v) { return
    fn::expected<int, fn::copack<Missing>>{v}; };
  constexpr auto f = [](int v) { return 
    fn::expected<int, 
    fn::copack<Missing>>{v * 2}; };
  static_assert((pure(42) 
    | fn::and_then(f)) == f(42));
}
Listing 15

Other properties hold:

  • Functor composition:

      m | transform(f) | transform(g)

    equals

      m | transform([](auto&&... v) {
        return g(f(v...)); })

    (for singular parameters; for packs, use apply).

  • Monad right identity: m | and_then(pure) equals m.
  • Monad associativity: (m | and_then(f)) | and_then(g) equals:

      m | and_then([](auto v) {
        return f(v) | and_then(g); })

    For graded expected, both sides of the associativity derive the same normalized union grade.

  • Product associativity: Holds after canonical pack normalization.
  • Coproduct set semantics: Union associativity, commutativity, and idempotence apply.
  • Coherent widening: Upcasting an error through intermediate supersets yields the same final type as upcasting directly to the broadest superset.

15. C++ mechanics that preserve the algebra

To ensure reliability, libfn uses compiler mechanisms to reject malformed usage and preserve performance.

Constraints and exhaustiveness

Public concepts and requires clauses constrain which calls are accepted. fn::monadic_invocable verifies pipeline invocation, while concepts such as fn::applicable_transform and fn::applicable_and_then express individual applicability rules. These checks enforce exhaustive handling of alternatives where a callback is invoked and require complete type definitions to be present. This underpins the compile-time exhaustiveness guarantees of apply and monadic operations established in Sections 4 and 12.

One normalization order per program

The copack normal form rests on a strict total order over types, and the default and LIBFN_CXX26 modes may derive different orders (Section 2). Each mode therefore places fn in its own ABI namespace: the mode becomes part of every fn symbol, so the two modes never link as one, and copacks whose layouts disagree cannot merge unnoticed. (pfn carries no such split – it has no normal form to protect.)

C++ value properties

The library respects C++ value mechanics:

  • Core operations are constexpr.
  • const is propagated.
  • The algebra’s own types (pack, copack, just, and choice) are structural when their elements are, so a constexpr value of these types may be used as a template parameter.
  • Value categories (lvalue/rvalue) propagate strictly to callbacks.
  • Reference collapsing rules apply to carriers and their payloads.
  • Immovable and move-only payloads are supported in place.
  • Reference-bearing pack<T&...> and copack<T&...> are supported. Lifetime management of non-owning references remains with the caller.
  • pack compares element-wise, supporting equality and three-way comparison. For reference-bearing pack<T&...> and copack, comparison applies to the referents.

pfn and fn

The library is divided into layers:

  • pfn (Polyfill fn) is the standards-facing layer. It provides std::optional and std::expected in their C++26 shape – monadic member functions, optional<T&>, range support – plus smaller utilities such as std::invoke_r and std::unreachable, all available to a C++20 compiler.
  • fn is the strict extension layer. It introduces the pack/copack algebra, multidispatch, graded monads, choice, just, the pipeline functors, and the composition operators & and |.

Every fn type with a pfn counterpart is a superset of it. Mixing pfn and fn within a single program is allowed, provided that types from the two namespaces do not interact. For example, storing an fn::pack in a pfn::optional is not supported, but the two may be used independently in the same program. Switching a valid program from pfn to fn is not expected to change compilation nor program behaviour.

Functional terminology

For readers with a background in functional languages (like Haskell or OCaml), this table translates standard terminology to libfn’s C++ vocabulary:

Functional term libfn equivalent
fmap / map transform / transform_error
bind / >>= and_then
pure / return just<T>{v}/ expected<T, copack<>>{v} – a carrier constructor
Lift / inject fn::as_pack / fn::as_copack / fn::as_choice
Kleisli arrow The callable passed to and_then
Product type pack / std::tuple
Coproduct / Sum copack (the sum itself) / choice (the never-failing carrier over a sum)
Subeffecting Widening an error grade / subset inclusion

Further reading

For the mathematical background to the structures modeled in libfn, refer to:

1. Orchard and Petricek, ‘Embedding effect systems in Haskell’ (for effect sets, union, and subeffecting) available at: https://www.doc.ic.ac.uk/~dorchard/publ/haskell14-effects.pdf

2. Orchard, Wadler, and Eades, ‘Unifying graded and parameterised monads’, specifically Definition 21 (for the graded-monad interpretation) available at https://arxiv.org/pdf/2001.10274

3. McDermott and Uustalu, ‘Flexibly Graded Monads and Graded Algebras’, available at: https://dylanm.org/flexibly-graded-monads.pdf

Note: libfn does not claim to fully implement their flexibly graded construction, but the work contextualizes graded structures.

4. Emily Riehl, ‘Category Theory in Context’ (for the categorical foundations, including universal properties, products, coproducts, and monads), available at: https://emilyriehl.github.io/files/context.pdf

The library is available at github.com/libfn/functional

Bronek Kozicki developed his lifelong programming habit at the age of 13, teaching himself Z80 assembler to create special effects on his dad’s ZX Spectrum. BASIC, Pascal, Lisp and a few other languages followed, before he settled on C++ as his career choice. In 2005, he moved from Poland to London, and promptly joined the BSI C++ panel with a secret agenda: to make C++ more like Lisp, but with more angle brackets.






Your Privacy

By clicking "Accept Non-Essential Cookies" you agree ACCU can store non-essential cookies on your device and disclose information in accordance with our Privacy Policy and Cookie Policy.

Current Setting: Non-Essential Cookies REJECTED


By clicking "Include Third Party Content" you agree ACCU can forward your IP address to third-party sites (such as YouTube) to enhance the information presented on this site, and that third-party sites may store cookies on your device.

Current Setting: Third Party Content EXCLUDED



Settings can be changed at any time from the Cookie Policy page.