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 avoid.
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 viaoperator|(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:
- Type algebra records and normalizes the exact stored C++ types using
packandcopackas you compose operations. - The application protocol uses
applyand 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 containsT, similar tocopack_for<T, std::nullopt_t>)std::expected<T, E>≅ T + E (It contains either successTor errorE, similar tocopack_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 is0.pack<>is the one nullary product value. You can construct it viapack<>{}orfn::as_pack(). Algebraically, it is1.
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 (addingcopack<>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::tupleremains a standard tuple.std::variantdoes not acquirecopackset semantics.- Tuple-like participation in
applychanges 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
packandcopack_for. - Explicit conversions via
fn::as_packandfn::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 typeTor is empty (std::nullopt).expected<T, E>(representing 𝑇 + 𝐸): A carrier that either holds a successful value of typeTor an error of typeE.
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 typeT.choice<Ts...>: Always contains one of selected alternatives. It is a type alias tojust<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:
transformpreserves the carrier family.- Success and error states are preserved.
- A bare
copackhas a membertransformto map across alternatives, but takes no pipeline functor (it’s not a carrier). - Heterogeneous results inside
transformortransform_errorform a normalizedcopack(grading, Section 9). - Applying
transform_errorto a carrier with no error side (such asjustorchoice) is ill-formed. - When a side is uninhabited (
copack<>), transformation is well-formed but vacuous: the callback overcopack<>is neither invoked nor instantiated. This applies to all ofoptional<copack<>>,expected<copack<>, E>, andexpected<T, copack<>>. - A
voidresult is represented in acopackaspack<>. - Mapping from
copacknever yields a plain type – even when all branches return the same type, the result remainscopack.
| Functorial action on the initial object | |
|
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
justorchoiceoperand preserves the fallible operand’s error side (plain or graded). Anexpected<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 gradedcopack. - Value Bundling: The identity cluster operand’s value conjoins with the fallible operand’s value into a product.
- Void Elision:
just<void>andexpected<void, copack<>>act as the product’s identity unit and are elided from the value product (for example,expected<int, E> & just<void>yieldsexpected<pack<int>, E>). - Choice Distribution: Conjoining a
choicewith a fallible carrier distributes the coproduct through the product, yielding acopackofpacks 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 | |
|
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
justcarrier, including when an operand isexpected<T,copack<>>. A result of typejust<copack<Ts...>>can also be spelledchoice<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 | |
|
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:
optionalbinds tooptional.- Plain
expected<T, E>binds toexpected<U, E>(retaining errorE) or, via singular lift, toexpected<U, copack<E>>(transitioning to a graded context, Section 9). - Error-graded
expectedforms the union of heterogeneous error sets with acopack. - Copack-valued inputs join heterogeneous successful branch types into a
copack. - Branch convergence preserves the exact type without duplicate union states.
- All-
voidbranches join tovoid; in mixedvoidand non-voidbranches,voidcontributespack<>. - Callback results returning bare values require
transformrather thanand_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:
- The successful branch values formed the normalized value
copack. - The existing outer errors (
BadSyntax,UnknownKey) and the new branch errors (OutOfRange,Missing)formed the normalized errorcopack.
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 typeEis promoted tocopack<E>if the returning error type of the callback iscopack<E>. - In
or_else(recovery/error binding), a plain success typeTis promoted tocopack<T>if the returning success type of the callback iscopack<T>. - For a
voidsuccess type inor_else, the corresponding lift iscopack<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()onexpectedexplicitly lifts the error, transformingexpected<T, E>toexpected<T, copack<E>>..copack_value()onexpectedexplicitly lifts the success value, transformingexpected<T, E>toexpected<copack<T>, E>.- For
expected<void, E>, the result isexpected<copack<pack<>>, E>. .copack_value()onoptionalsymmetrically lifts the value, transformingoptional<T>tooptional<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 | |
|
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. Acopackreturned from a callable mapped over ajustproduces ajustof thatcopack, which is thechoiceover its alternatives (Section 11). - Sequential Binding (
and_then): Allows cross-carrier transitions within the identity cluster (such asjusttoexpected<U, copack<>>) when using pipelinefn::and_then. - Recovery & Error Mapping (
transform_error,or_else,recover,inspect_error): Becausejustandchoicelack an error side, these are ill-formed. Onexpected<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 onjustandchoice. Onexpected<T, copack<>>, it remains well-formed to support generic code, but the fallback must still initializeTeven though its branch is statically unreachable. If the value side isvoid, 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:
copackis self-flattening data; acopackreturned from a branch dissolves into the result.choiceis an atomic, never-failing computation; achoicereturned from a branch remains an alternative unlessand_thenjoins 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 | |
|
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:
transformremains inside the carrier orcopack, producing a new carried type.applyeliminates 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 toR.
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 receivesstd::in_placeand the success value (orstd::in_placealone ifvoid), while the error arm receivesfn::unexpectand the error.optional: The success arm receivesstd::in_placeand the value, while the empty arm receivesstd::nullopt.copackandchoice: The active alternative arm receivesstd::in_place_type<T>and the payload.just: The arm receivesstd::in_place_type<T>and the value. Symmetrically,just<void>’s arm receivesstd::in_place_type<void>alone, representing success without a payload.
| Elimination of algebraic structures | |
|
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; pipelinefn::value_or(x)preserves the carrier, returning it engaged with either its own value or the fallback.
Neutral
discard: Discards the carrier and returnsvoidto 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 apack, errors as a union).operator| (disjunction): Combines alternative computations (values into acopackdisjoint sum, errors as a productpack).fn::conjoin: An n-ary fold ofoperator&, over monadic carriers or over packs, copacks and scalars – not a mix of the two.fn::disjoin: An n-ary fold ofoperator|over monadic carriers, supporting total disjunction with the identity cluster.
Key architectural rules of the map
failandrecoverare duals:failtransitions success to failure (Success ⇒ Failure), andrecovertransitions 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. filterandfail: Enter an existing short-circuit state; the carrier must already support the failure state.- Error-side operations (such as
transform_error,or_else,recover, andinspect_error): Require a carrier with an error or empty side. They are ill-formed onjustandchoice, and remain vacuously well-formed onexpected<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)equalsm. 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
packnormalization. - 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. constis propagated.- The algebra’s own types (
pack,copack,just, andchoice) are structural when their elements are, so aconstexprvalue 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&...>andcopack<T&...>are supported. Lifetime management of non-owning references remains with the caller. packcompares element-wise, supporting equality and three-way comparison. For reference-bearingpack<T&...>andcopack, comparison applies to the referents.
pfn and fn
The library is divided into layers:
pfn(Polyfill fn) is the standards-facing layer. It providesstd::optionalandstd::expectedin their C++26 shape – monadic member functions,optional<T&>, range support – plus smaller utilities such asstd::invoke_randstd::unreachable, all available to a C++20 compiler.fnis the strict extension layer. It introduces thepack/copackalgebra, 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
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.









