Please use Generically instead of DefaultSignatures!
For a long time, the Haskell library author's tool of choice to
provide default type class implementations based on Generic
was DefaultSignatures
. Since GHC 9.4.1 however, there is a
better way: the Generically
newtype that is in
base
.
What are
DefaultSignatures
?
Let's use the example of aeson
to encode a type in JSON.
We have a type class ToJSON
as well as a function
genericToJSON
that can be used to encode an arbitrary type
as long as it derives GHC.Generics
:
class ToJSON a where
toJSON :: a -> Value
genericToJSON :: (Generic a, GToJSON (Rep a)) => a -> Value
= -- redacted genericToJSON
However, to use genericToJSON
the user has to manually
write an instance of the ToJSON
class:
data MyData = MkMyData { foo :: Int }
deriving Generic
instance ToJSON MyData where
= genericToJSON toJSON
DefaultSignatures
allow the library author to provide a
default implementations that is only available if the type fulfils
additional constraints. In our case this addition constraint is that the
type implements Generic
. Together with
DeriveAnyClass
which will create an empty type class
instance, this makes it possible for the user to directly derive an
instance of ToJSON
:
-- Library.hs
{-# LANGUAGE DefaultSignatures #-}
class ToJSON a where
toJSON :: a -> Value
toJSON :: (Generic a, GToJSON (Rep a)) => a -> Value
default= genericToJSON
toJSON
-- User.hs
{-# LANGUAGE DeriveAnyClass #-}
data MyData = MkMyData { foo :: Int }
deriving (Generic, ToJSON)
The problems with
DefaultSignatures
In my opinion there are two issues here: 1) the reliance on
DeriveAnyClass
which introduces a footgun for the user and
2) DefaultSignatures
force the author to provide only one
way to derive instances
The DeriveAnyClass
extension allows the user to put any
type class in the deriving clause of any datatype, not just the stock
derivable classes like Show
or Read
. For
non-stock classes this deriving statement will create an empty
instance:
{-# LANGUAGE DeriveAnyClass #-}
data MyData = MkMyData { foo :: Int }
deriving (Generic, ToJSON)
-- equivalent to
data MyData = MkMyData { foo :: Int }
deriving Generic
instance ToJSON MyData
If you derive a class with no default implementation this will result
in a missing method. -Wmissing-methods
(part of
-Wall
) will warn about this case, but in my opinion it
still feels wrong. And I am not alone with this opinion, see for example
Richard
Eisenberg's video on the topic.
The other big issue with DefaultSignatures
is that there
can only be a single default implementation. In Haskell this is usually
an implementation based on GHC.Generics
. But even then
there might be several possible implementations to choose from. Maybe
the default genericToJSON
works for any type, but is not as
fast as it could be while a genericFlatToJSON
is faster,
but requires the type to be non-recursive. With
DefaultSignatures
choosing the second implementation will
require writing an instance manually again.
Another use case is if you are the author of another library that
provides encodings for several structured data formats. Your library
requires the user to provide an instance of an Encoding
class and they can use that to convert their datatypes to YAML, JSON and
more. Because aeson
is so prevalent in the ecosystem you
might want to allow the user of your library to derive an instance of
ToJSON
if they already implement Encoding
. But
because DefaultSignatures
are tied to the class declaration
(which sits outside your library, in aeson
) you are out of
luck.
DerivingVia to the rescue!
Instead of DeriveAnyClass
and
DefaultSignatures
your library can encourage the use of
DerivingVia
. The base
library provides the
Generically
newtype since GHC 9.4.1:
newtype Generically a = Generically a
While this type looks rather useless, it is meant for library authors
to provide type class instances based on GHC.Generics
:
class ToJSON a where
toJSON :: a -> Value
instance (Generic a, GToJSON (Rep a)) => ToJSON (Generically a) where
Generically x) = genericToJSON x toJSON (
The user can then use DerivingVia
to use this instance
for their type:
import GHC.Generics (Generic, Generically(..))
data MyData = MkMyData { foo :: Int }
deriving Generic
deriving ToJSON via (Generically MyData)
This deriving clause makes it clear that we are using
MyData
's Generic
instance to derive
ToJSON
. We could also have newtypes with custom
Generic
instance that e.g. convert fields to camel case,
and use them together with the Generically
newtype to
derive ToJSON
.
Going to the example from earlier with the Encoding
typeclass, we as author of that library could provide an
Encoded
newtype that then has the ToJSON
instance:
data DataRep = -- redacted
class Encoding a where
encode :: a -> DataRep
encodeJSON :: Encoding a => a -> Value
= -- redacted
encodeJSON
newtype Encoded a = MkEncoded a
instance Encoding a => ToJSON (Encoded a) where
= encodeJSON toJSON
A user can then use the same DerivingVia
clause to
derive ToJSON from the Encoding
instance.
data MyData = MkMyData { foo :: Int }
deriving Generic
deriving Encoding via (Generically MyData)
deriving ToJSON via (Encoded MyData)
With StandaloneDeriving
this could also be used to
derive ToJSON
without Generic
at all:
{-# LANGUAGE StandaloneDeriving #-}
data MyData = MkMyData { foo :: Int }
instance Encoding MyData where
= -- custom implementation
encode
deriving via (Encoded MyData) instance ToJSON MyData
Conclusion
If you did not know about Generically
I hope this
article was able to sufficiently explain the benefits of composability
of newtypes and DerivingVia
, especially across libraries.
If you are a library author yourself, see this article as a plea to
ditch DefaultSignatures
and provide instances for
Generically
and potentially other newtypes only. Then we
might be able to deprecate DeriveAnyClass
for good.