Skip to main content

Module architecture

Module architecture 

Source
Expand description

§Architecture & Design Decisions

This document records architectural decisions and design patterns used in fp-library.

For the module layout and dependency graph, see Project Structure. For the type class hierarchy diagrams, see Features.

§1. Module Organization

§1.1. Brand Structs (Centralized)

Decision:

Brand structs (e.g., OptionBrand) are centralized in src/brands.rs.

Reasoning:

  • Leaf Nodes: In the dependency graph, Brand structs are leaf nodes; they have no outgoing edges (dependencies) to other modules in the crate.
  • Graph Stability: Centralizing these leaf nodes in brands.rs creates a stable foundation. Higher-level modules (like types/*.rs) can import from this common sink without creating back-edges or cycles.

§1.2. Free Functions (Two Layers + Facade)

Decision:

Free functions exist in two layers, each adding a level of inference:

  1. classes/: Trait definitions and brand-explicit free functions without dispatch. Defined in their trait’s module (e.g., classes/functor.rs defines Functor::map).
  2. dispatch/: Each dispatch module (e.g., dispatch/functor.rs) contains the complete dispatch system in a pub(crate) mod inner:
    • The dispatch trait (e.g., FunctorDispatch) with Val/Ref impls that route by closure argument type.
    • The inference wrapper (e.g., pub fn map) that infers the brand from the container type via InferableBrand.
    • A pub mod explicit submodule with the brand-explicit dispatch variant (requires Brand turbofish).

functions.rs acts as a facade, re-exporting inference wrappers as the bare names (map, bind, etc.) and dispatch functions in the explicit sub-module (explicit::map, explicit::bind, etc.). All re-exports come from crate::dispatch::*.

Reasoning:

  • Primary API is inference-based: Users write map(f, Some(5)) with no turbofish. The explicit:: variants are the escape hatch for multi-brand types like Result.
  • Colocation: The dispatch trait, its impls, and the inference wrapper live in the same module. This keeps related code together and enables the #[document_module] proc macro to analyze dispatch traits and generate HM type signatures for the inference wrappers in a single pass.
  • Downstream dependencies: The dependency graph is dispatch/ -> classes/, and functions.rs re-exports from dispatch/ without adding a layer.
  • Facade pattern: functions.rs re-exports from dispatch/ modules to provide a unified API surface without coupling the underlying definition graph.

§2. Type Class Hierarchy Design

For the hierarchy diagrams, see Features.

§2.1. Composite Traits as Blanket Impls

Decision:

Applicative, Monad, Alternative, MonadPlus, and Comonad are marker traits with blanket implementations over their component traits. They define no methods of their own.

Reasoning:

  • Compositional: A type becomes Applicative automatically by implementing Pointed + Semiapplicative + ApplyFirst + ApplySecond. No additional code is required.
  • Mirrors theory: Matches the PureScript/Haskell pattern where composite classes are defined purely by superclass constraints (e.g., class (Pointed f, Semiapplicative f) => Applicative f).
  • Reduced boilerplate: Implementors only write the fundamental operations; composite traits are derived for free.

§2.2. Micro-Traits (Lift, ApplyFirst, ApplySecond)

Decision:

Lift, ApplyFirst, and ApplySecond are separate traits rather than methods on Semiapplicative.

Reasoning:

  • Semantic granularity: Lift provides the fundamental operation (lift2, lift3, etc.) for lifting uncurried functions into a functor. ApplyFirst and ApplySecond are sequencing combinators with default implementations via Lift.
  • Independent supertraits: Applicative requires all four (Pointed + Semiapplicative + ApplyFirst + ApplySecond) as equal parents. This makes the hierarchy explicit rather than hiding ApplyFirst/ApplySecond inside Semiapplicative.
  • Overridable defaults: Types can override ApplyFirst/ApplySecond independently for performance without touching Lift.

§2.3. Pointed is Separate from Applicative

Decision:

Pointed (providing pure/of) is an independent trait, not part of Semiapplicative.

Reasoning:

  • Follows PureScript’s hierarchy: Pointed is an independent superclass of Applicative, not bundled with Apply.
  • Avoids the “Why not Pointed?” problem: In Haskell, pure is bundled into Applicative, meaning you cannot have pure without <*>. Separating them allows types to implement Pointed alone (wrapping values) without committing to Semiapplicative.
  • Blanket composition: Keeping Pointed separate makes the Applicative blanket impl a clean intersection of four traits.

§2.4. Compactable is Separate from Filterable

Decision:

Compactable (compact, separate) is a standalone trait. Filterable extends Compactable + Functor.

Reasoning:

  • Staged capability: Compactable provides the minimal operations (unwrap nested Options, split Results) without requiring Functor. Filterable adds predicate-based operations (filter, partition, filter_map, partition_map) that depend on mapping.
  • Default implementations flow downward: filter_map defaults to map then compact; filter defaults to filter_map with a predicate. The staging makes these defaults natural.

§2.5. Ref* Hierarchy is Independent, Not a Subtrait

Decision:

RefFunctor, SendRefFunctor, etc. are independent traits, not subtraits of each other or of the base hierarchy.

Reasoning:

  • Send/Sync incompatibility: ArcLazy::new requires Send on the closure, which a generic RefFunctor cannot guarantee. As a result, ArcLazy implements only SendRefFunctor (not RefFunctor), and RcLazy implements only RefFunctor (not SendRefFunctor). Making SendRefFunctor: RefFunctor would prevent ArcLazy from implementing it without also implementing RefFunctor, which is unsound for Rc-based internals.
  • Separate from base traits: By-reference operations return different types (e.g., ref_map returns Brand::Of<B> from &Brand::Of<A>, not from an owned value). This is a fundamentally different signature, not a refinement of Functor::map.

§2.6. Par* Hierarchy Mirrors the Sequential Hierarchy

Decision:

ParFunctor, ParFoldable, ParCompactable, ParFilterable, etc. are separate traits that mirror the sequential hierarchy with Send + Sync bounds throughout.

Reasoning:

  • Avoids over-constraining: Adding Send + Sync to the base hierarchy would prevent non-thread-safe types (anything using Rc, Cell, etc.) from implementing Functor.
  • Feature-gated execution: With the rayon feature, par_* functions use true parallel execution. Without it, they degrade to sequential equivalents. The trait hierarchy is identical either way; only the implementation changes.
  • Composable with Ref axis: The ParRef* variants combine both the parallel and by-reference axes, giving four combinations total: base, Ref*, Par*, ParRef*.

§3. Documentation & Examples

Documentation structure (sections, headings, parameter ordering) is enforced by the documentation macros in fp-macros/src/documentation/ (#[document_module], #[document_signature], #[document_type_parameters], #[document_parameters], #[document_returns], #[document_examples]). All modules should use #[document_module]. See fp-macros/src/lib.rs for usage.

This section covers content quality guidelines that the macros cannot enforce.

§3.1. Type Signature Content

The type signature generated by #[document_signature] must accurately reflect the code. Contributors should verify:

  • Signatures correctly indicate uncurried semantics (the library’s standard).
  • Brand type parameters are replaced with their corresponding concrete types for clarity. Write Result e not ResultWithErrBrand e.
  • Quantifiers are accurate, correctly ordered (matching the code), and omit unused variables for clarity.

§3.2. Example Content

Examples should demonstrate the library’s intended usage patterns:

  • Import items using grouped wildcards (use fp_library::{brands::*, functions::*}) instead of individually by name.
  • For types with a single unambiguous brand (Option, Vec, Identity, etc.), use inference-based free functions without turbofish: map(|x| x * 2, Some(5)).
  • For types with multiple brands (Result at arity 1, Tuple2, Pair), use explicit:: variants with turbofish: explicit::map::<ResultErrAppliedBrand<E>, _, _, _, _>(f, x).
  • Prefer free functions over trait method calls (OptionBrand::map(...)).

Reasoning: The library is designed to be used via free functions with brand inference for the common single-brand case. The explicit:: variants are the escape hatch for ambiguous types. Examples should demonstrate the inference-based API as the primary path.

§4. Lint Policy

§4.1. Restriction Lints

The workspace enables several clippy restriction lints as warnings (promoted to errors by -D warnings in CI):

  • clippy::unwrap_used, clippy::expect_used - panicking unwrap/expect
  • clippy::indexing_slicing - panicking index/slice
  • clippy::panic, clippy::todo, clippy::unimplemented, clippy::unreachable - explicit panics

These lints are appropriate for production library code but overly strict for test and benchmark code. Test code suppresses them with #[expect(...)] rather than #[allow(...)].

§4.2. #[expect] vs #[allow]

Decision: Use #[expect(...)] everywhere the suppressed lint is known to fire. Reserve #[allow(...)] only where the lint does not currently fire but suppression is kept for correctness (e.g., dead_code on items consumed by macro expansion, deprecated on modules testing deprecation-based warnings).

Reasoning: #[expect] warns when the suppression becomes unnecessary (the lint no longer fires), preventing stale attributes from accumulating. #[allow] is silent when unused, so stale #[allow] attributes persist undetected.

§4.3. Reasons on All Lint Attributes

Decision: Every #[expect(...)] and #[allow(...)] attribute must include a reason = "..." string.

Reasoning: Reasons make the intent self-documenting. Without them, a reader must infer why the lint was suppressed, which is error-prone for restriction lints where the suppression could be masking a real bug.

§4.4. Suppression Scope

  • Inline test modules (#[cfg(test)] mod tests): Place #[expect(...)] on the mod tests block, listing only the restriction lints that actually fire within that module.
  • Integration test files (tests/*.rs): Use #![expect(...)] as an inner attribute at the file top.
  • Benchmark modules: Use #[expect(...)] on the module declaration in benchmarks.rs. Benchmarks that intentionally use identity operations or and_then instead of map (for fair std-vs-fp comparison) suppress identity_op, bind_instead_of_map, etc.
  • Production code: Suppress on the narrowest scope possible (individual statement or function), with a comment or reason explaining the safety invariant.