Story
The initial idea for this library stemmed from the pipes library. The
interfaces of a pipes Proxy are given by four types: What the proxy sends
upstream, receives from upstream, sends downstream, and receives from
downstream. A value of type Proxy a' a b' b m r
is depicted in the pipes
documentation as follows:
Upstream | Downstream
+---------+
| |
a' <== <== b'
| |
a ==> ==> b
| | |
+----|----+
v
r
A pipes server (focusing on downstream side) can accept exactly one of request
(b'
) and issue exactly one type of response (b
). The response type cannot
vary based on what type of request it is responding to.
When interfaces are described by a type constructor rather than a pair of
types, then we can pair types of requests with appropriate responses. I first
saw this approach in the haxl library. The Facebook example in haxl's
documentation gives this example of what it calls a "data source API":
data FacebookReq response =
(response ~ Object) => GetObject Id
| (response ~ User) => GetUser UserId
| (response ~ [Friend]) => GetUserFriends UserId
The kind of FacebookReq
is Type -> Type
. The phantom type variable
response
here indicates what type of response a data source must give in
response to a FacebookReq response
. For example, the GetUser
constructor
produces a request of type FacebookReq User
, and so the data source returns a
value of type User
.
This pattern can also be found in the effectful library, where a similar sort
of type constructor is called a "dynamic effect". To take just one example of a
dynamic effect that the library offers, its dynamic State effect (analogous to
StateT of the transformers library) is defined as follows:
data State s m response =
(response ~ s) => Get
| (response ~ ()) => Put s
| (response ~ a) => State (s -> (a, s))
| (response ~ a) => StateM (s -> m (a, s))
The kind of State s m
is, again, Type -> Type
, and a constraint on each
constructor specifies what type of corresponding response is expected.
Let us return our attention to the pipes Proxy type. When we attempt to alter
Proxy
to employ the type constructor interface style as discussed in the
previous two examples, it becomes apparent that much of the generality and
symmetry that the pipes library enjoys must be sacrificed. Our library has only
"pull" streams, in which a vendor's action is always a response to a downstream
request. Pipes discusses "push" streams, in which upstream actors may take
initiative, and we offer nothing of the sort.
Also unlike pipes, we have separate Vendor
and Job
types, whereas pipes
unifies upstream and downstream ideas into a single type called Proxy
, of
which Producer
and Consumer
are merely aliases with a few of the type
parameters fixed. Although we have opted not to to so, it would be possible for
supply-chain to mimic the pipes design, observing that loop
and once
demonstrate an isomorphism between Vendor up (Unit product) action
and
Job up action product
and that would permit unifying Vendor
and Job
in
the pipes style.
loop :: Job up action product -> Vendor up (Unit product) action
once :: Vendor up (Unit product) action -> Job up action product
This approach is tempting chiefly because it permits a single connection
operator (which pipes calls >->
) where supply-chain has two (>->
and >-
).
But in our library, this unification appears to cause more problems than it
solves, and type aliases in general tend to result in poor developer
experiences.
I also explored the possibility of defining a pipes-like unified >->
operator
as a multi-parameter typeclass method, with one instance for vendor-to-vendor
connection and another for vendor-to-job connection. This can be made to work,
and with the help of functional dependencies can even lend itself to
sufficient type inference despite a large number of type parameters involved. In
practice, however, the complication that the polymorphism introduces to type
errors and typed hole feedback outweighs the benefit of an overloaded connection
operator.
Another difference between supply-chain and pipes is that Vendor
cannot
return. The ability of a Producer
or Pipe
to return is a point of difficulty
for me as a pipes user, because in a pipes chain a >-> b >-> c
very rarely is
there any reason why a
or b
would ever return and "shut the pipeline down
early". The advantage of this approach in pipes is that it allows Producer
(being simply an alias for Proxy
) to be monad, whereas to define a Vendor
one must take the additional step of applying the Vendor
constructor to a
function whose actual monadic context is Job
. The advantage of the
supply-chain approach is that the type signatures for vendors are not burdened
with an irrelevant type parameter representing the return type of something that
will never actually return.
The influence of the effectful library, in spirit if not manifest in any
concrete technique, deserves remark. A job's upstream interface and action
context correspond to what the effectful library calls "dynamic effects" and
"static effects" respectively. Our (>-)
function corresponds roughly to what
effectful describes as "reinterpeting" the job's upstream interface.
What effectful has that supply-chain lacks is its mechanism for combining
multiple interfaces (which they call "effects") via the type-level list which
parameterizes the Eff type.
data Eff (es :: [Effect]) a
type Effect = (Type -> Type) -> Type -> Type
I have left this feature out because I do not currently have any pressing need
for it, I find it too difficult to use, and I am not convinced that it needs to
be built into this library. It is possible to express the combination of two
supply-chain interfaces as:
data Either interface1 interface2 response =
Left (interface1 response) | Right (interface2 response)
Either
may be nested to produce interface combinations larger than two.
Although this approach is cumbersome, it does suggest that more clever
conveniences could be designed.