(I've edited my answer to have significantly more explanations. If you just want a module containing the code, it's still available.
The question aims at changing the default Show
and Read
instances for
enumeration types such as OrderType
and providing custom ones. I'll show
below how that can be done, although in principle I advise against doing so,
because Show
and Read
are typically supposed to produce Haskell
representations of the values. I'll also suggest a different solution,
however, by going via new type classes.
My solution is similar to the one proposed by Li-yao Xia, but based on generics-sop rather than built-in GHC generics.
We are using the following module header.
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
module CustomShowEnum where
import Data.Aeson
import Data.Aeson.Types
import Data.Maybe
import Generics.SOP
import Generics.SOP.NS
import Generics.SOP.TH
import Text.Read
Let's start with a function that computes a product (a list
with a statically known number of elementS) of all the
constructor names.
conNames ::
forall a proxy .
(Generic a, HasDatatypeInfo a)
=> proxy a -> NP (K String) (Code a)
conNames _ =
hmap
(K . constructorName)
(constructorInfo (datatypeInfo (Proxy @a)))
The datatypeInfo
function provides all meta-information
about a given datatype, the constructorInfo
function extracts
from that a product with meta-information about each
constructor. We are only interested in the names, nothing else,
so we use hmap
over the product to extract the constructor
names in each position.
Let's see how we can use it:
GHCi> conNames (Proxy @Bool)
K "False" :* (K "True" :* Nil)
Read Nil
as the empty product, and :*
as "cons". Each element
is wrapped in a K
constructor, because it is a product containing
a (constant) String for every constructor of the datatype.
The same works on other datatypes:
GHCi> conNames (Proxy @Ordering)
K "LT" :* (K "EQ" :* (K "GT" :* Nil))
GHCi> conNames (Proxy @(Maybe ()))
K "Nothing" :* (K "Just" :* Nil)
We can also make it work on the OrderType
mentioned in the question:
data OrderType = Confirmed | AwaitingShipping | Shipped
But if we try this blindly, then we get an error that we have no
instances for the Generic
and HasDatatypeInfo
classes. For
generics-sop functions to work, types must be an instance of these
classes. One way to achieve this is to use Template Haskell:
deriveGeneric ''OrderType
(Another way for people who dislike Template Haskell is mentioned in
the library documentation.)
Now, we can use conNames
:
GHCi> conNames (Proxy @OrderType)
K "Confirmed" :* (K "AwaitingShipping" :* (K "Shipped" :* Nil))
A variant of this is a function that takes a specific value, and computes
the outermost constructor that built that value.
conName ::
forall a .
(Generic a, HasDatatypeInfo a)
=> a -> String
conName x =
hcollapse
(hzipWith
const
(conNames (Proxy @a))
(unSOP (from x))
)
Here, we use from
to compute the generic representation of the given
value, which is a sum of products. The sum encodes a choice between one
of the constructors of the datatype. We can use hzipWith
to combine a
compatible product (a product of n values) and sum (a choice of option
i out of n possible options), and it will select the i'th position of
the product and combine the two. By using const
to combine the two,
the effect is that we'll just return the constructor name corresponding
to the given constructor from our conNames
product. The hcollapse
application in the end extracts the single String
value.
Let's look at a number of examples again:
GHCi> conName Confirmed
"Confirmed"
GHCi> conName (Just 3)
"Just"
GHCi> conName [1,2,3]
":"
Note that in the last example, the list is, on the top-level, just an
application of cons.
Next, we define a function enum
that computes a product of all
the values of an enumeration type. This is similar to conNames
,
but rather than returning the constructor names (as strings), we
return the actual constructors.
enum ::
forall a .
(Generic a, HasDatatypeInfo a, All ((~) '[]) (Code a))
=> NP (K a) (Code a)
enum =
hmap
(mapKK to)
(apInjs'_POP (POP (hcpure (Proxy @((~) '[])) Nil)))
The apInks'_POP
function produces a product of all the constructor
functions in their generic representation. These still have to be
applied to a representation of their arguments, and we need to provide
these arguments as a product of products (a two-dimensional table
with one row per constructor, each row containing the arguments
to be applied to that particular constructors).
Fortunately, we restrict ourselves to enumeration types here. These
are types without any constructor arguments. This is expressed
by the constraint All ((~) '[]) (Code a)
. The code of a type is
a list of list of types. The outer list contains an entry per
constructor, the inner lists give the types of the constructor
arguments. The constraint states that each of the inner lists must
be empty, which is equivalent to each of the constructors having
no arguments.
We can therefore produce a product of empty argument lists,
which is what we do via POP (hcpure (Proxy (@((~) '[])) Nil))
.
Finally, we use hmap
with to
to turn each of the constructed
values back from their generic representation into their original
shape.
Let's look at examples:
GHCi> enum @Bool
K False :* (K True :* Nil)
Compare this again with
GHCi> conNames (Proxy @Bool)
K "False" :* (K "True" :* Nil)
and note that in one case, we return strings, in the other, we
return actual values.
GHCi> enum @Ordering
K LT :* (K EQ :* (K GT :* Nil))
If we try to apply enum
to a type that is not an enumeration
type, we get a type error.
If we try to apply enum
to OrderType
, we get an error that a
Show
instance for OrderType
is lacking.
If we derive one via
deriving instance Show OrderType
we obtain:
GHCi> enum @OrderType
K Confirmed :* (K AwaitingShipping :* (K Shipped :* Nil))
If we use the custom Show
instance that was desired in the
question and that I show how to define below, we instead get
GHCi> enum @OrderType
K confirmed :* K awaiting_shipping :* K shipped :* Nil
This also demonstrates why it might not be such a good idea
to change the instance, because we now see that the output of
show
used by GHCi to print back the result mixes standard
Haskell notation with special notation intended to be used
in a particular domain.
Before we go there, however, let's first implement one final
utility function that we'll need for the parsing direction:
conTable ::
forall a .
(Generic a, HasDatatypeInfo a, All ((~) '[]) (Code a))
=> [(String, a)]
conTable =
hcollapse
(hzipWith
(mapKKK (,))
(conNames (Proxy @a))
enum
)
The conTable
function computes a lookup table associating
the string constructor names with the actual values. We have
the functions to compute the two products, conNames
and
enum
. We use hzipWith
with (,)
to pair them. The result
is another product, but because the product contains the same
type in every position, we can use hcollapse
to turn it into
a normal Haskell list.
GHCi> conTable @Bool
[("False", False), ("True", True)]
GHCi> conTable @Ordering
[("LT", LT), ("EQ", EQ), ("GT", GT)]
GHCi> conTable @OrderType
[("Confirmed", Confirmed), ("AwaitingShipping", AwaitingShipping), ("Shipped", Shipped)]
The final example is using the default / derived Show
instance.
With these ingredients in place, we are now able to implement
custom show
and read
replacements for enumeration types.
The show
direction quite easy:
customShowEnum ::
forall a .
(Generic a, HasDatatypeInfo a, All ((~) '[]) (Code a))
=> (String -> String)
-> a -> String
customShowEnum f = f . conName
Given a value, we use conName
to determine its constructor and
then apply the given conversion function to the result.
This function would work for all types in Generic
and
HasDatatypeInfo
, so the All ((~) '[]) (Code a)
constraint
that restricts it to enumeration types is optional.
Here are a few examples:
GHCi> customShowEnum id AwaitingShipping
"AwaitingShipping"
GHCi> customShowEnum reverse Confirmed
"demrifnoC"
GHCi> customShowEnum (camelTo2 '_') AwaitingShipping
"awaiting_shipping"
For the read
replacement, we implement a function that can
be used to defined the readPrec
method of the Read
class.
This produces a parser of type ReadPrec a
:
readPrec :: Read a => ReadPrec a
The basic strategy is as follows: We start from the lookup table
given by conTable
. We adjust the strings in this lookup table
using the same conversion function that we also used in
customShowEnum
. Given an input string, we then try to find
it in the adjusted lookup table, and if we find it, we return
the associated value. The code looks as follows:
customReadEnum ::
forall a .
(Generic a, HasDatatypeInfo a, All ((~) '[]) (Code a))
=> (String -> String)
-> ReadPrec a
customReadEnum f =
let
adjustedTable :: [(Lexeme, a)]
adjustedTable = map (\ (n, x) -> (Ident (f n), x)) conTable
in
parens $ do
n <- lexP
maybe pfail return (lookup n adjustedTable)
This essentially follows the description above: parens
additionally allows the constructor name to be wrapped in
parentheses, as is generally allowed by read
, and the use
of lexP
additionally handles whitespace. If the lookup
in the table fails, we let the parser fail using pfail
.
If we want to try this, we have to run the ReadPrec
parser
by applying readPrec_to_S
, which then expects a precedence
level (irrelevant in this case) and an input string and returns
a list of pairs containing pairs of possible parses and remaining
strings:
GHCi> readPrec_to_S (customReadEnum @OrderType id) 0 "AwaitingShipping"
[(AwaitingShipping, "")]
GHCi> readPrec_to_S (customReadEnum @OrderType (camelTo2 '_')) 0 "AwaitingShipping"
[]
GHCi> readPrec_to_S (customReadEnum @OrderType (camelTo2 '_')) 0 "awaiting_shipping"
[(AwaitingShipping, "")]
>>> readPrec_to_S (customReadEnum @OrderType (camelTo2 '_')) 0 " ( awaiting_shipping) "
[(AwaitingShipping, " ")]
If we now wanted to use customReadShow
and customReadEnum
to
define the Show
and Read
instance for OrderType
, we could do
this simply as follows:
instance Show OrderType where
show = customShowEnum (camelTo2 '_')
instance Read OrderType where
readPrec = customReadEnum (camelTo2 '_')
However, as I said above, I'd advise to just use the derived instances
here to avoid confusion, and for the domain-specific textual representation,
I would just introduce new classes, for example:
class ToString a where
toString :: a -> String
class FromString a where
fromString :: String -> Maybe a
instance ToString OrderType where
toString = customShowEnum (camelTo2 '_')
customFromString ::
forall a .
(Generic a, HasDatatypeInfo a, All ((~) '[]) (Code a))
=> (String -> String)
-> String -> Maybe a
customFromString f x =
case readPrec_to_S (customReadEnum f) 0 x of
[(r, "")] -> Just r
_ -> Nothing
instance FromString OrderType where
fromString = customFromString (camelTo2 '_')
We could also go a few steps further:
We could use default signatures that map toString
and fromString
either
to the default Show
/ Read
behaviour, or to our custom behaviour, so that
we can provide empty instances or using deriving in the more common of the
two cases.
We could use a third class to associate a particular conversion function with
a given type and use this class in our generic definitions, to make it more
obvious that the same function is used for both directions and thereby remove
a source of potential errors.
Show
? How about deriving the standardShow
, and then composingcamelTo2 '_' . show
? – Li-yao Xia May 26 at 5:12ToJSON
via the standardShow
instance and it works perfectly fine.instance ToJSON TxnSource where toJSON = toJSON.(camelTo2 '_').show
So, this technique can be used at other places as well. Any idea how to do this forFromJSON
? – Saurabh Nanda May 26 at 5:28read . toCamel . fromJSON
, except the last bit is actuallyparseJSON
and it is monadic:instance FromJSON TxnSource where parseJSON = fmap (read . toCamel) . parseJSON
. You need to definetoCamel
doing the inverse ofcamelTo2 '_'
. You probably want to usereadMaybe
too. – Li-yao Xia May 26 at 5:42FromJSON
use the samecamelTo
function during parsing? Here's theFromJSON
instance that does the camel<>Haskell conversion absolutely fine -- ` parseJSON = genericParseJSON $ (aesonPrefix snakeCase){constructorTagModifier=(camelTo2 '_')}` – Saurabh Nanda May 26 at 5:46