Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

Design history of libfn, newest first. The living documents — [README.md](README.md), [CONTRIBUTING.md](CONTRIBUTING.md), [docs/](docs/) — describe only the present state of the design; when a decision makes an earlier idea obsolete, this file is where the transition is recorded and explained.

## The conjunction sums two ungraded errors — 25 July 2026

- **`operator&` no longer asks its operands to arrive graded** ([#384](https://github.com/libfn/functional/issues/384)): conjoining `expected<int, Error>` with `expected<bool, Fault>` was ill-formed unless one of the two error types was already a `copack`, though the same pair disjoined without complaint. The conjunction's error channel *is* the sum, so producing the grade is the operator's work, not a precondition on its operands — and nowhere else does a sum ask for one: `|` forms its value sum out of two ungraded value types, in every carrier. Of the four channels — `&`'s value product and error sum, `|`'s value sum and error product — this was the only one gated. The four different-error arms now serve any distinct pair, widening to `copack_for<E1, E2>` exactly as they did for a graded operand, and `conjoin` follows as their fold. Purely additive: every expression newly accepted was ill-formed before, and a matching pair of error types still collapses to the plain error it always had.
- **The bind is deliberately left out of this.** `and_then` and `or_else` are the foundational operations, and a callback whose error type merely differs from self's still refuses: switching a pipeline to the graded monad is the caller's decision to make, not something a bind does behind their back, and the library offers spellings enough to opt in.

## The verbs reach an uninhabited value side, and `pack` compares — 25 July 2026

- **Every value-side verb now serves a carrier whose value side is uninhabited** ([#380](https://github.com/libfn/functional/issues/380)): `optional<copack<>>`, never engaged, and `expected<copack<>, E>`, always holding its error. Their members have been the identity there since the empty-sum doctrine (17 July below) — the mapping returns `*this` and the callback is neither invoked nor instantiated — but the verbs answered false, so a pipeline could not reach what the member could. `transform` and `and_then` delegate to those vacuous members; `inspect` observes nothing, `filter` rejects nothing and `fail` never fires, each passing the operand through; `discard` always accepted them. The operand class is named: `some_empty_value` for the value side and `some_empty_error` for its mirror, the identity `expected` and only it, which `some_identity` now spells as its third alternative. Both answer for any type at all — the carrier test short-circuits before the value or error type is named — so their negations stay usable in constraints.
Expand Down
8 changes: 0 additions & 8 deletions include/fn/expected.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -1971,8 +1971,6 @@ template <typename Lh, typename Rh>
requires some_expected_void<Lh> && (not some_expected_void<Rh>)
&& (not ::std::is_same_v<typename ::std::remove_cvref_t<Lh>::error_type,
typename ::std::remove_cvref_t<Rh>::error_type>)
&& (some_copack<typename ::std::remove_cvref_t<Lh>::error_type>
|| some_copack<typename ::std::remove_cvref_t<Rh>::error_type>)
[[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) //
noexcept(detail::_nothrow_join_widened<
expected<typename ::std::remove_cvref_t<Rh>::value_type,
Expand Down Expand Up @@ -2024,8 +2022,6 @@ template <typename Lh, typename Rh>
requires(not some_expected_void<Lh>) && some_expected_void<Rh>
&& (not ::std::is_same_v<typename ::std::remove_cvref_t<Lh>::error_type,
typename ::std::remove_cvref_t<Rh>::error_type>)
&& (some_copack<typename ::std::remove_cvref_t<Lh>::error_type>
|| some_copack<typename ::std::remove_cvref_t<Rh>::error_type>)
[[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) //
noexcept(detail::_nothrow_join_widened<
expected<typename ::std::remove_cvref_t<Lh>::value_type,
Expand Down Expand Up @@ -2074,8 +2070,6 @@ template <typename Lh, typename Rh>
requires some_expected_void<Lh> && some_expected_void<Rh>
&& (not ::std::is_same_v<typename ::std::remove_cvref_t<Lh>::error_type,
typename ::std::remove_cvref_t<Rh>::error_type>)
&& (some_copack<typename ::std::remove_cvref_t<Lh>::error_type>
|| some_copack<typename ::std::remove_cvref_t<Rh>::error_type>)
[[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) //
noexcept(detail::_nothrow_join_widened<
expected<void, copack_for<typename ::std::remove_cvref_t<Lh>::error_type,
Expand Down Expand Up @@ -2121,8 +2115,6 @@ template <typename Lh, typename Rh>
requires(not some_expected_void<Lh>) && (not some_expected_void<Rh>)
&& (not ::std::is_same_v<typename ::std::remove_cvref_t<Lh>::error_type,
typename ::std::remove_cvref_t<Rh>::error_type>)
&& (some_copack<typename ::std::remove_cvref_t<Lh>::error_type>
|| some_copack<typename ::std::remove_cvref_t<Rh>::error_type>)
[[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) //
noexcept(noexcept(
::fn::detail::_join<
Expand Down
60 changes: 58 additions & 2 deletions tests/fn/expected.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1085,12 +1085,13 @@ TEST_CASE("expected conjunction", "[expected][operator_and][graded][copack]")
static_assert(
not noexcept(std::declval<fn::expected<throwing_copy, Error> &>() & std::declval<fn::expected<int, Error> &>()));

// constraints: both operands are expected, and their error types must match or be graded
// constraints: both operands are expected; their error types may differ, graded or not
constexpr auto can_amp = [](auto &&rh) { return requires { std::declval<fn::expected<int, Error> &>() & rh; }; };
static_assert(can_amp(fn::expected<void, Error>{}));
static_assert(not can_amp(42));
enum class other_error {};
static_assert(not can_amp(fn::expected<int, other_error>{1})); // mismatched non-graded error
static_assert(can_amp(fn::expected<int, other_error>{1})); // mismatched non-graded error
static_assert(not can_amp(fn::optional<int>{1})); // ... but the kinds still have to match

SECTION("same error type")
{
Expand Down Expand Up @@ -1470,6 +1471,61 @@ TEST_CASE("expected conjunction", "[expected][operator_and][graded][copack]")
}
}

SECTION("different plain error types")
{
// The conjunction produces the error sum rather than requiring it: two unrelated, ungraded
// error types conjoin into their copack, exactly as an already graded operand does.
enum OtherError : int { Oops };
using EA = fn::expected<int, Error>;
using EB = fn::expected<bool, OtherError>;
using Sum = fn::copack_for<Error, OtherError>;

static_assert(
std::same_as<decltype(std::declval<EA>() & std::declval<EB>()), fn::expected<fn::pack<int, bool>, Sum>>);
static_assert(std::same_as<decltype(std::declval<fn::expected<void, Error>>() & std::declval<EB>()),
fn::expected<bool, Sum>>);
static_assert(std::same_as<decltype(std::declval<EA>() & std::declval<fn::expected<void, OtherError>>()),
fn::expected<int, Sum>>);
static_assert(std::same_as<decltype(std::declval<fn::expected<void, Error>>()
& std::declval<fn::expected<void, OtherError>>()),
fn::expected<void, Sum>>);

constexpr auto is_1_true = [](int i, bool b) { return i == 1 && b; };
// which alternative the sum holds: the failing operand's error injects by type, leftmost first
constexpr auto which = fn::overload{[](Error e) { return e == FileNotFound ? 1 : 0; },
[](OtherError e) { return e == Oops ? 2 : 0; }};

static_assert((EA{1} & EB{true}).value().apply(is_1_true));
static_assert((EA{::fn::unexpect, FileNotFound} & EB{true}).error().apply(which) == 1);
static_assert((EA{1} & EB{::fn::unexpect, Oops}).error().apply(which) == 2);
static_assert((EA{::fn::unexpect, FileNotFound} & EB{::fn::unexpect, Oops}).error().apply(which) == 1);
static_assert((fn::expected<void, Error>{} & EB{true}).value());
static_assert((EA{1} & fn::expected<void, OtherError>{}).value() == 1);
static_assert(
(fn::expected<void, Error>{} & fn::expected<void, OtherError>{::fn::unexpect, Oops}).error().apply(which) == 2);

CHECK((EA{1} & EB{true}).value().apply(is_1_true));
CHECK((EA{::fn::unexpect, FileNotFound} & EB{true}).error().apply(which) == 1);
CHECK((EA{1} & EB{::fn::unexpect, Oops}).error().apply(which) == 2);
CHECK((EA{::fn::unexpect, FileNotFound} & EB{::fn::unexpect, Oops}).error().apply(which) == 1);
CHECK((fn::expected<void, Error>{} & EB{true}).value());
CHECK((EA{1} & fn::expected<void, OtherError>{}).value() == 1);
CHECK((fn::expected<void, Error>{} & fn::expected<void, OtherError>{::fn::unexpect, Oops}).error().apply(which)
== 2);

SECTION("noexcept")
{
// both operands' errors are lifted into the sum, so both relocations weigh
using Th = fn::expected<int, MoveNothrow>;
static_assert(noexcept(std::declval<EA &>() & std::declval<EB &>()));
static_assert(not noexcept(std::declval<Th &>() & std::declval<EA &>())); // copies the error
static_assert(noexcept(std::declval<Th &&>() & std::declval<EA &&>())); // moves it
static_assert(not noexcept(std::declval<EA &>() & std::declval<Th &>()));
static_assert(noexcept(std::declval<EA &&>() & std::declval<Th &&>()));
SUCCEED();
}
}

SECTION("graded monad as left operand")
{
static_assert(std::same_as<decltype(std::declval<fn::expected<int, fn::copack<Error>>>()
Expand Down
10 changes: 10 additions & 0 deletions tests/fn/pack.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1022,6 +1022,16 @@ TEST_CASE("operator &", "[pack][copack][operator_and]")
static_assert(fn::conjoin(EA{1}, EB{2.5}).value().apply(is_1_2_5));
static_assert(fn::conjoin(EA{fn::unexpect, true}, EB{2.5}).error() == true);

// ... and the operands' errors need not agree: the conjunction sums them, ungraded or not
using EC = fn::expected<double, int>;
static_assert(std::same_as<decltype(fn::conjoin(std::declval<EA>(), std::declval<EC>())),
fn::expected<fn::pack<int, double>, fn::copack_for<bool, int>>>);
static_assert(fn::conjoin(EA{1}, EC{2.5}).value().apply(is_1_2_5));
static_assert(fn::conjoin(EA{fn::unexpect, true}, EC{2.5}).error() == fn::copack_for<bool, int>{true});
static_assert(fn::conjoin(EA{1}, EC{fn::unexpect, 7}).error() == fn::copack_for<bool, int>{7});
CHECK(fn::conjoin(EA{1}, EC{2.5}).value().apply(is_1_2_5));
CHECK(bool(fn::conjoin(EA{1}, EC{fn::unexpect, 7}).error() == fn::copack_for<bool, int>{7}));

// n-ary, and the product splices rather than nesting
constexpr auto is_1_true_2 = [](int a, bool b, int c) { return a == 1 && b && c == 2; };
static_assert(fn::conjoin(fn::just<int>{1}, fn::just<bool>{true}, fn::just<int>{2}).value().apply(is_1_true_2));
Expand Down