Type Class Patterns and Anti-patterns
In a prior post I wrote about how type class instance selection worked. To help get a sense of good type class design, I want to walk through a type class pattern and a related type class anti-pattern.
To/From Type Classes
The first pattern is the To
and From
type classes. This pattern shows up in many packages: aeson
, cassava
, postgresql-simple
, among others.
In general the To
class looks like:
class ToBlah a where
toBlah :: a -> Blah
Although it can also have the form:
class ToBlah a where
toBlah :: a -> BlahBuilder
Here BlahBuilder
is a more efficiently composable intermediate version of Blah
(this is the case when converting to ByteString
s via Builder
s).
The From
type classes tend to look like:
class FromBlah a where
parseBlah :: Parser a
The Parser
type typically exposes a Monad
interface with some type of state and a way to short-circuit parsing to produce an error message. There tends to be some API function that uses the parsers (this is the decode
function in aeson
and the query
function in postgresql-simple
), and they are only used directly to compose other parsers.
Sometimes, both To
and From
classes are provided; other times, as in the case of the Yesod.Core.Content.ToContent
, only one direction is needed.
In general, this pattern is used for converting between user-defined types and a particular type which an API is built around.
Why the To Pattern is Good
Let’s look at an example with aeson
’s ToJSON
, which is used to convert a user’s type to a Value
or JSON expression type.
If I have a type,
data BigRecord = BigRecord
{ fiestaPolicies :: [Policy]
, brunchMenu :: Menu
, dinnerMenu :: Menu
, backgroundMusic :: Maybe (Either ScreamingMan [Song])
}
the ToJSON
instance for types like this tend to be boring like*:
instance ToJSON BigRecord where
toJSON BigRecord {..} = object
[ ("fiesta-policy" , toJSON fiestaPolicies )
, ("brunch-menu" , toJSON brunchMenu )
, ("dinner-menu" , toJSON dinnerMenu )
, ("background-music", toJSON backgroundMusic)
]
Boring is good. Boring means predictable. Boring means I don’t have to think hard when writing or reading it.
Additionally, type classes allow us to write a single function for polymorphic types like Maybe a
, and Either a b
. The instances for the a
’s and b
’s will automatically get used correctly.
Type classes provide canonicity, there is only one instance for a type. For conversions that only have a single valid conversion per type, To
classes are a good fit. The To
type class pattern takes advantage of identifier overloading and canonicity to reduce the degrees of freedom when writing instances.
Freedom isn’t Free
If I don’t use the To
type class pattern, I need to make more choices. Every time I make a choice, I increase the chance I will make the wrong choice.
Here is one way we can write conversion code like the ToJSON
example:
bigRecordToJSON :: BigRecord -> Value
bigRecordToJSON BigRecord {..} = object
[ ("fiesta-policy" . , listToJSON policyToJSON fiestaPolicies)
, ("brunch-menu" , brunchMenuToJSON brunchMenu)
, ("dinner-menu" , dinnerMenuToJSON dinnerMenu)
, ("background-music", maybeToJSON (eitherJSON screamingManToJSON
playlistToJSON
)
backgroundMusic
)
]
Even though the conversions are completely determined by the types, I have to choose the correct functions for the conversion. Also, I have to pass an additional function to every polymorphic conversion function — one function per type variable, in fact.
This code is the best-case scenario. I have decomposed my conversion functions predictably and used a naming convention. I have not always found myself in such a lucky situation.
Since there is less incentive to decompose the code into small functions, it could also look like this:
bigRecordToJSON :: BigRecord -> Value
bigRecordToJSON BigRecord {..} = object
[ ("fiesta-policy" , Array
$ Vector.fromList
$ map policyToJSON fiestaPolicies
)
, ("brunch-menu" , Array
$ Vector.fromList
$ map brunchMenuItem brunchMenu
)
, ("dinner-menu" , Array
$ Vector.fromList
$ map dinnerMenuItem dinnerMenu
)
, ("background-music", maybe (object [])
(eitherJSON screamingManToJSON
playlistToJSON
)
backgroundMusic
)
]
For both of these versions, I added potential issues. If you haven’t already found the problem areas, see if you can.
If one does not use a type class, they give themselves more freedom than they potentially need. This opens the door for errors, inconsistency and confusion.
Roundtrip Anti-Pattern
Sometimes a single type class is used that has both a to
and from
method.
This is the case with the Binary
type class from the binary
package. There are no functions in the binary
API that require having both directions specified, but I have to implement both directions anyway.
If I am writing a client that sends data to a server, it is likely I will only serialize datatypes and never deserialize them. I will still need an implementation for deserializing, so I’ll create a dummy one, or throw an error.
A meaningless method implementation is a maintenance hazard. A programmer in the future might assume the instance was completely implemented and become confused when deserialization fails.
Put a Law on It
The argument for forcing the implementation of Binary
to have both to
and from
might be it allows one to require the law decode . encode = id
, which Binary
documents as a requirement.
A law is useful as a specification. It can also be useful for equational reasoning, which means I can replace decode . encode
with id
. This is a very useful optimization … if one was ever to come across an actual use of decode . encode
.**
Except I don’t think this will happen in practice. Encoding and decoding are part of totally different code paths.
Why it is Bad
In practice, what happens is you have a type class that sometimes has meaningless implementations for methods, with an API that doesn’t take advantage of both directions, with a law that is not particularly useful for equational reasoning.
From the this vantage point, I believe roundtrip type classes are many times an anti-pattern. Instead of providing programs with more reasoning power, they provide less because they are abused. They are trying to encourage a best-practice that is not always applicable. They don’t fit the problem they are used to solve.
If our type should roundtrip from the to
and from
directions, we should do the same thing we do with our laws: document it and test it.***
There are cases where it does make sense to have both a to
and from
in a single type class, but lacking a compelling reason (like a shared associated type, or an API function that needs both directions for a single type), including them together is a mistake.
Lawless Good
For cases where there is single mapping between a type and a conversion function, To
and From
type classes can be used to compose easy-to-reason-about functions. Avoiding type classes results in conversion functions that accept an additional function for elements (think of the eitherToJSON
example earlier), or some bespoke process that can be inconsistent. To
and From
instances implicitly use unique and correct implementations. This process automatically prevents against a class of incorrect implementations and encourages correctness purely through composition.
In the short, the To
and From
type class are simple and powerful type class patterns you should not be afraid to use.
* For the aeson
smarties, I’m avoiding the .=
method because most To
classes don’t have something analogous, and this makes the general pattern more obvious.
** Not only can equational reasoning be used for rewriting expressions to simplify them, it can also be used to expand expressions and derive functions. I have not witnessed Haskellers other than Conal doing this however.
- ** Cale Gibbard, in addition to helping me refine my ideas, pointed out the
ToJSON
andFromJSON
instances forMaybe
do not roundtrip, when encoding and then decoding aJust Nothing
. I think the same is true withpostgresql-simple
’sToField
instance forMaybe
. Ideally, this should be more clearly documented, since Haskeller’s tend to assume these instances do roundtrip.
Hacker Noon is how hackers start their afternoons. We’re a part of the @AMIfamily. We are now accepting submissions and happy to discuss advertising & sponsorship opportunities.
To learn more, read our about page, like/message us on Facebook, or simply, tweet/DM @HackerNoon.
If you enjoyed this story, we recommend reading our latest tech stories and trending tech stories. Until next time, don’t take the realities of the world for granted!