Can i haz? Part 3: extending the Has pattern
Once we scrapped the boilerplate for
the Has
pattern, the next obvious question is if we can
generalize
further. And, turns out, we can!
In this post we’ll see how some algebraic considerations help us to discover one more pattern
useful with MonadError
(and a Generic
implementation thereof),
and we’ll also update our Has
class with one more method that brings it closer to
something lens-like and makes it useful with writable environments like MonadState
.
Inverting the arrows
Given some structure, a true algebraist (which I’m not by any means) always wonders what happens if one turns the arrows in that struсture, obtaining a categorical dual.
In this particular case we have a single arrow extract
in the structure Has
:
class Has part record where
extract :: record -> part
Let’s invert it and prepend co
everywhere:
class CoHas copart corecord where
coextract :: copart -> corecord
You might already have an intuition about what CoHas
might be,
but let’s proceed turning the arrows based on pure formalism for now.
Path
and Combine
are boring and have no good applicable structure,
so we leave them as is. Search
, on the other hand, is more interesting.
Recall the definition we used when we built the generic implementation for Has
:
type family Search part (grecord :: k -> *) :: MaybePath where
Search part (K1 _ part) = 'Found 'Here
Search part (K1 _ other) = 'NotFound
Search part (M1 _ _ x) = Search part x
Search part (l :*: r) = Combine (Search part r) (Search part r)
Search _ _ = 'NotFound
Note the next-to-last case, which mentions f :*: g
. The dual of a product is a sum,
so the object dual to Search
would be:
type family CoSearch copart (gcorecord :: k -> *) :: MaybePath where
CoSearch copart (K1 _ copart) = 'Found 'Here
CoSearch copart (K1 _ other) = 'NotFound
CoSearch copart (M1 _ _ x) = CoSearch copart x
CoSearch copart (l :+: r) = Combine (CoSearch copart l) (CoSearch copart r)
CoSearch _ _ = 'NotFound
Generic GCoHas
follows the same pattern:
class GCoHas (path :: Path) copart gcorecord where
gcoextract :: Proxy path -> copart -> gcorecord p
And so do the instances:
instance GCoHas 'Here corecord (K1 i corecord) where
gcoextract _ = K1
instance GCoHas path copart corecord => GCoHas path copart (M1 i t corecord) where
gcoextract proxy = M1 . gcoextract proxy
instance GCoHas path copart l => GCoHas ('L path) copart (l :+: r) where
gcoextract _ = L1 . gcoextract (Proxy :: Proxy path)
instance GCoHas path copart r => GCoHas ('R path) copart (l :+: r) where
gcoextract _ = R1 . gcoextract (Proxy :: Proxy path)
Note that nothing happens with the position of Proxy
here, which totally makes sense:
Proxy
is only used as a hint to the type checker to help deducing the right type,
and it doesn’t bear any deep semantics within the structure we’re considering.
The implementation of the type class is obvious given the above:
class CoHas copart corecord where
coextract :: copart -> corecord
default coextract :: forall path. (Generic corecord, SuccessfulSearch copart corecord path) => copart -> corecord
coextract = to . gcoextract (Proxy :: Proxy path)
Interpretation
Now let’s think about the meanings.
record
is some product type,corecord
is dual to it, so it’s some sum type. Indeed,sum
might be a better name for it.part
is a field in therecord
,copart
is dual to it, so it’s one of the options in thesum
. Indeed,option
might be a better name for it.extract
takes arecord
and produces apart
of thatrecord
.coextract
, on the other hand, takesoption
and produces asum
. One obvious interpretation (that also follows the implementation) is thatcoextract
just creates a value of thesum
type given one of theoption
s. Say,inject
might be a better name for it.
So, to sum it up: CoHas option sum
means that we can inject
values
of type option
into the sum
type.
We have Either
as a prime example:
instance SuccessfulSearch a (Either l r) path => CoHas a (Either l r)
So, if l
is not the same as r
, we can create Either l r
out of l
(and out of r
, of course)
via the CoHas
class and its inject
method.
Yes, we also have the same type safety and soundness guarantees:
this CoHas option sum
is derivable (via Generics
)
iff there is one and only one way to construct sum
set to the option
.
So, for example, the following won’t work:
data AppError = QaDbError DbError
| ProdDbError DbError
deriving (CoHas DbError)
Where could CoHas
be useful? One might consider
CoHas
and MonadError
We write MonadReader r m, Has FooEnv r
to denote that
the environment r
contains a value of type FooEnv
.
Similarly we might write MonadError e m, CoHas FooErr e
to denote
that the error context could be used to report about errors of type FooErr
.
This again might help modularity: assume our good old modules we considered earlier (the DB layer, the web server and the cron scheduler) might each report an error of their own type. Then we could have an application-level error type
data AppError
= AppDbError DbError
| AppWebServerError WebServerError
| AppCronError CronError
deriving (Generic, CoHas DbError, CoHas WebServerError, CoHas CronError)
that could be used to collect errors from all of the components,
just as we used AppConfig
to provide the configuration for all of the components at once.
Bottom line: using purely algebraic considerations, we arrived at yet another useful (and free, in a sense) generalization of our approach!
Updating record
s
Alright, back to the Has
class.
We’ve learned how to extract the values of some type part
out of a record
containing them.
What else can we do?
Let’s say we have a function f :: part -> part
.
Then we can obtain a function transforming record
s “for free”,
merely by applying f
to the part
contained in the record
:
update :: (part -> part) -> record -> record
If you’re familiar with the concept of the lenses, you’ve quite likely noticed that
update
paired with extract
essentially allows defining one’s own lenses,
and this is not a coincidence, but we will not delve too deep into that.
Anyway, after all our exercises with extract
and inject
/coextract
the generic code for update
is a routine.
First we add the gupdate
method to the GHas
helper class.
The only real difference is that we get the generic representation back
as opposed to a “real” type for gextract
:
class GHas (path :: Path) part grecord where
...
gupdate :: Proxy path -> (part -> part) -> grecord p -> grecord p
Then we add the implementation of gupdate
to the instances
of GHas
we already had, keeping in mind we should produce
the appropriate Generic
value:
instance GHas 'Here rec (K1 i rec) where
...
gupdate _ f (K1 x) = K1 $ f x
instance GHas path part record => GHas path part (M1 i t record) where
...
gupdate proxy f (M1 x) = M1 (gupdate proxy f x)
instance GHas path part l => GHas ('L path) part (l :*: r) where
...
gupdate _ f (l :*: r) = gupdate (Proxy :: Proxy path) f l :*: r
instance GHas path part r => GHas ('R path) part (l :*: r) where
...
gupdate _ f (l :*: r) = l :*: gupdate (Proxy :: Proxy path) f r
Our final step is to add the corresponding update
method to Has
and write a default implementation for it:
class Has part record where
...
update :: (part -> part) -> record -> record
default update :: forall path. (Generic record, SuccessfulSearch part record path) => (part -> part) -> record -> record
update f = to . gupdate (Proxy :: Proxy path) f . from
And that’s it! Easy-peasy.
update
and MonadState
extract
has a nice use with MonadReader
:
we replace MonadReader Foo m
with (MonadReader r m, Has Foo r)
and use extract
appropriately in the bodies of the corresponding functions.
What uses does update
have?
Not much with MonadReader
, but there is a very similar monad that supports modifications: MonadState
.
So, now instead of MonadState FooState m
we also write (MonadState s m, Has FooState s)
,
obtaining nice composable and reusable stateful functions!
What we’ve achieved is basically a generalizaton of the Has
pattern to writable environments!
It’s also tempting to reverse the arrows once again and seeing what happens with update
,
but that requires some less trivial math, so we’ll leave that out for another post. Stay tuned!