Type Class Patterns and Anti-patterns by@jonathangfischoff
2,928 reads

Type Class Patterns and Anti-patterns

Read on Terminal Reader

Too Long; Didn't Read

Company Mentioned

Mention Thumbnail
featured image - Type Class Patterns and Anti-patterns
Jonathan Fischoff HackerNoon profile picture


Jonathan Fischoff
react to story with heart

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](https://hackage.haskell.org/package/cassava-, [postgresql-simple](https://hackage.haskell.org/package/postgresql-simple), among others.

In general the To class looks like:

class ToBlah a wheretoBlah :: a -> Blah

Although it can also have the form:

class ToBlah a wheretoBlah :: a -> BlahBuilder

Here BlahBuilder is a more efficiently composable intermediate version of Blah (this is the case when converting to ByteStrings via Builders).

The From type classes tend to look like:

class FromBlah a whereparseBlah :: 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](https://hackage.haskell.org/package/aeson- function in aeson and the [query](https://hackage.haskell.org/package/postgresql-simple- 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](https://hackage.haskell.org/package/yesod-core-1.4.33/docs/Yesod-Core-Content.html#t: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 wheretoJSON 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 -> ValuebigRecordToJSON BigRecord {..} = object[ ("fiesta-policy" . , listToJSON policyToJSON fiestaPolicies), ("brunch-menu" , brunchMenuToJSON brunchMenu), ("dinner-menu" , dinnerMenuToJSON dinnerMenu), ("background-music", maybeToJSON (eitherJSON screamingManToJSONplaylistToJSON)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 -> ValuebigRecordToJSON 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 screamingManToJSONplaylistToJSON)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](https://hackage.haskell.org/package/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 and FromJSON instances for Maybe do not roundtrip, when encoding and then decoding a Just Nothing. I think the same is true with postgresql-simple’s ToField instance for Maybe. 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!


. . . comments & more!
Hackernoon hq - po box 2206, edwards, colorado 81632, usa