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 -> tThe 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 -> tMany 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
default render :: (RenderDefault a, Textual t) => a -> t
render = renderDefaultThe 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 TTCIf 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 IntSince 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.