Haskell Opt-In Instances
When defining a type class in a library, it can be difficult to decide whether to include instances for common types. Providing instances can be convenient to users of the library, but it prevents users from writing instances with a different implementation. This article documents a third option, which allows users to “opt-in” to default instances with minimal boilerplate.
I first learned about this pattern from Miguel Mitrofanov on the Haskell cafe mailing list.
Motivation
GHC requires that it be unambiguous which instance declaration should be used to resolve a type-class constraint. It provides overlapping instances functionality so that the most specific of matching instances is used, but an instance for a distinct type effectively prevents users from using any other instance for that type.
Since all instances declared or loaded by a module are loaded when the module is imported, one should be mindful about declaring instances. It is often said that orphan instances should be avoided. Specifically, one should not declare instances that have more than one valid implementation in a shared library, so that a suitable instance can be declared in the application layer.
There are some cases where more than one instance implementation is possible but one of those definitions covers the vast majority of use cases. Since more than one instance implementation is possible, an instance should not be declared in the library. Users of the library must then declare the instances themselves, which has various drawbacks. It makes the library more difficult to use, it forces users to write more code (“boilerplate”), and copy-and-pasting the same code across projects is inelegant.
Implementation
Using the “opt-in instances” design pattern, libraries provide default instances for common types. Users of the library must declare which instances to load, but the cost is only one line of code per type.
The pattern is implemented for a type class (“primary”) by creating a separate type class (“secondary”) that is isomorphic to the primary type class. The instances are declared for the secondary type class, not the primary type class. The DefaultSignatures extension is used to define default instances for methods of the primary type class to call the matching methods of the secondary type classes when the type is an instance of the secondary type class.
Users load an instance for a specific type by writing an instance
declaration without a where
clause. Note that no extensions
must be loaded for this.
Case Study
The “opt-in instances” design pattern is used in the TTC library, for both
the Render
and Parse
type classes. The
implementation for the Render
type class is considered
here, to serve as a simple, concrete example.
The primary type class (Render
) has a single method
(render
) that renders an instance type as a
Textual
type.
class Render a where
render :: Textual t => a -> t
The secondary type class (RenderDefault
) has a single
method (renderDefault
) that has the same type signature as
the render
method.
class RenderDefault a where
renderDefault :: Textual t => a -> t
Many RenderDefault
instances for core data types are
defined. For example, the instance for the Int
type uses
the Show
instance for Int
to render a
Textual
type.
instance RenderDefault Int where
renderDefault = renderWithShow
This is likely the desired behavior in the vast majority of use
cases, but there are many other possible ways to render an
Int
. For example, some applications may display negative
numbers in parenthesis.
Since there is only one method, only one default needs to be defined
in the Render
type class definition.
class Render a where
render :: Textual t => a -> t
render :: (RenderDefault a, Textual t) => a -> t
default= renderDefault render
The DefaultSignatures
extension must be enabled. This may be done by adding the extension in
the .cabal
file, or it may be enabled in the module source
by adding the following language pragma.
{-# LANGUAGE DefaultSignatures #-}
Both the primary and secondary type classes should be exported by the module.
module Data.TTC
...
( Render(..)
, RenderDefault(..)
, ...
)
When a user imports the module, the RenderDefault
instances are loaded and no Render
instances are
loaded.
import qualified Data.TTC as TTC
If the user wants to load the instance for Int
, the
following line loads a Render
instance using the
RenderDefault
implementation for Int
.
instance TTC.Render Int
Since neither Render
nor Int
is defined in
the user’s module, this is considered an orphan instance. The following
pragma can be used to hide the GHC warning.
{-# OPTIONS_GHC -fno-warn-orphans #-}
While it is acceptable to use orphan instances in the application
layer, they should be avoided in shared libraries. In cases where the
shared library needs to use a default instance implementation, the
RenderDefault
type class can be used directly by calling
the TTC.renderDefault
method.