I have been working lately on a web API where a JSON field of type string is parsed and then later stored to Google Firestore. For JSON de-/encoding I use aeson, for interaction with Firestore gogol-firestore both work as expected, but there is an odd thing:
module TextualRepresentationWithWitch.Example1 where
import qualified Data.Aeson as A
import qualified Data.Text as T
import qualified Network.Google.FireStore as F
import Control.Lens ((&), (?~))
data Pet = Cat
| Dog
deriving (Eq, Show)
instance A.ToJSON Pet where
Cat = A.String "cat"
toJSON Dog = A.String "dog"
toJSON
instance A.FromJSON Pet where
= A.withText "Pet" $
parseJSON case
\"cat" -> pure Cat
"dog" -> pure Dog
-> fail $ "Not a known pet: " <> show s
s
-- | Turn something into a field of a 'F.Document'.
class ToField a where
toField :: a -> F.Value
instance ToField T.Text where
= F.value & F.vStringValue ?~ v
toField v
instance ToField Pet where
Cat = toField @T.Text "cat"
toField Dog = toField @T.Text "dog"
toField
We have the strings for textual representation, like "cat"
, repeated three times! If I want to
read things back from Firestore I will have to do this once more. This is error prone: As I want the
textual representation of a Pet
to be the same in all places I have to remind myself somehow when
I update one instance to also update the other. This seems to sound like a minor issue in this case,
but it turns nasty as we add more pets or need a textual representation of a Pet
in more places.
The first thing we can do about this, obviously, is to have one common place where a Pet
is turned
into its textual representation:
petAsString :: Pet -> T.Text
Cat = "cat"
petAsString Dog = "dog" petAsString
then the ToField Pet
instance becomes toField = toField . petAsString
and ToJSON Pet
becomes
toJSON = A.String . petAsString
. That addresses the issue in one direction, going from Pet
to
textual. What about the other direction, from textual to Pet
? We face a problem here – as
conversion from a string to a Pet
object is partial: What is the right way to fail?
The rather fresh witch library provides type classes for exactly these two kinds of conversions. Namely From for a conversion which is total and TryFrom for a partial conversion. Lets see what the code looks like if we use it:
module TextualRepresentationWithWitch.Example2 where
import qualified Data.Aeson as A
import qualified Data.Text as T
import qualified Network.Google.FireStore as F
import qualified Witch
import Control.Lens ((&), (?~))
import Data.Proxy (Proxy(..))
import Data.Typeable (Typeable, typeRep)
data Pet = Cat
| Dog
deriving (Eq, Show)
instance Witch.From Pet T.Text where
Cat = "cat"
from Dog = "dog"
from
instance Witch.TryFrom T.Text Pet where
= Witch.maybeTryFrom convert
tryFrom where
"cat" = pure Cat
convert "dog" = pure Dog
convert = Nothing
convert _
instance A.ToJSON Pet where
= A.String . Witch.from
toJSON
instance A.FromJSON Pet where
= A.withText "Pet" failFrom
parseJSON
instance Witch.From T.Text F.Value where
= F.value & F.vStringValue ?~ v
from v
failFrom :: forall tgt src m. (Typeable tgt, MonadFail m, Witch.TryFrom src tgt)
=> src -> m tgt
= case Witch.tryFrom source of
failFrom source Right target -> pure target
Left _ -> fail $ "Not a valid " <> show (typeRep (Proxy @tgt))
There are two things to note here. First, it looks way more complicated, doesn’t it? But that’s
actually just because of failFrom
, right? We look at that in a second. Furthermore, the ToField
class is gone. That is because we really do not need it anymore as we have a better abstraction now:
We just told witch
how to represent something of type T.Text
as a F.Value
. From now on we can
simply use this:
> Witch.via @T.Text Cat :: F.Value
Value' { …, _vStringValue = Just "cat", … }
and it will work for everything we have a textual representation for. You can also use the above and
wrap it up in a Witch.From Pet F.Value
instance.
Now for that weird looking thing called failFrom
: The complicated type signature simply stems from
the fact that I wanted a nice representation of the type I want to convert into:
> A.eitherDecode "\"cat\"" :: Either String Pet
Right Cat
> A.eitherDecode "\"lion\"" :: Either String Pet
Left "Error in $: Not a valid Pet"
We could have written this in a much lighter way:
failFrom2 :: (MonadFail m, Witch.TryFrom src tgt) => src -> m tgt
= either (const (fail "Not a valid value")) pure . Witch.tryFrom failFrom2
but that would just give us "Error in $: Not a valid value"
as error message.
As I introduced witch
I encountered more cases where I was actually just converting objects from
one representation into another one, either by introducing extra functions like petAsString
or by
using type classes like ToField
. And these cases were not just about textual representation.
Using witch
simplified things a lot for two reasons. First, I don’t have to remember or look up
the names of these conversion functions anymore. It is all just Witch.from
. Second, there is a
common interface now how to do conversions. I use this as a base. With some tooling for the
specific problem domain, like failFrom
for conversions to aeson
instances, writing instances
become one-liners, is really easy and coherent.