Skip to main content

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

  default render :: (RenderDefault a, Textual t) => a -> t
  render = renderDefault

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.

Author

Travis Cardwell

Published

Tags