Skip to content

Add fn::join - the monadic join as a verb and an endo member #415

Description

@Bronek

Add the monadic join as both a pipeline verb (x | fn::join()) and an endo member (.join()).

Specification

join is exactly and_then with the identity Kleisli arrow. Semantically, x | fn::join()x | fn::and_then(λv. v) wherever the latter is well-formed, and is ill-formed otherwise.

The implementation extracts (moves) the inner carrier directly. It deliberately does not use std::identity: its T&& return would rely on auto decay to avoid a dangling reference, creating a hazard for future refactoring. The equivalence is the specification, not the implementation.

All behavior follows from and_then's admission table:

  • Member .join() is a strict, same-kind endomorphism. It flattens expected<expected<T,E>,E>, optional<optional<T>>, just<just<T>>, and a choice of choices through the branch-wise superset splice. A copack-graded nesting unions the grades (the graded μ: G_E ∘ G_F → G_{E∪F}).
  • Pipeline fn::join() supports the endo cases plus the crossings licensed by the and_then verb: just<optional<T>>optional<T>, expected-at-⊥ evaporation, and the optional bridge.
  • Refusals are inherited as well. expected<expected<T,E1>,E2> with differing ungraded errors is rejected in both forms, leaving the grading opt-in doctrine untouched.
  • copack has no join in either spelling. This follows automatically: a copack is data rather than a carrier (some_monadic_type excludes it), and its flattening is an invariant rather than an operation. A dedicated diagnostic should state: "a copack is self-flattening; there is nothing to join".

A public applicable_join concept accompanies the verb. It is defined by delegation to the and_then applicability families with the identity arrow, making rejection substitution-visible in accordance with the library-wide constraint discipline.

Law

This addition makes the following law pinnable for carrier-returning callbacks:

and_then(f)join ∘ transform(f)

This is the defining equation bind = join ∘ fmap, expressed as a compile-time test. The following witness compiles today with g++ 16.2.1 and clang++ 22.1.8 under -std=c++20 -Wall -Wextra -Werror:

fn::just<int> j{42};

auto nested = j | fn::transform([](int) { return fn::choice_for<A, B>{A{}}; });
static_assert(std::same_as<decltype(nested), fn::just<fn::choice_for<A, B>>>);  // fmap nests

auto joined = std::move(nested)
              | fn::and_then([](auto &&v) { return std::forward<decltype(v)>(v); });
static_assert(std::same_as<decltype(joined), fn::choice_for<A, B>>);            // join strips

Sequencing

Implement this after the choice re-founding (#414).

With choice<Ts...> = just<copack<Ts...>>, the equation holds with no carve-outs. transform never changes the carrier family: a copack-returning map lands flat on just<copack<A,B>> (nothing is nested, so join and and_then both refuse), while a choice-returning map lands on the genuinely nested just<just<copack<A,B>>>, which join strips. Without the re-founding, join's documented domain would retain the pipeline-promotion boundary only to delete it.

Presentation

μ remains derived. The library's graded monad is defined by unit plus Kleisli extension (bind), matching the papers' extension form, and join is definitionally and_then(identity): a named convenience, never a new primitive.

This issue also subsumes the parked collapse operation for flattening a nested choice. splice—opening a choice inside a copack at the data level—remains a separate future item.

Assisted-by: Claude:claude-fable-5

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestrelease-0.2Planned for release 0.2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions