kleisli
Three newtype wrappers around p a (f b) with different type parameter orderings, enabling different type class instances depending on which parameter is last.
| Type |
Parameter order |
Primary instances |
Kleisli p a f b |
functor in b |
Functor, Applicative, Monad, MonadTrans, Distributive, Representable |
ProKleisli p f a b |
profunctor in (a, b) |
Profunctor, Strong, Choice, Closed, Category, Arrow, Sieve, Representable |
ContraKleisli p b f a |
contravariant in a |
Contravariant, Divisible, Decidable |
All three are representationally identical (p a (f b)) and connected by isomorphisms.
Usage
When p is specialised to (->), extensive instances are derived via Star, ReaderT, Arrow.Kleisli, and Op:
import Data.Kleisli
-- Kleisli: use as a functor/monad in the result
k :: Kleisli (->) Int Maybe Int
k = fmap (+1) (Kleisli Just)
-- ProKleisli: use as a profunctor/arrow
p :: ProKleisli (->) Maybe Int Int
p = arr (+1) >>> arr (*2)
-- ContraKleisli: use as a contravariant functor
c :: ContraKleisli (->) Int Maybe Int
c = contramap (+1) (ContraKleisli Just)
Type aliases
Convenient aliases eliminate common type parameters:
type Kleisli' p a b = Kleisli p a Identity b -- no functor layer
type KleisliA a f b = Kleisli (->) a f b -- specialise profunctor to (->)
type KleisliA' a b = KleisliA a Identity b -- both specialised
Analogous aliases exist for ProKleisli and ContraKleisli.
Isomorphisms
Lens Isos convert between the three orderings:
_Kleisli_ProKleisli :: Iso (Kleisli p a f b) ... (ProKleisli p f a b) ...
_Kleisli_ContraKleisli :: Iso (Kleisli p a f b) ... (ContraKleisli p b f a) ...
Identity-eliminating isos map through the Identity wrapper:
kleisli' :: Iso (Kleisli' p a b) ... (p a b) ...
Design: types as interfaces
This library follows an approach where the data type is the API and
type-class instances are the interface. There are almost no top-level
functions — users interact with these newtypes entirely through standard
algebraic vocabulary (fmap, >>=, lmap, arr, contramap, divide,
etc.) that they already know.
How it works
A single representation (p a (f b)) is wrapped in three newtypes that differ
only in which type parameter is "outermost" — i.e. which parameter appears last
and therefore determines which type classes the newtype can inhabit. The
newtypes carry no runtime payload beyond what p a (f b) already is; they
exist solely to place the type in the right position for GHC's instance
resolution to fire.
The few top-level values that do exist cover the gaps that type classes cannot
express:
- Isomorphisms — there is no standard
IsomorphicTo class that yields both
directions, so _Kleisli_ProKleisli and friends are explicit Iso values.
- Natural-transformation maps (
hoistKleisli, pureKleisli, etc.) —
mapping over an inner functor layer is not captured by Functor (which
operates on the outermost parameter), so a small family of combinators is
provided.
Everything else is an instance.
Benefits
-
Vocabulary reuse. Users do not learn a new API. If you know Profunctor
you already know how to use ProKleisli; if you know Divisible you already
know how to use ContraKleisli. Every generic combinator, tutorial, and
intuition that applies to those classes applies here without modification.
-
Composability for free. Because the interface is expressed through
standard classes, values of these types compose with any other code written
against those same abstractions — lens combinators, servant routing,
contravariant logging, arrow notation, selective functors, and so on — with
no adapter or conversion layer.
-
Minimal surface area. A small export list means fewer names to remember,
fewer possible breakages across versions, and fewer degrees of freedom for
API design mistakes. The type and its instances are the documentation.
-
Derivability. DerivingVia lets GHC generate correct, law-abiding
instances mechanically from an existing carrier (Star, ReaderT,
Arrow.Kleisli, Op). The module author writes each instance at most once;
the compiler writes the rest.
Trade-offs
-
Discoverability. The real functionality lives in instance heads, not in a
list of named functions. Users must know (or look up) which class provides
the operation they want. Haddock instance lists help, but they are less
immediately scannable than a flat function list.
-
Type errors. When an instance is missing or a constraint doesn't match,
GHC's error messages refer to the class and the full newtype-wrapped type,
which can be verbose. Type aliases help at use sites but cannot simplify error
output.
-
Orphan-instance risk. If a user needs an instance that this library did
not provide, they cannot add it without creating an orphan (since both the
class and the type are defined elsewhere relative to the user). In practice
this is mitigated by providing instances densely — if a lawful instance
exists, this module should already have it.
All three newtypes are strict newtypes — they vanish at runtime under GHC's
optimiser. The key mechanisms:
-
Newtype coercion. GHC represents Kleisli (->) a f b identically to
a -> f b in memory. Wrapping, unwrapping, and converting between the three
newtypes via the provided isos are zero-cost (they compile to id/coerce
after optimisation).
-
INLINE pragmas. Every top-level combinator and every hand-written instance
method carries an INLINE pragma, ensuring that after inlining the only code
that remains is the user's function and the functor operations — no dictionary
passing, no wrapper allocation.
-
DerivingVia and specialisation. Instances derived via Star, ReaderT,
or Arrow.Kleisli are thin wrappers around those types' methods. With
optimisation enabled (-O1 or above), GHC specialises and inlines through
the via-type, eliminating it entirely. The generated Core is the same as if
the instance had been written by hand.
-
No runtime indirection. Because there is no existential, no GADT, and no
dictionary field stored in the type, using these newtypes has exactly the same
runtime cost as using the underlying p a (f b) directly. The abstraction is
purely a compile-time mechanism for selecting instances.
In summary: you pay nothing at runtime for the newtype layer. The only cost is
at compile time — more instances means more constraint solving — but in practice
this is negligible.
Building
cabal build
cabal test doctest
cabal bench